Skip to main content
Glama
jkpe

mcp-fetch-worker

by jkpe
README.md
# mcp-fetch-worker

A generic [MCP](https://modelcontextprotocol.io/) connector for bots and agents that need to call your self-hosted APIs without handing over long-lived credentials.

Put [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/) in front of this Worker with **Managed OAuth**, and bots like [Grok Bot](https://x.ai/bot) or [OpenClaw](https://openclaw.ai/) can connect via Dynamic Client Registration (DCR). You approve access when the bot first connects; revoke it any time from Access. The bot gets a scoped token tied to the connector, not a generic API key it can reuse elsewhere.

**Production MCP URL:** `https://<your-domain>/mcp`

## Why this exists

Managed bots are good at tool calling, but most homelab and self-hosted apps don't ship an MCP server. You could:

- **Use a service token**: persistent access via custom headers. Fine for scripts; risky for bots you don't fully trust.
- **Let the bot use Chrome**: it opens your web UI, you sign in through SSO, repeat. Works, but burns tokens and doesn't scale.
- **Build a bespoke MCP per app**: correct, but a lot of work for a Home Assistant API or a random internal dashboard.

This Worker is the middle ground: one thin MCP connector that exposes a single `http_fetch` tool. Point it at APIs on the same origin (already behind Access), and the bot can `GET`/`POST` them using the OAuth token you approved. No dedicated MCP server required on the other end.

See [First World Problem: How do I grant my bots access to my stuff without it wrecking everything?](https://www.jackpearce.co.uk/posts/granting-bots-access-without-wrecking-everything) for the full write-up.

## How it works

```
Bot (Grok Bot, OpenClaw, Cursor, …)
  → OAuth via Cloudflare Access Managed OAuth (you approve)
  → MCP at /mcp (this Worker)
  → http_fetch to allowed origins on your domain
  → Access JWT forwarded on outbound requests
```

1. **Access** handles authentication: Managed OAuth + DCR. The Worker is not an OAuth server; it validates the `Cf-Access-Jwt-Assertion` Access forwards.
2. **This Worker** serves Streamable HTTP MCP with one tool: `http_fetch`.
3. **Your APIs** sit on the same origin behind Access. Create **Linked App Token** policies so the forwarded JWT is accepted downstream.

Outbound fetches are restricted to the Worker's own origin by default (`ALLOWED_ORIGINS` overrides this).

## What it does

- Serves MCP at `/mcp` (and `/sse` as a legacy alias) using the [Agents SDK](https://developers.cloudflare.com/agents/) `createMcpHandler`
- Exposes one tool, `http_fetch`, for GET/POST requests against allowed origins
- Validates `Cf-Access-Jwt-Assertion` against your team's JWKS when `CF_ACCESS_TEAM_DOMAIN` and `CF_ACCESS_AUD` are set
- Forwards the caller's Access JWT on outbound fetches (`Cf-Access-Jwt-Assertion`, `Cf-Access-Token`, `Authorization: Bearer`)

## Deploy

```bash
npm install
npm run cf-typegen   # regenerate Env types from wrangler.jsonc
npx wrangler deploy
```

Configuration lives in `wrangler.jsonc`. Workers Logs and Traces are enabled by default via the `observability` block.

### Custom domain route

Add a Worker route in `wrangler.jsonc` (or via the dashboard):

```jsonc
"routes": [
  {
    "pattern": "example.com/mcp*",
    "zone_name": "example.com"
  }
]
```

Then redeploy. The MCP endpoint is `https://<your-domain>/mcp`.

### Environment variables

Set in `wrangler.jsonc` `vars`, as [secrets](https://developers.cloudflare.com/workers/configuration/secrets/) (`wrangler secret put`), or in `.dev.vars` for local dev (see `.dev.vars.example`):

| Variable | Required | Description |
|----------|----------|-------------|
| `CF_ACCESS_TEAM_DOMAIN` | Production | `https://<team>.cloudflareaccess.com` |
| `CF_ACCESS_AUD` | Production | AUD tag from your Access application |
| `ALLOWED_ORIGINS` | No | Comma-separated fetch allowlist (default: Worker origin) |
| `MAX_RESPONSE_BYTES` | No | Truncate bodies above this size (default: 65536) |
| `DEFAULT_HEADERS` | No | JSON object of default outbound headers (secret; see below) |

When `CF_ACCESS_*` vars are unset, the Worker still serves MCP (useful for `wrangler dev`) but does not validate Access JWTs. **Set both vars in production.**

### Default headers for API keys

Store extra outbound headers as a Worker secret so `http_fetch` can call APIs that need a key without putting secrets in chat or baking one API into the Worker:

```bash
wrangler secret put DEFAULT_HEADERS
# paste: {"X-Api-Key":"your-key-here"}
```

For local dev, set a non-empty value in `wrangler.jsonc` `vars`, pass `--var DEFAULT_HEADERS='{"X-Api-Key":"..."}'` to `wrangler dev`, or use `wrangler secret put` on the deployed Worker. When `secrets.required` is configured, `.dev.vars` only loads those secret names, so put `DEFAULT_HEADERS` in `vars` or use `--var` locally.

`DEFAULT_HEADERS` must be a JSON object with string values, e.g. `{"X-Api-Key":"..."}`. On every `http_fetch` call, those headers are merged into the outbound request. Headers you pass in the tool call override defaults on name conflicts. Access JWT forwarding (`Cf-Access-Jwt-Assertion`, `Cf-Access-Token`, `Authorization`) always wins and cannot be overridden by defaults.

Unset or empty `DEFAULT_HEADERS` adds no extra headers.

## Cloudflare Access setup

1. Add a **self-hosted Access application** for your MCP URL (e.g. `https://<your-domain>/mcp` or a path covering `/mcp*`)
2. Enable **Managed OAuth** on the application (Advanced settings); this enables DCR for MCP clients
3. Copy the application's **AUD** tag into `CF_ACCESS_AUD`
4. Set `CF_ACCESS_TEAM_DOMAIN` to your team domain (e.g. `https://<team>.cloudflareaccess.com`)
5. Configure Access policies for who may connect: time-bound, email-based, or whatever fits your approval model

For downstream Access-protected APIs on the same origin, create **Linked App Token** policies on those applications so the forwarded JWT is accepted.

## MCP client configuration

Paste this URL into Cursor, Grok Bot, OpenClaw, or any Streamable HTTP MCP client:

```
https://<your-domain>/mcp
```

Use **Streamable HTTP** transport (not legacy SSE). The client will OAuth through Access Managed OAuth on first connect; that's your human-in-the-loop approval step.

## Local development

```bash
npm run dev
```

MCP is available at `http://localhost:8787/mcp` without Access validation.

## http_fetch tool

```json
{
  "url": "/api/example",
  "method": "GET"
}
```

- `url`: absolute URL or same-origin path
- `method`: `GET` (default) or `POST`
- `headers`: optional extra headers (override `DEFAULT_HEADERS` on conflict)
- `body`: optional POST body

`DEFAULT_HEADERS` (secret) are applied first; tool `headers` override them. Access JWT headers are always applied last.

Returns JSON with `status`, `headers`, `body`, and `truncated` flag.