Architecture Overview¶
How the 218 tools flow from an agent prompt to a Google Workspace mutation — and back.
High-Level Flow¶
graph LR
A[OpenCode Agent] -->|STDIO<br/>JSON-RPC| B[MCP Server<br/>TypeScript]
B -->|HTTPS POST<br/>JSON + Bearer Token| C[Apps Script Web App<br/>script.google.com]
C -->|Workspace APIs| D[Google Drive]
C -->|Workspace APIs| E[Gmail API]
C -->|Workspace APIs| F[Calendar API]
C -->|Workspace APIs| G[Sheets API]
C -->|Workspace APIs| H[Slides API]
C -->|Workspace APIs| I[Docs API]
C -->|Workspace APIs| J[Tasks API]
C -->|Workspace APIs| K[Forms API] | Layer | Technology | Responsibility |
|---|---|---|
| Agent | OpenCode / LLM | Calls MCP tools via JSON-RPC over STDIO |
| MCP Server | TypeScript (Node 20+) | Registers tools with Zod schemas, validates input, calls proxy via HTTPS |
| Apps Script Proxy | Google Apps Script (V8) | Authenticates via bearer token, rate-limits, dispatches to Workspace APIs |
| Workspace APIs | DriveApp, GmailApp, CalendarApp, etc. | Perform the actual CRUD operations as the deploying user |
Package Structure¶
workspace-lite/
├── shared/ # Shared TypeScript package (single source of truth)
│ ├── src/
│ │ ├── catalog/ # Catalog SSOT: every tool's schema, action, risk, formatter
│ │ │ ├── index.ts # ToolSpec type, catalog assembly, registerCatalogTools
│ │ │ ├── risk.ts # TokenClass / risk-class resolution
│ │ │ ├── helpers.ts # Catalog helpers (naming, actions, flags)
│ │ │ └── services/ # drive.ts, gmail.ts, … one file per service (218 tools)
│ │ ├── schemas.ts # Zod schemas referenced by catalog entries
│ │ ├── proxy-client.ts # createProxyClient: class-token selection + HTTPS POST
│ │ ├── response.ts # ProxyResponse interface, format helpers
│ │ └── index.ts # Barrel exports
│ └── apps-script/ # Shared Auth.gs / Response.gs templates (copied per service)
├── packages/
│ ├── drive/ # 44 tools
│ │ ├── src/index.ts # Thin MCP entry: registerCatalogTools(service) + stdio transport
│ │ └── apps-script/ # Code.gs (generated), Auth.gs, Response.gs, DriveService.gs
│ ├── gmail/ # 39 tools ── every service package has the same two-file shape
│ ├── calendar/ # 22 tools
│ ├── sheets/ # 33 tools
│ ├── slides/ # 25 tools
│ ├── docs/ # 26 tools
│ ├── tasks/ # 13 tools
│ ├── forms/ # 16 tools
│ └── cli/ # wslite CLI: second frontend over the same catalog
│ └── src/ # program.ts, doctor.ts, deployments.ts, repair.ts, security-audit.ts
├── config/
│ └── service-registry.json # Canonical service metadata (titles, scopes, health identity)
├── scripts/ # setup.sh (+ --profile/--services), setup-services.mjs,
│ # client-config.mjs, generate-proxy-shell.mjs, validators
├── skills/
│ ├── google-workspace/ # Agent usage skill (tool-catalog.md is generated)
│ └── workspace-lite-installer/ # Install/operate skill + hardened deploy scripts
├── tests/ # Node test runner suites incl. fake-clasp installer harness
├── tsconfig.base.json
├── package.json # Workspace root: build, validate, doctor/repair/security:audit
└── mkdocs.yml
The catalog is the load-bearing design: each tool exists exactly once in shared/src/catalog/services/<service>.ts as a ToolSpec (name, description, Zod schema, backend action, risk class, formatter, validators). Both frontends consume it — the MCP servers via registerCatalogTools, the wslite CLI via the same specs — so tool behavior cannot drift between surfaces.
Each service package follows an identical structure:
| File | Purpose |
|---|---|
src/index.ts | Thin MCP entry: McpServer + registerCatalogTools(service) + StdioServerTransport |
shared/src/proxy-client.ts | Reads env vars, selects the narrowest matching class token, POSTs to the Apps Script web app |
apps-script/Code.gs | Generated from config/service-registry.json (npm run generate:proxy-shell): doGet health/bootstrap, doPost validate → rate-limit → dispatch |
apps-script/Auth.gs | Copy of the shared template: token classes, bootstrapping, validation, throttling |
apps-script/Response.gs | Copy of the shared template: structured JSON response helpers |
apps-script/{Service}Service.gs | Action policies (ACTION_POLICIES with risk classes and allowlists) + action handlers |
Zod Validation Layer¶
Every tool parameter is validated by Zod schemas in shared/src/schemas.ts before the proxy call.
// Example: file creation schema (shared/src/schemas.ts:38-43)
export const fileCreateSchema = {
name: z.string().describe('File name.'),
content: z.string().describe('File content.'),
mimeType: z.string().default('text/plain').describe('MIME type.'),
parentId: z.string().optional().describe('Parent folder ID.'),
}
Validation happens at the MCP Server layer before the request reaches the Apps Script proxy:
Agent call → McpServer tool handler
→ Zod validates args against schema
→ Invalid? → return error to agent
→ Valid? → callProxy(action, validatedArgs)
→ fetch(PROXY_URL, { body: JSON.stringify({...}) })
Key design decisions:
| Decision | Rationale |
|---|---|
| Zod on the TypeScript side, not in Apps Script | Type-safe validation before network call. Reduces wasted executions. |
Regex on IDs (/^[a-zA-Z0-9_-]+$/) | Prevents injection attacks at the validation layer |
min/max on counts and sizes | Protects against oversized requests. Sheets operations also have proxy-enforced cell limits; Gmail body fields are capped at 100KB. |
.default() for optional fields | Simpler agent calls; sensible defaults for pagination, MIME types, positioning |
Auth Flow¶
The authentication model eliminates OAuth from every API call by leveraging Apps Script’s execution identity.
One-Time Bootstrap¶
┌──────────┐
│ Admin │
└────┬─────┘
│ POST /exec
│ { "setupKey": "..." }
▼
┌─────────────────────────────────────────┐
│ Apps Script Web App (Code.gs:doPost) │
│ │
│ 1. Check isBootstrapped() → false │
│ 2. Validate bootstrap setup key │
│ 3. getOrCreateToken() │
│ → Generate UUID-derived token │
│ → Store in ScriptProperties │
│ 4. markBootstrapped() → set property │
│ 5. Return { token: "abc...xyz" } │
└─────────────────────────────────────────┘
│
▼
Save as env var
GOOGLE_WORKSPACE_*_PROXY_TOKEN
Per-Request Auth¶
┌──────────────┐ POST /exec
│ MCP Server │─────{ token, action, params }───►┌──────────────────────────┐
│ (proxy-client)│ │ Apps Script (Code.gs) │
└──────────────┘ │ doPost(e): │
│ 1. validateRequest(e) │
│ → Parse token from │
│ JSON body │
│ → Compare against │
│ stored token │
│ 2. isRateLimited() │
│ 3. Service.handle() │
│ 4. respond(result) │
└──────────────────────────┘
| Property | Value |
|---|---|
| Token format | Utilities.getUuid() plus a second UUID with dashes removed |
| Token storage | PropertiesService.getScriptProperties() — persists across deployments |
| Token extracted from | JSON body only (body.token) |
| Bootstrap one-shot | markBootstrapped() prevents re-bootstrap; returns FORBIDDEN on subsequent calls |
| Bootstrap setup key | setupKey must match the generated, untracked BootstrapSecret.gs value |
Token security
The token is a shared secret. Anyone with the token and deployment URL can call your web app as you. Store tokens in environment variables, never commit .env to version control.
Rate Limiting¶
Each Apps Script proxy enforces weighted request limits using CacheService.getScriptCache().
function isRateLimited(maxWeight, weight) {
var cache = CacheService.getScriptCache()
var key = 'rate_' + getCurrentAuthRateKey()
var count = parseInt(cache.get(key) || '0', 10)
if (count + weight > maxWeight) return true
cache.put(key, String(count + weight), 60) // 60-second TTL
return false
}
| Aspect | Detail |
|---|---|
| Rate limit | 100 weighted units per 60-second window |
| Per proxy | Separate rate limit for each of the 8 services |
| Response on limit | { success: false, error: { code: "RATE_LIMITED", message: "Too many requests. Try again in 60 seconds." } } |
| Implementation | CacheService — zero external dependencies, works across all instances of a deployment |
Handling rate limits
The MCP server will throw an error on rate-limited responses. The agent should retry after 60 seconds. For high-throughput workflows, use {service}_batch tools to pack up to 20 operations into a single request, but note that a batch costs the sum of its child action weights.
Batch Operations¶
Every service exposes a {service}_batch tool that executes up to 20 operations in a single HTTPS round-trip.
How Batch Works¶
Agent calls sheets_batch({ spreadsheetId, operations: [op1, op2, ..., opN] })
│
▼
MCP Server: validates N ≤ 20, passes to proxy
│
▼
Apps Script: forEach operation → handle(action, params) → collect result
│
▼
Response: { success: true, results: [
{ action: "rangeWrite", success: true, data: {...} },
{ action: "rangeFormat", success: true },
{ action: "chartCreate", success: false, error: { code: "BAD_REQUEST", message: "..." } },
] }
| Property | Detail |
|---|---|
| Max operations | 20 per batch (enforced by Zod .max(20)) |
| Execution | Sequential within the Apps Script proxy (not parallel) |
| Error handling | Per-operation error collection. One failed operation does not stop the batch. |
| Response format | { success: true, results: [{ action, success, data?, error? }] } |
| Use case | Compound spreadsheet setup, multi-slide deck creation, multi-file Drive operations |
Batch vs. Individual Calls¶
| Aspect | Individual tools | Batch tool |
|---|---|---|
| Round-trips | 1 per operation | 1 total |
| Error isolation | Each call independent | Errors collected per-op |
| Rate limit impact | Counts by action weight | Counts by the sum of child action weights |
| Atomicity | Each call is isolated | No transaction guarantees |
Proxy Request Lifecycle¶
A single tool call goes through this pipeline:
sequenceDiagram
participant Agent
participant Server as MCP Server (TS)
participant Zod
participant Proxy as proxy-client
participant WebApp as Apps Script Web App
participant Auth as Auth.gs
participant Cache as CacheService
participant Service as {Service}Service.gs
participant API as Google API
Agent->>Server: JSON-RPC: tools/call { name, arguments }
Server->>Zod: Validate arguments against schema
alt Invalid
Zod-->>Agent: Error: validation failed
else Valid
Zod-->>Server: Validated args
Server->>Proxy: callProxy(action, params)
Proxy->>WebApp: POST { token, action, params }
WebApp->>Auth: validateRequest(e)
alt Invalid token
Auth-->>Agent: { success: false, error: "UNAUTHORIZED" }
else Valid
Auth->>Cache: isRateLimited(token, 100)
alt Rate limited
Cache-->>Agent: { success: false, error: "RATE_LIMITED" }
else OK
WebApp->>Service: handle(action, params)
Service->>API: DriveApp / GmailApp / etc.
API-->>Service: Result
Service-->>WebApp: { success: true, data }
WebApp-->>Proxy: JSON response
Proxy-->>Server: ProxyResponse
Server-->>Agent: Formatted tool result
end
end
end Why Apps Script?¶
| Benefit | Detail |
|---|---|
| No OAuth per call | Apps Script web apps run as USER_DEPLOYING. Your identity, your permissions. No service accounts, no refresh tokens, no consent screens after deploy. |
| Native API access | DriveApp, GmailApp, CalendarApp, SpreadsheetApp, SlidesApp, DocumentApp — first-class Google APIs with no REST overhead |
| Free tier | Apps Script is free. 20,000 URL Fetch calls/day. 6-min execution per request. |
| HTTPS transport | Web apps are served over HTTPS with Google-managed certificates |
| No infrastructure | No servers to manage. Google hosts the proxy. |
Next Steps¶
| Topic | Link |
|---|---|
| Security model deep-dive | Security |
| Drive service architecture | Drive Service |
| Batch API reference | Batch Operations |
| Quotas and limits | Quotas & Limits |