mcp-worker-kit
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-worker-kitshow me the documents I created this week"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-worker-kit
A remote Model Context Protocol server on Cloudflare Workers, with OAuth 2.1 sign-in, permission tiers and an audit trail. Connect Claude, ChatGPT, Cursor or any MCP client to your data. The assistant acts as the person using it and never goes further than that person may.
Extracted from the MCP server behind mcp.moraph.com, with Moraph's own tools, accounts and data taken out.
Two endpoints.
/publicneeds no sign-in and serves public tools./mcpneeds an OAuth token and adds what the signed-in person's account may see and do.OAuth 2.1 the way the MCP spec asks for it. Discovery metadata (RFC 9728, RFC 8414), dynamic client registration (RFC 7591), authorization code with PKCE S256 only, rotating refresh tokens with reuse detection, revocation (RFC 7009), and audience-bound tokens (RFC 8707).
Bring your own sign-in. The server never checks a password. A small
IdentityProviderinterface asks your existing sign-in who the person is. Cloudflare Access is built in.Read by default, write by consent. People always grant
read.writeis a separate checkbox on the consent screen, and read-only connections never see write tools.Arguments checked before tools run. Each tool's
inputSchemais enforced, so a tool only ever sees arguments that fit it. Mistakes go back to the assistant as a message it can act on.Every call recorded, never its contents.
tool_callskeeps who, which tool, the outcome and the time taken, refusals included. It never stores arguments, results or anything else the caller typed.Small and dependency-free. TypeScript, D1 and no runtime dependencies. It's small enough to read in one sitting, and every module explains its design decisions with the RFC or MCP spec section behind them.
Deploy
You need Node 22.18 or later and a Cloudflare account.
git clone https://github.com/moraph-com/mcp-worker-kit && cd mcp-worker-kit
npm install
npx wrangler d1 create mcp-worker-kit # put the printed database_id in wrangler.jsonc
npm run db:migrate
npx wrangler secret put IP_SALT # any long random string, e.g. from `openssl rand -hex 32`
npx wrangler deployYour server is now at https://mcp-worker-kit.<your-subdomain>.workers.dev, and /public works straight away. To let
people sign in, set up an identity provider (below).
Tokens are bound to the host they were issued on. If you add a custom domain, connect assistants through that domain:
tokens issued there won't work on the workers.dev address, and the reverse is true too.
Try it locally
cp .dev.vars.example .dev.vars # turns on DEV_LOGIN=1, the name-only development sign-in
npm run db:migrate:local
npm run dev # http://localhost:8787DEV_LOGIN=1 turns on a name-only sign-in form. It works only on localhost and 127.0.0.1, whatever the setting
says. Never set it anywhere else.
Related MCP server: MCPGate
Connect an assistant
Client | How |
Claude (web, desktop) | Settings → Connectors → Add custom connector → |
Claude Code |
|
Public tools only | Use |
MCP Inspector |
|
(*) The Inspector's default proxy mode works as is. Its direct (in-browser) mode sends an Origin header, so add that
origin to ALLOWED_ORIGINS, e.g. http://localhost:6274.
The assistant finds the sign-in endpoints by itself: a request to /mcp without a token gets a 401 whose
WWW-Authenticate header points to the metadata.
How it fits together
sequenceDiagram
participant A as Assistant (MCP client)
participant W as Worker
participant B as Person's browser
participant I as Your sign-in (IdentityProvider)
A->>W: POST /mcp (no token)
W-->>A: 401, WWW-Authenticate → /.well-known/oauth-protected-resource
A->>W: GET metadata, POST /oauth/register
A->>B: open /oauth/authorize (PKCE S256, resource=/mcp)
B->>W: /oauth/authorize → /oauth/continue
W->>I: who is this?
I-->>W: user (or redirect to sign in)
W-->>B: consent screen: read, and write only if ticked
B->>W: Allow
W-->>B: 302 to the assistant with a one-time code (+ iss)
A->>W: POST /oauth/token (code + verifier)
W-->>A: access token (1 h) + refresh token (30 days, rotated)
A->>W: POST /mcp tools/list, tools/call (Bearer)
W->>W: re-check token audience, grant and person, filter tools, run, record the callFile | What |
| Routes, the |
| JSON-RPC over Streamable HTTP (stateless JSON), |
| The authorization server and |
|
|
| Tool type, argument checking ( |
| Server name, scopes, instructions sent to assistants, every lifetime and limit |
| Hashing, the byte-capped body reader, per-network keys, JSON and strict-CSP HTML helpers |
| Tables, with what each column is for |
Settings
Setting | Where | What |
|
| Turn on Cloudflare Access sign-in (below) |
|
| Comma-separated emails with the |
|
| Browser origins allowed to call |
|
| Salt for the per-network keys stored with registrations and sign-in attempts |
|
| Optional per-network rate limit on the anonymous endpoints (commented out) |
|
|
|
Lifetimes, limits, scopes, server name |
| Access token 1 h, refresh 30 days, connection 30 days max, audit 13 months, … |
Security model
Secrets are stored hashed. Client secrets, authorization codes, access and refresh tokens, and dev sessions are stored as SHA-256 only. A database leak doesn't hand out working tokens.
Codes and refresh tokens are single-use. Codes last 5 minutes. If a code or refresh token is used twice, the server assumes it leaked and revokes the whole connection. "Used" is claimed with a conditional update, so two requests racing with the same token can't both win.
Tokens work only where they were issued for. Each token records its resource (RFC 8707,
<origin>/mcp), and/mcprefuses tokens issued for anything else.Access is checked on every call, not once. Each request re-checks the token, the grant and the person (
users.disabled_at, or your provider'slookupUser), and the person's roles as they are now. Disabling someone stops all their assistants at once, and token refreshes for them stop too.Connections end. A connection lasts at most
GRANT_MAX_DAYS(30) however often it's refreshed, and then the person signs in through your identity provider again. Someone removed from your sign-in loses their assistants within that time, even when the provider can't be asked directly (Cloudflare Access can't).The consent step is tied to one browser. An authorization request only continues in the browser that started it (an
HttpOnly,__Host-cookie). The approval form checksOrigin.Redirects are strict. Redirect URIs must match a registered one exactly. The one exception is the port of a loopback http URI, which native apps choose at run time (RFC 8252). URIs can be https, loopback http or a desktop-app scheme, never
javascript:,data:or fragments. Registration is open, so the server never redirects anyone to an address they haven't seen first. Errors in an authorization request are shown on a page with a link back, never redirected automatically (RFC 9700 §4.11.2). The consent screen names where the person will return to, and warns when that's an app on their own computer.HTML pages have strict headers. There are no scripts at all. The policy is
default-src 'none', the one inline stylesheet is allowed by hash,frame-ancestors 'none',X-Frame-Options: DENY,no-store, andform-actionis limited to this site and the assistant's own return address.MCP endpoints check
Origin. A browser request from an origin that isn't allowed gets 403, as the Streamable HTTP spec requires against DNS rebinding.Requests are bounded. Bodies are capped (64 KB for MCP, 16 KB for OAuth, 4 KB for the dev sign-in) and counted in bytes as they're read, so an oversized request is never held in memory. Registrations (30 per hour) and pending sign-ins (60 per 15 minutes) are limited per network, which means an IPv4 address or an IPv6 /64. The optional
RATE_LIMITERbinding covers every anonymous endpoint.Calls are audited without their contents.
tool_callsrecords refusals and failures with the message the assistant saw. For argument errors, which can quote what was typed, it records a fixed message instead. The cron job removes rows after 13 months.
What assistants can't do: keep it that way
The pattern this kit is built for:
Reading is what assistants are good at. Make it rich.
Everyday actions like creating a note, raising a ticket or adding a comment go behind
write: true, which requires the person to opt in. Tool descriptions tell the assistant to confirm the details first.Anything consequential stays on your own site, behind your own confirmation screens. That includes approvals, deletions of other people's data, payments, role or access changes, invitations and downloads of confidential documents. Don't write tools for them. Give the assistant a link instead.
Plugging in your sign-in
src/identity.ts picks a provider from the environment:
Setting | Provider |
| Cloudflare Access (below) |
| Development name-only sign-in |
nothing | None. |
Cloudflare Access. Create an Access application that covers https://<your-host>/oauth/continue, and only that
path, because assistants call the other endpoints directly and can't pass an Access login. Set ACCESS_TEAM_DOMAIN to
your team domain and ACCESS_AUD to the application's AUD tag. The Worker verifies the Cf-Access-Jwt-Assertion
JWT: an RS256 signature against your team's published keys (cached, and refetched when Access rotates them), then
audience, issuer and expiry. ADMIN_EMAILS gives listed people the admin role, re-read on every call. Access is
only asked at sign-in, so GRANT_MAX_DAYS decides how long a connection outlives someone's removal from Access. To
cut someone off sooner, set users.disabled_at.
To use something else, such as your app's session cookie, Auth0, Okta or Clerk, implement the interface and return it from
providerFor():
export const myProvider: IdentityProvider = {
name: 'my-app',
async currentUser(request, env) {
const session = await mySession(request, env); // your existing cookie or JWT check
return session
? { id: `app:${session.userId}`, name: session.name, email: session.email, roles: session.roles }
: null;
},
loginUrl: (next) => `https://app.example.com/login?return_to=${encodeURIComponent(`https://mcp.example.com${next}`)}`,
// Optional: re-check on every tool call and refresh (still employed? still allowed?). Return null to end their connections.
async lookupUser(id, env) {
return myUserById(id.replace(/^app:/, ''), env);
},
};Never write a provider that accepts a password typed into this server. The point is that the assistant's sign-in goes through the sign-in your people already trust, with its two-step verification and its rules.
Adding tools
A tool is an object in src/tools/, added to TOOLS in src/tools/index.ts:
import { all } from '../util.ts';
import { lim, type Tool } from './types.ts';
export const listInvoices: Tool = {
name: 'list_invoices',
title: 'List invoices',
description: 'Invoices for your organization, newest first.',
inputSchema: {
type: 'object',
properties: { limit: { type: 'integer', minimum: 1, maximum: 50 } },
additionalProperties: false,
},
annotations: { readOnlyHint: true },
access: (user) => user.roles.includes('billing'), // or 'public' | 'signed_in'
async run(c, args) {
// Scope every query to the person: orgOf() is your own lookup.
return { invoices: await all(c.env, 'SELECT … WHERE org_id = ? LIMIT ?', orgOf(c.user!), lim(args.limit)) };
},
};accessdecides who sees the tool.write: truealso hides it from signed-in connections without thewritescope.toolsFor()insrc/mcp.tsis the only place that decision is made, andtools/calluses the same check. (A write tool withaccess: 'public'is offered on/public. Use that only for something like a contact form.)inputSchemais enforced beforerun(src/tools/validate.ts): types,required,additionalProperties: false,enum, lengths, ranges and array items. Other keywords such asformatandpatternare passed to clients but not checked, so check those inrunif they matter.Throw
ToolError(or callfail('…')) for a message the assistant should pass on. That message is also recorded in the audit trail, so don't put argument values in it. Any other error becomes a generic message, and the details go to the Worker's logs, not to the assistant.Annotations such as
readOnlyHintare hints for clients, not a security boundary.accessandwriteare.Scope every query to
c.user. The tool runs as the person, so it should see what they see and nothing more.
Development
npm test # node:test against an in-memory D1 stand-in (node:sqlite); Node 22.18+ runs the TypeScript directly
npm run typecheck
npm run lint # ESLint with type-aware typescript-eslint rules
npm run format # Prettier
npm run check # everything CI runsThe tests drive the real fetch handler through the full flows: registration rules, the PKCE flow, the wrong
verifier, code reuse, refresh rotation and reuse detection (including two requests racing, with simulated D1 latency),
grant lifetimes, token audience, revocation, open-redirect and consent protections, Origin checks, scope filtering,
refusals, argument checking (including prototype-named keys), JSON-RPC edge cases, protocol versions, the audit trail,
Access JWT verification (tampered, expired, wrong-audience, alg: none and HS256-confusion tokens) and the dev sign-in's
localhost lock.
Limits
Stateless JSON responses only. There's no SSE stream, no server-to-client requests and no resumable sessions. Tools and results cover most uses of a remote server. Add a Durable Object if you need sessions.
The
MCP-Protocol-Versionheader is checked on every request, and an unknown version gets 400. JSON-RPC batches are accepted only for protocol versions before 2025-06-18, which removed them.Clients connect through dynamic client registration. Client ID Metadata Documents, which MCP 2025-11-25 also describes, aren't supported yet.
A read-only connection that calls a write tool gets a tool error explaining how to reconnect, not the 403
insufficient_scopestep-up challenge. Write tools are hidden from such connections anyway.Refresh-token reuse ends the connection with no grace period. That's strict rotation (RFC 9700 §4.14.2): a client that loses a refresh response has to connect again.
resourcesandpromptsanswer with empty lists.Registration is open to any client (that's how MCP clients connect), so the per-network limits are the main protection. Hosted assistants register from their providers' servers, so many of your users can share one "network". Raise
REGISTRATIONS_PER_HOURfor a large rollout. Turn onRATE_LIMITER, or put Cloudflare WAF rate limiting in front, if you expect abuse.
License
Apache-2.0. Contributions are welcome: see CONTRIBUTING.md and the Code of Conduct. Security reports: see SECURITY.md.
This server cannot be deployed
Maintenance
Related MCP Connectors
One OAuth connector for your team's remote MCP servers, with Tool Packs, guardrails and logs.
Hosted MCP server with managed OAuth for 15+ toolkits: Google Workspace, Fitbit, Oura, Kalshi, etc.
Governed MCP gateway: one endpoint for your tools, with credential custody and audit log.
One MCP endpoint for your company's tools, with per-person sign-in, policy and audit.
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceEnables AI clients to securely access, query, and mutate normalized user-controlled health data (e.g., from Apple Health or Supabase) through a bounded set of MCP tools, with optional OAuth and sandboxed deployment.MIT
- FlicenseNot gradedqualityDmaintenanceA governed MCP server with OAuth 2.1 + PKCE, declarative tool scoping, row-level data filters, per-identity rate limits, and a tamper-evident audit trail.-
- AlicenseNot gradedqualityCmaintenanceProvides an OAuth-protected, scoped, and audited MCP server that lets AI assistants safely query business data through a fixed set of tools without direct database access.1MIT

ForIT MCP Gatewayofficial
AlicenseNot gradedqualityBmaintenanceEnables turning selected OpenAPI operations into authenticated MCP tools with branded OAuth, operation allowlisting, and live schema refreshes.Apache 2.0