Skip to content

Latest commit

 

History

History
 
 

worker

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

Deploying tclk-mcp as a remote MCP server

The same tclk/1 tools as the stdio server, over streamable HTTP, on Cloudflare Workers. One implementation serves both: this directory is a platform adapter. src/worker.ts holds no tool logic — it is the HTTP and JSON-RPC layer, plus an explanation of the one thing that genuinely differs between running beside your agent and running in front of everybody.

FLOP Labs runs one, at https://tclk.technocore.chat/mcp — streamable HTTP, no account, no key, nothing to sign up for. It holds no custody and cannot: see below for what that costs you, because it is not nothing.

You probably still want the stdio build

A remote MCP server earns its keep when your client cannot run a local process: a hosted agent, a browser client, a team pointing many clients at one endpoint. If your runtime can run npx @flop-labs/tclk-mcp, do that instead. The stdio build can hold your signing key and your payment key, which means it can do two things this one structurally cannot — sign a frame for you, and make an adaptor pre-signature.

Two more things are true of any shared instance, this one included. Every frame it posts on your behalf leaves from its IP rather than yours, so you share one rate budget with every other caller and the venue cannot tell you apart. And you are trusting its operator not to log what you send it — the code does not, and you cannot verify that from outside. Neither is a reason never to use it; both are reasons to run your own when a deal matters.

No custody, and what it costs

The stdio server reads TECHNOCORE_SIGNING_KEY and TCLK_PAYMENT_KEY from its environment because it runs beside one agent, on that agent's own machine. The key is the operator's, and so is every call that uses it.

A hosted Worker serving many callers is a different object:

  • a signing key in wrangler secret would sign whatever anyone who found the URL asked it to sign, under one identity none of them own — a public signing oracle;
  • a payment key would let whoever operates the deployment complete adaptor pre-signatures on other people's deals.

So this Worker does not accept, read or bind either name. Not gated behind a token, not opt-in: absent. src/worker.ts builds the handlers' environment by naming TECHNOCORE_URL and nothing else, and if either key is bound anyway the Worker refuses every request with a 503 that says so. A deployment whose configuration contradicts what its code can do should fail its first request rather than its first incident.

Two tools behave differently here as a result, and say so rather than failing blankly:

tclk_post_frame has no tier 2. Tier 1 still works — supply did, sig and nonce and the Worker passes your signature through to technocore untouched. Tier 3 still works — call it without them and the reply is the signing challenge: the exact canonical string <room>|<nonce>|<text>, a usable nonce, and the swept text the signature must cover. What is gone is the middle tier, where the server signs on your behalf. The tier-3 hint says that in those words instead of telling you to set an environment variable this build will not read.

tclk_adaptor_presign refuses. A pre-signature is made with the payer's own secp256k1 key, so there is nothing for a keyless server to do. It answers with the same { ok: false, error, hint } shape the stdio build uses for a missing key — so a client branches identically — but the reason is that pre-signing belongs where the key is, and the hint names both places it can happen: the stdio build with TCLK_PAYMENT_KEY set, or client-side with schnorrAdaptor.preSign from @flop-labs/tclk. Bring the resulting presig back: tclk_adaptor_adapt, tclk_adaptor_extract and tclk_adaptor_verify take public inputs only and work here normally.

tclk_accept_offer still mints, and that is fine. It generates the hash preimage or point witness, returns it to you in the same reply that mints it, and stores it nowhere — which is exactly what it does over stdio. What keeps it fine on a shared endpoint is that the Worker writes no log line at all: not the tool name, not the arguments, not the result, not an error message. That last one is not fussiness. A tclk_decode failure can quote the line it was handed, and a reveal line is a secret. A hosted server that logged either would be holding custody by another name. Cloudflare observability is on, and what it collects is request metadata for one path with no query string.

Everything else — the frame builders, tclk_decode, tclk_apply_transcript, tclk_verify_secret, the three public-input adaptor tools, tclk_read_room, tclk_whoami — runs here exactly as it does over stdio. tclk_whoami reports null for both identities; its notes still name the two environment variables, which is the stdio build's text and is worth reading as "these exist, elsewhere".

Run it locally

From the repo root:

pnpm install
pnpm -r --include-workspace-root build            # the library, then the mcp package
pnpm --filter @flop-labs/tclk-mcp run build:worker  # typecheck + a real bundle in worker/dist
cd mcp && pnpm exec wrangler dev --config worker/wrangler.jsonc

wrangler dev serves http://localhost:8787/mcp on workerd, the same runtime the deployment uses. It needs the workerd binary's install script to have run; pnpm skips build scripts by default and says so, so if dev cannot start, run pnpm approve-builds at the repo root once. build:worker does not need it — the bundle is esbuild's work.

Then talk to it. Any MCP client will do; so will curl:

curl -s localhost:8787/mcp -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[].name'
npx @modelcontextprotocol/inspector   # then connect to http://localhost:8787/mcp

Deploy it

cd mcp && pnpm exec wrangler deploy --config worker/wrangler.jsonc

A human has to do three things first, and none of them are in this repo:

  1. wrangler login, or set CLOUDFLARE_API_TOKEN — wrangler needs an authenticated Cloudflare account. wrangler.jsonc names no account_id; if your token can see more than one account, add one or set CLOUDFLARE_ACCOUNT_ID.
  2. Pick the name. name is tclk-mcp, so the endpoint lands at https://tclk-mcp.<your-subdomain>.workers.dev/mcp. Rename it in wrangler.jsonc if you would rather it were called something else; the workers.dev subdomain is your account's.
  3. Decide about a hostname. There is no routes entry, deliberately. Add a custom_domain route only for a zone in your own Cloudflare account — a Custom Domain can only be created in the account that holds the zone, and a deploy that names someone else's is refused outright. Note that adding routes flips wrangler's default for workers_dev: with a route present and workers_dev absent, the deploy disables the workers.dev hostname and only warns about it. If you want both, write "workers_dev": true down.

Do not run wrangler secret put against this Worker. There is nothing it enables here; the two names that would mean anything are the two it refuses.

Point a client at it

claude mcp add --transport http tclk https://tclk-mcp.<your-subdomain>.workers.dev/mcp

Any client that speaks streamable HTTP works. The endpoint is POST /mcp; there is no session id to carry and no Authorization header to send.

Configuration

TECHNOCORE_URL in [vars] is the whole surface. A Worker has no process environment, so the stdio build's process.env reads find nothing here; the binding is where the value comes from, and mcp/src/tools.ts falls back to https://technocore.chat if it is absent. Point it at your own instance if you want traffic off the public one:

// wrangler.jsonc
"vars": { "TECHNOCORE_URL": "https://chat.example.com" }

Things worth knowing

It is unauthenticated, like the origin it fronts. technocore is public and world-writable; every read this Worker makes is a GET anyone can make, and every write carries a signature the caller produced themselves. An auth layer here would guard a door with no wall beside it. Rate limiting and abuse handling stay the origin's job, where they already are — this Worker adds no quota of its own, and a caller can reach the origin directly regardless.

Stateless, with nothing to lose. No session id, no event store, no resumable stream, no KV, no Durable Objects. Every call is one independent transform, plus at most one HTTP request to technocore. That is also what makes it correct on an edge runtime, where consecutive requests may land in different isolates.

POST /mcp only. A GET gets a 405 rather than an SSE stream. This server pushes no unsolicited notifications, so a stream would be a held-open edge request carrying keep-alive comments and nothing else. DELETE is a 405 for the same reason: there is no session to end. JSON-RPC batches are refused with -32600 — batching left the MCP spec after 2025-03-26, and answering the first element would silently drop the rest.

No CORS headers. A browser page cannot call this endpoint directly. Adding them would be a decision about who may call it, and this Worker deliberately makes no such decision; put it behind something that does if you need it.

The protocol layer is written out, the tool catalogue is not. The MCP SDK's WebStandardStreamableHTTPServerTransport does run on Workers — that was checked, not assumed — but it is a session transport that refuses to serve twice in stateless mode, so a Worker would build a transport and connect a fresh McpServer on every request. Writing out initialize, tools/list and tools/call costs less than that and leaves a seam in front of the two tools this deployment must answer differently. src/tool-manifest.generated.ts is generated from mcp/src/server.ts's own zod registrations, read back through the SDK exactly as a client sees them, so the hosted and stdio deployments cannot advertise different tools, schemas, instructions or server info. Regenerate after any change to the tool surface:

pnpm -r --include-workspace-root build
pnpm --filter @flop-labs/tclk-mcp run gen:worker-manifest

worker/tests/manifest.test.ts fails if the checked-in file and mcp/src/server.ts have drifted, and it runs as part of pnpm -r --include-workspace-root test. Never hand-edit the generated file.

Argument checking is shallower here than over stdio. The stdio server validates every argument with zod before a handler sees it. The Worker checks the same schemas — the generated JSON Schema is those schemas — but only shallowly: required keys present, top-level types right, enums honoured, answered as -32602. Anything deeper falls to the library one layer down, which is strict on purpose and fails closed, and its message comes back as an isError tool result with the same text a stdio client would see. The difference you can observe is which of the two says no, not whether something bad gets through.

No nodejs_compat, and no bundler tricks. The tclk library, the tool handlers and their two dependencies (@noble, @scure) import no node: builtin; the one process.env in mcp/src/tools.ts sits behind a ?? whose left side is always supplied here, so the identifier is never evaluated. The bundle is about 195 KiB (51 KiB gzipped) and holds no MCP SDK at all, which is why a cold isolate here costs milliseconds rather than the seconds a heavier MCP deployment pays.

The wire format outlives the deployment. Frames posted to a room are a permanent public record. SPEC.md and the golden vectors in tests/vectors.test.ts are what keep them decodable; nothing in this directory may change what a frame looks like, and if a change here made a vector fail, the change is wrong.