Skip to content

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