Architecture¶
The MCP Worker is a small, stateless TypeScript Cloudflare Worker that implements the MCP Streamable HTTP transport and forwards each tool call to the swapps-app REST API. There is no database, KV, Durable Object, or queue — all state lives in the two secrets (SWAPPS_API_TOKEN, EDGE_TOKENS).
Tech stack¶
| Category | Technology |
|---|---|
| Runtime | Cloudflare Workers (nodejs_compat, compatibility date 2025-06-01) |
| Language | TypeScript (ES2022, ESM, moduleResolution: bundler) |
| MCP SDK | @modelcontextprotocol/sdk 1.23.0 |
| MCP handler | agents ^0.2.0 (agents/mcp → createMcpHandler) |
| Validation | zod ^3.23.8 |
| Tooling | wrangler (dev/deploy), vitest (tests), eslint + typescript-eslint (lint) |
Project structure¶
swapps-mcp-worker/
├── src/
│ ├── index.ts # Worker entry: routing, edge-token auth, MCP handler
│ ├── server.ts # buildServer(env): the 18 read-only MCP tools
│ ├── auth.ts # extractToken + validateEdgeToken (timing-safe)
│ ├── api-client.ts # apiGet(env, path, params): read-only downstream GET
│ ├── env.ts # Env type (secrets + vars)
│ └── stubs/ai.ts # stub aliased over the optional `ai` package
├── tests/
│ ├── auth.test.ts # token extraction + validation
│ └── api-client.test.ts # URL building, headers, error handling
├── wrangler.toml # bindings, vars, alias, observability, routes
├── package.json # scripts + deps
├── tsconfig.json # @/* path alias -> src/*
├── vitest.config.ts # node env, @/* alias
├── eslint.config.js
├── .dev.vars.example # local secrets template (copy to .dev.vars)
└── README.md
Request lifecycle¶
How the pieces fit¶
src/index.ts (Worker entry)¶
The default export fetch(request, env, ctx) handler:
GET /healthreturns{ status: "OK", ts }(no auth) for uptime checks.- Any path other than
/mcpreturns404. - For
/mcp: extracts and validates the edge token. On failure it returns401with aWWW-Authenticate: Bearerheader and{ "error": "unauthorized" }. - On success it writes a JSON audit log line (
evt: "mcp_request",user,method,ts) — captured by Cloudflare observability — and then builds a fresh MCP server per request viacreateMcpHandler(buildServer(env), { route: '/mcp' }).
Fresh server per request
A new McpServer instance is created on every request (a requirement of the MCP SDK ≥ 1.26). A global, reused server instance must not be shared across requests.
src/auth.ts (edge-token auth)¶
extractToken(request)reads the token fromAuthorization: Token <x>orAuthorization: Bearer <x>(case-insensitive), falling back to theX-MCP-Tokenheader.validateEdgeToken(env, token)parsesEDGE_TOKENS(a JSONtoken -> usernamemap) and compares the supplied token against each entry with a constant-time comparison (timingSafeEqual) to avoid leaking token content via timing. It returns{ username }on a match, ornullif the token is missing/unknown or the map is malformed/empty.
The edge token is consumed at the edge and never forwarded downstream (per the MCP spec) — it controls access to the MCP server, not data scoping.
src/server.ts (tool definitions)¶
buildServer(env) constructs an McpServer named swapps-app and registers the 18 read-only tools (see Tools reference). Inputs are validated with zod:
paramsSchema— optional record ofstring | number | boolean(DRF filters / pagination).pkSchema/contract_pk—z.number().int().
Every tool calls apiGet(env, path, params) and wraps the result with the ok() helper into MCP text content.
src/api-client.ts (downstream client)¶
apiGet(env, path, params) is the only path to the downstream API:
- Joins
pathontoSWAPPS_API_BASE_URL(defaulthttps://app.swapps.com/api), trimming stray slashes, and appendsparamsas query string. - Sends
Authorization: Token <SWAPPS_API_TOKEN>(the service token),Accept: application/json, andUser-Agent: <SWAPPS_API_USER_AGENT>(defaultswapps-mcp/1.0). - Uses a 30s timeout via
AbortController. - On success returns pretty-printed JSON (or
"null"for an empty body). On a non-2xx it returnsHTTP <status> error for <url>: <body>; on a thrown error it returnsError: <message>— it never throws back to the handler.
src/env.ts (typed bindings)¶
Declares the Env type: secrets SWAPPS_API_TOKEN and EDGE_TOKENS, and optional vars SWAPPS_API_BASE_URL and SWAPPS_API_USER_AGENT. See the binding table in the Overview.
src/stubs/ai.ts (bundle stub)¶
The agents package dynamically import("ai") (the Vercel AI SDK) inside its MCP client code to convert JSON schemas. This Worker only uses the MCP handler (server side), so that path never executes at runtime. wrangler.toml aliases ai to this tiny stub ([alias] ai = "./src/stubs/ai.ts") so esbuild does not bundle the large real package.
wrangler.toml highlights¶
| Setting | Value / purpose |
|---|---|
main |
src/index.ts |
compatibility_date |
2025-06-01 |
compatibility_flags |
["nodejs_compat"] |
account_id |
Cloudflare account the Worker deploys to |
[vars] |
SWAPPS_API_BASE_URL, SWAPPS_API_USER_AGENT |
[alias] |
ai = "./src/stubs/ai.ts" (avoids bundling the real ai package) |
[observability.logs] |
enabled = true (captures the audit log lines) |
[[routes]] |
Custom domain mcp.swapps.com — kept commented until the wrangler token has zone permissions for swapps.com; first deploy uses the *.workers.dev URL |
Security notes¶
- Least-privilege service token. Use a dedicated read-only DRF user (
view_*only). Even though all tools are GETs, theapi_requestescape hatch and the downstream identity must not be able to write. - Edge token isolation. The edge token is never forwarded downstream; comparison is timing-safe; TLS terminates at the edge; the
401does not reveal detail. - WAF compatibility. The
swapps-appAPI sits behind a WAFskiprule on/api/. The Worker sendsUser-Agent: swapps-mcp/1.0so it stays compatible if that rule is later narrowed.