Add HTTP transport: SDK 1.30 upgrade, request-scoped core, Cloudflare Worker - #44
Merged
Merged
Conversation
Streamable HTTP transport on a Cloudflare Worker, with OAuth 2.1 handled by workers-oauth-provider and identity/consent delegated to Rails. Project is selected by URL path so tool signatures are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MGW7FX8fFDRxmFiKtN9pR
Captures the full tool catalog returned over a JSON-RPC handshake so the upcoming @modelcontextprotocol/sdk upgrade can be proven non-breaking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MGW7FX8fFDRxmFiKtN9pR
The tools/list snapshot recorded against 0.4.0 passes unchanged, confirming Server + setRequestHandler survive the jump. No source changes required. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MGW7FX8fFDRxmFiKtN9pR
Adds createMcpServer() for per-request server instances, loadServerConfig() returning only the base URL for transports that receive credentials per request, and TestomatioMCPServer.connect(transport) so run() is no longer the only way to attach a transport. Stdio startup is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MGW7FX8fFDRxmFiKtN9pR
A bare hostname such as beta.testomat.io resolves to https://beta.testomat.io. An explicit --base-url or TESTOMATIO_BASE_URL still wins, and a full URL passed to --host is accepted as-is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MGW7FX8fFDRxmFiKtN9pR
Serves POST /mcp/<project_id> with a per-request createMcpServer() and a stateless WebStandardStreamableHTTPServerTransport. GET returns 405 since the server never initiates traffic. The SDK is imported through deep paths so express, cors and @hono/node-server stay out of the bundle, and CfWorkerJsonSchemaValidator replaces the default Ajv validator, which compiles schemas with new Function and is therefore forbidden in Workers. @cloudflare/workers-oauth-provider handles DCR, metadata, PKCE and token issuance. testomat_ and tstmt_ bearer tokens bypass it and reach the MCP handler directly so IDE clients and curl keep working. /authorize stores the OAuth request in OAUTH_KV under a random state and /callback redeems the opaque code server-to-server with X-Mcp-Worker-Secret. A 403 from the backend becomes a 401 with WWW-Authenticate so clients re-run OAuth after revocation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MGW7FX8fFDRxmFiKtN9pR
Runs npm ci && npm test on pull requests, bumps the package to 2.0.0-beta.8 and documents the /mcp/<project_id> endpoint, TESTOMATIO_HOST and the worker deployment steps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MGW7FX8fFDRxmFiKtN9pR
The WWW-Authenticate resource_metadata URL is built by hand, so the tests now fetch it and confirm OAuthProvider serves the path-scoped document for /mcp/<project_id>. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019MGW7FX8fFDRxmFiKtN9pR
Api::V2 answers 403 for seven conditions; only a missing header and an invalid or paused token can be fixed by getting a new token. Treating all of them as authentication failures made a read-only user or a plan-gated feature re-authorize forever instead of surfacing the error. Keys off the code the API now returns for those two cases (testomatio/testomatio#10015). An uncoded 403 passes through untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ho83H9Jcv5iutcHGi3DGvv
The authorize redirect carried only state, so Rails could not name the grant and every connector showed up as "MCP Connector" with no project on the revoke page. Forwards the DCR client name and the project taken from the RFC 8707 resource parameter. Adds an env.beta target so a beta worker deploys against beta.testomat.io with its own KV namespace, keeping beta grants out of the production namespace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ho83H9Jcv5iutcHGi3DGvv
Each handler is now a WorkerEntrypoint subclass, which the OAuth provider instantiates directly rather than being handed a bare object, so env and ctx arrive as instance state instead of being threaded through every call. Public entry points come first in each class and the private helpers follow. Token parsing moves onto McpHandler as statics so the entry point can detect a static token without duplicating the header regex. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ho83H9Jcv5iutcHGi3DGvv
main moved from 2.0.0-beta.7 to 2.2.1 while this branch was open, adding tool profiles, slim list projection, session cleanup and the enterprise package. Both sides touched the config loader, the CLI and the server class, so those are merged rather than taken from either side: - loadConfig keeps the tools profile and gains the host resolution and the injectable env the worker needs - the CLI offers both --tools and --host - TestomatioMCPServer keeps tools/name/registryOptions and session cleanup alongside connect() and the injected version and schema validator, so the worker still avoids reading package.json and Ajv - version becomes 2.3.0-beta.1 rather than regressing to 2.0.0-beta.8 The tools/list snapshot is regenerated for the 90 tools main now ships, and the worker end-to-end test asserts the payload it forwards rather than an exact body, since list results are slimmed by default now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ho83H9Jcv5iutcHGi3DGvv
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the HTTP transport half of the approved design: steps 1 and 2 of its rollout, plus the worker side of step 3.
Closes #4
What ships
SDK upgrade, proven safe. A
tools/listsnapshot was recorded against@modelcontextprotocol/sdk@0.4.0before the bump, driving a real JSON-RPC handshake through a capture transport. The SDK then went to^1.30.0and the snapshot passed byte-identical —Server+setRequestHandlersurvive the jump, andsrc/mcp/server.jsneeded no change beyond the newconnect().Request-scoped construction. New
createMcpServer({ token, projectId, baseUrl, logger })builds a server and API client per request.loadServerConfig()returns only{ baseUrl }so HTTP mode no longer needs credentials at process start.TestomatioMCPServer.connect(transport)decouples the transport fromrun().TestomatioApiClientandToolRegistryare untouched.TESTOMATIO_HOST/--host.beta.testomat.iobecomeshttps://beta.testomat.io. A full URL is accepted as-is rather than producinghttps://https://…. Precedence:--base-url/TESTOMATIO_BASE_URL>--host/TESTOMATIO_HOST>DEFAULT_BASE_URL.Cloudflare Worker in
worker/, excluded from the npm tarball (verified withnpm pack --dry-run).POST /mcp/<project_id>builds a fresh server and a statelessWebStandardStreamableHTTPServerTransportper request;GETreturns405.testomat_/tstmt_bearer tokens bypassOAuthProviderentirely, so IDE clients andcurlkeep working once OAuth is live. A backend403becomes a401withWWW-Authenticate, and the tests fetch the advertisedresource_metadataURL to confirm it resolves.Stdio is unchanged. Same startup path, same log line, same wire output.
Rails prerequisites (step 3)
The worker side of OAuth is complete; Rails still needs three things. The exchange contract is defined here, because
completeAuthorizationrequires a user id that the design doc left unstated:GET /mcp/authorize?state=<random>— consent page behindauthenticate_user!. The worker redirects here after storingoauthReqInfoinOAUTH_KVunder that randomstate. Rails never receives a callback URL; the worker callback is Rails configuration, which removes open-redirect risk.?code=<opaque>&state=<same random>. The opaque code lives inRails.cachefor 60s, delete-on-read.POST /mcp/authorize/exchange— CSRF-exempt, authenticated by theX-Mcp-Worker-Secretheader, body{"code": "<opaque>"}. It must respond:{ "token": "testomat_…", "user_id": 42, "user_email": "qa@example.com" }tokenis theMcpTokenkey,user_ididentifies the grant owner (required bycompleteAuthorization),user_emailis optional and becomes the grant label shown in the connectors list.Rollout
Deploy:
kv_namespaces[0].idinworker/wrangler.jsoncis a placeholder and must be replaced before the first deploy. A staging worker is the same code withTESTOMATIO_BASE_URLset tohttps://beta.testomat.ioin its ownvarsblock. The shared secret is a Wrangler secret and is never committed.Tests
npm test— 29 tests, 6 files, all passing. ATestsworkflow now runsnpm ci && npm teston every PR; there was no test job before.test/tools-list.test.js— the SDK regression snapshot.test/load-config.test.js,test/create-server.test.js— precedence rules and per-instance isolation.worker/test/mcp-endpoint.test.js—StreamableHTTPClientTransportagainst the worker with a stubbed api/v2:initialize→tools/list→tools/call, asserting the project id from the URL reaches the api/v2 path and the token reaches theAuthorizationheader.worker/test/token-revocation.test.js— 403 → 401 with a resolvableWWW-Authenticate; a 422 is not masked as an auth failure.worker/test/oauth-flow.test.js— DCR,/authorizestoring state in KV,/callbackexchanging the code with the secret header and completing the grant, path-scoped protected-resource metadata, and the static-bearer bypass.Deviations from the design doc
createMcpServerandTestomatioMCPServergained optionalversionandjsonSchemaValidator. The spec's four-argument signature cannot thread the Cloudflare validator, andgetPackageVersion()readspackage.jsonwithfs, which has no meaning in workerd. Both default toundefined, so the stdio path is unaffected.@cfworker/json-schemais a devDependency, not a dependency. It is an optional peer of the SDK and only the worker imports it, so stdio users should not carry it.ToolRegistry.executecatchesApiErrorand returns it as a text result, so nothing propagates out ofhandleRequest, and changing registry internals was out of scope. The check is narrow: it only fires on a tool result whose JSON payload hasstatus: 403.@cloudflare/vitest-pool-workers@0.22droppedfetchMockanddefineWorkersProject. Config uses the newcloudflareTestVite plugin under Vitest 4test.projects; upstream stubbing usesvi.stubGlobal('fetch', …), which does reach the worker's outbound calls.npm installlocally. npm 10.9.4 hits an arborist crash resolving vitest's peer set. CI already runsnpm install -g npm@latest.Open items from the design doc that remain open: whether
mcp.testomat.iois a route on the existing zone or aworkers.devsubdomain behind a custom domain.🤖 Generated with Claude Code
https://claude.ai/code/session_019MGW7FX8fFDRxmFiKtN9pR