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/mcpcreateMcpHandler)
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

sequenceDiagram participant C as "MCP client" participant W as "Worker (index.ts)" participant A as "auth.ts" participant S as "server.ts" participant API as "swapps-app API" C->>W: "POST /mcp (edge token header)" W->>A: "extractToken + validateEdgeToken" A-->>W: "principal {username} or null" alt "token invalid" W-->>C: "401 unauthorized" else "token valid" W->>W: "audit log line (user, method, ts)" W->>S: "createMcpHandler(buildServer(env))" S->>API: "GET path (Authorization: Token service-token)" API-->>S: "JSON" S-->>C: "MCP result (text content)" end

How the pieces fit

src/index.ts (Worker entry)

The default export fetch(request, env, ctx) handler:

  • GET /health returns { status: "OK", ts } (no auth) for uptime checks.
  • Any path other than /mcp returns 404.
  • For /mcp: extracts and validates the edge token. On failure it returns 401 with a WWW-Authenticate: Bearer header 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 via createMcpHandler(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 from Authorization: Token <x> or Authorization: Bearer <x> (case-insensitive), falling back to the X-MCP-Token header.
  • validateEdgeToken(env, token) parses EDGE_TOKENS (a JSON token -> username map) 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, or null if 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 of string | number | boolean (DRF filters / pagination).
  • pkSchema / contract_pkz.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 path onto SWAPPS_API_BASE_URL (default https://app.swapps.com/api), trimming stray slashes, and appends params as query string.
  • Sends Authorization: Token <SWAPPS_API_TOKEN> (the service token), Accept: application/json, and User-Agent: <SWAPPS_API_USER_AGENT> (default swapps-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 returns HTTP <status> error for <url>: <body>; on a thrown error it returns Error: <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, the api_request escape 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 401 does not reveal detail.
  • WAF compatibility. The swapps-app API sits behind a WAF skip rule on /api/. The Worker sends User-Agent: swapps-mcp/1.0 so it stays compatible if that rule is later narrowed.