Skip to main content
Glama
AayushCharde

mcp-worker-template

by AayushCharde
README.md
# mcp-worker-template

**An MCP server on Cloudflare Workers with zero runtime dependencies.** No SDK, no framework — a hand-rolled [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http) transport in ~190 lines you can read in one sitting, plus one file where your tools live.

```bash
git clone https://github.com/AayushCharde/mcp-worker-template my-mcp-server
cd my-mcp-server && npm install && cp .dev.vars.example .dev.vars
npm run dev   # → http://localhost:8787/mcp
```

Test it:

```bash
curl -s http://localhost:8787/mcp \
  -H 'Authorization: Bearer dev-token-change-me' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hello"}}}'
```

Connect it to Claude Code:

```bash
claude mcp add --transport http my-server http://localhost:8787/mcp \
  --header "Authorization: Bearer dev-token-change-me"
```

## Why no SDK?

Most MCP server examples pull in the official SDK, an HTTP framework, and a session layer. For a **tools-only** server, the protocol surface is four methods: `initialize`, `tools/list`, `tools/call`, `ping`. That's small and stable enough to own outright:

- **Zero runtime dependencies** — nothing to update, nothing to audit, no bundle bloat. The Worker is two source files.
- **Nothing is magic** — when a client misbehaves, you read your own 190-line transport, not a stack trace through someone else's abstraction.
- **Stateless by design** — no sessions, no SSE stream, no Durable Objects. Every request is self-contained, which is exactly the shape Cloudflare Workers want.

The trade-off: no server→client notifications, resources, or prompts. If you need those, use the official SDK — this template is for the (very common) case where you just want tools.

## Adding a tool

Everything you touch is in [`src/tools.ts`](src/tools.ts). A tool is a name, a description, a JSON Schema for its input, and a `run` function:

```ts
{
  name: 'get_weather',
  description: 'Current weather for a city.',
  inputSchema: {
    type: 'object',
    properties: { city: { type: 'string' } },
    required: ['city'],
    additionalProperties: false
  },
  async run(args, { env }) {
    const res = await fetch(`https://api.example.com/weather?q=${args.city}`);
    if (!res.ok) throw new ToolError(`Weather API returned ${res.status}`);
    return res.json();
  }
}
```

Throw `ToolError` for failures the client should see verbatim; any other exception is masked as a generic internal error so implementation details never leak. Need bindings (KV, D1, a database URL)? Add them to `Environment` in `src/index.ts` — they arrive in every tool via `ctx.env`.

## Auth model — read this before deploying

Auth is a **single static bearer token** (`MCP_BEARER_TOKEN`), compared in constant time. That token is the entire trust boundary: anyone who has it can call every tool. This is the right shape for a personal server or an internal integration; it is **not** multi-tenant. If different users need different permissions, you need OAuth (see the [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)) — at which point the official SDK starts earning its keep.

```bash
openssl rand -hex 32                      # generate a real token
npx wrangler secret put MCP_BEARER_TOKEN  # set it in production
```

## Deploy

```bash
npm run check     # wrangler types + tsc --noEmit
npm run deploy    # → https://my-mcp-server.<your-subdomain>.workers.dev/mcp
```

Rename the worker in `wrangler.jsonc` first.

## Transport details (for the curious)

The spec-relevant behavior, all in [`src/index.ts`](src/index.ts):

| Behavior | Implementation |
|---|---|
| Protocol versions | Negotiates `2025-06-18` / `2025-03-26` / `2024-11-05` — echoes the client's version if supported, else offers the newest |
| Notifications | No response body; HTTP `202 Accepted` |
| JSON-RPC batches | Accepted; responses filtered of notification slots |
| `GET /mcp` | `405 Method Not Allowed` — stateless server, no SSE stream to offer |
| Unknown method | `-32601`; unknown tool `-32602`; parse failure `-32700` |
| CORS | Permissive by default (`*`) — tighten `Access-Control-Allow-Origin` if a browser client will hold your token |

## Real-world example

This transport was extracted from [Junto](https://github.com/AayushCharde/Junto), a keyboard-first task tracker whose MCP server lets Claude list, create, and update tasks in a live Postgres database — see [`apps/mcp`](https://github.com/AayushCharde/Junto/tree/main/apps/mcp) there for what a production instance of this pattern looks like (per-request DB clients, workspace scoping, activity logging).

## License

[MIT](LICENSE) © Aayush Charde