Skip to content

Add HTTP transport: SDK 1.30 upgrade, request-scoped core, Cloudflare Worker - #44

Merged
DavertMik merged 12 commits into
mainfrom
feature/http-transport
Sep 15, 2026
Merged

DavertMik merged 12 commits into
mainfrom
feature/http-transport

Conversation

@DavertMik

Copy link
Copy Markdown
Contributor

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/list snapshot was recorded against @modelcontextprotocol/sdk@0.4.0 before the bump, driving a real JSON-RPC handshake through a capture transport. The SDK then went to ^1.30.0 and the snapshot passed byte-identical — Server + setRequestHandler survive the jump, and src/mcp/server.js needed no change beyond the new connect().

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 from run(). TestomatioApiClient and ToolRegistry are untouched.

TESTOMATIO_HOST / --host. beta.testomat.io becomes https://beta.testomat.io. A full URL is accepted as-is rather than producing https://https://…. Precedence: --base-url / TESTOMATIO_BASE_URL > --host / TESTOMATIO_HOST > DEFAULT_BASE_URL.

Cloudflare Worker in worker/, excluded from the npm tarball (verified with npm pack --dry-run). POST /mcp/<project_id> builds a fresh server and a stateless WebStandardStreamableHTTPServerTransport per request; GET returns 405. testomat_ / tstmt_ bearer tokens bypass OAuthProvider entirely, so IDE clients and curl keep working once OAuth is live. A backend 403 becomes a 401 with WWW-Authenticate, and the tests fetch the advertised resource_metadata URL 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 completeAuthorization requires a user id that the design doc left unstated:

  1. GET /mcp/authorize?state=<random> — consent page behind authenticate_user!. The worker redirects here after storing oauthReqInfo in OAUTH_KV under that random state. Rails never receives a callback URL; the worker callback is Rails configuration, which removes open-redirect risk.
  2. Redirect back to the configured worker callback with ?code=<opaque>&state=<same random>. The opaque code lives in Rails.cache for 60s, delete-on-read.
  3. POST /mcp/authorize/exchange — CSRF-exempt, authenticated by the X-Mcp-Worker-Secret header, body {"code": "<opaque>"}. It must respond:
{ "token": "testomat_…", "user_id": 42, "user_email": "qa@example.com" }

token is the McpToken key, user_id identifies the grant owner (required by completeAuthorization), user_email is optional and becomes the grant label shown in the connectors list.

Rollout

Step State
1. SDK bump + core refactor + tests this PR
2. Worker with bearer pass-through this PR — deployable and curl-testable today
3. Rails authorize endpoint + Worker OAuth provider worker side done, Rails side pending (see above)
4. Revocation UI, connect-to-Claude UI, docs out of scope

Deploy:

cd worker
npx wrangler kv namespace create OAUTH_KV     # put the id into wrangler.jsonc
npx wrangler secret put TESTOMATIO_MCP_WORKER_SECRET
npx wrangler deploy

kv_namespaces[0].id in worker/wrangler.jsonc is a placeholder and must be replaced before the first deploy. A staging worker is the same code with TESTOMATIO_BASE_URL set to https://beta.testomat.io in its own vars block. The shared secret is a Wrangler secret and is never committed.

Tests

npm test — 29 tests, 6 files, all passing. A Tests workflow now runs npm ci && npm test on 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.jsStreamableHTTPClientTransport against the worker with a stubbed api/v2: initializetools/listtools/call, asserting the project id from the URL reaches the api/v2 path and the token reaches the Authorization header.
  • worker/test/token-revocation.test.js — 403 → 401 with a resolvable WWW-Authenticate; a 422 is not masked as an auth failure.
  • worker/test/oauth-flow.test.js — DCR, /authorize storing state in KV, /callback exchanging 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

  • createMcpServer and TestomatioMCPServer gained optional version and jsonSchemaValidator. The spec's four-argument signature cannot thread the Cloudflare validator, and getPackageVersion() reads package.json with fs, which has no meaning in workerd. Both default to undefined, so the stdio path is unaffected.
  • @cfworker/json-schema is 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.
  • 403 → 401 is done by inspecting the JSON-RPC result body. ToolRegistry.execute catches ApiError and returns it as a text result, so nothing propagates out of handleRequest, and changing registry internals was out of scope. The check is narrow: it only fires on a tool result whose JSON payload has status: 403.
  • @cloudflare/vitest-pool-workers@0.22 dropped fetchMock and defineWorkersProject. Config uses the new cloudflareTest Vite plugin under Vitest 4 test.projects; upstream stubbing uses vi.stubGlobal('fetch', …), which does reach the worker's outbound calls.
  • npm 11+ is required for npm install locally. npm 10.9.4 hits an arborist crash resolving vitest's peer set. CI already runs npm install -g npm@latest.

Open items from the design doc that remain open: whether mcp.testomat.io is a route on the existing zone or a workers.dev subdomain behind a custom domain.

🤖 Generated with Claude Code

https://claude.ai/code/session_019MGW7FX8fFDRxmFiKtN9pR

DavertMik and others added 12 commits September 14, 2026 14:05
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
@DavertMik
DavertMik merged commit eb4efd3 into main Sep 15, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add HTTP transport support for MCP server

1 participant