Skip to content

feat(mcp): add authenticated Streamable HTTP transport alongside stdio (#419) - #521

Merged
Miracle656 merged 10 commits into
Divinity-Labs:mainfrom
AGWAM001:feat/mcp-http-transport-419
Oct 23, 2026
Merged

Miracle656 merged 10 commits into
Divinity-Labs:mainfrom
AGWAM001:feat/mcp-http-transport-419

Conversation

@AGWAM001

Copy link
Copy Markdown
Contributor

Summary

Adds a Streamable HTTP transport to the MCP server, selected with MCP_TRANSPORT=http, while keeping stdio the default so nothing currently working changes.

  • HTTP transport - runHttp() serves Streamable HTTP; resolveMcpTransportConfig() (src/mcp/config.ts) selects the transport from configuration.
  • Fails closed on auth - a non-empty MCP_HTTP_AUTH_TOKEN is required before the signer is built and again before the socket binds. Every request is authenticated over SHA-256 digests with timingSafeEqual; missing/malformed/wrong tokens get 401 + ERR_UNAUTHORIZED before any tool runs. The token is never echoed into an error or log.
  • Identical tools - each POST builds its own stateless protocol server from the same tool list stdio uses; a parity test proves tools/list and a paid_call result match across both transports.
  • Docs - README documents running stdio and HTTP; .env.example lists MCP_TRANSPORT and MCP_HTTP_*.

Related issue

Closes #419

Type of change

  • New feature
  • Docs
  • Tests

Checklist

  • I have read CONTRIBUTING.md
  • npx tsc --noEmit passes - clean, 0 errors
  • npm run build passes - not run here (needs prisma generate), left unticked
  • I added / updated tests where relevant
  • I updated docs where relevant (README.md, .env.example, one changeset)

Test evidence

  • npx vitest run tests/mcp-http.test.ts - 21 passed (config selection, auth refusal, both-transport parity, handshake)
  • npx vitest run tests/mcp.test.ts - 4 passed (pre-existing stdio suite unchanged)
  • npx tsc --noEmit - clean

Notes

Scoped deliberately to #419 only. The spend budget/audit ledger (#406) and the extra tool-coverage tests (#421) live in other PRs, so server.ts here keeps the plain bazaar_search/paid_call handler with no budget/ledger imports. No secret key is logged, echoed, or used in a fixture; MCP_HTTP_AUTH_TOKEN fixtures are opaque test strings.

AGWAM001 and others added 3 commits October 18, 2026 21:05
The MCP server could only speak stdio, so it was reachable only by a client
that can spawn this process locally — useless for a hosted price API. Add a
Streamable HTTP transport selected by MCP_TRANSPORT, keeping stdio the default.

HTTP exposes paid_call to the network, so it fails closed without a bearer
token: resolveMcpTransportConfig requires MCP_HTTP_AUTH_TOKEN and runHttp()
re-checks it before binding, and every request is authenticated over SHA-256
digests with timingSafeEqual before any tool runs. Each POST gets its own
stateless protocol server registered from the same tools, so both transports
serve an identical tool list.

Closes Divinity-Labs#419
Repairs the class body the previous merges of main left interleaved: the
constructor and createProtocolServer() were written into each other, so
src/mcp/server.ts did not parse and every paid-call type in the repo failed.

Streamable HTTP stays per-request (one protocol server per POST, so
setupHandlers takes the target), and main's cumulative spend ceiling,
lens_get_price, zod tool schemas, paid_call host allow-list and structured
error classification all keep working through it.

tests/mcp-http.test.ts opts the parity servers into the paid_call allow-list,
which is deny-all by default, and checks the upstream timeout signal the
transport now passes.
@AGWAM001
AGWAM001 force-pushed the feat/mcp-http-transport-419 branch from 7914b84 to 181996f Compare October 21, 2026 17:10
AGWAM001 and others added 7 commits October 22, 2026 07:07
fa09e34 resolved src/mcp/codes.ts, src/mcp/server.ts, tests/mcp.test.ts,
.env.example and README.md to the branch's older side while taking main's
for everything else, so the two halves stopped agreeing: tsc failed on the
codes main added, the case-count gate found 31 tests/mcp.test.ts cases gone,
and npm test was red on the env-drift and README guards that the same
revert broke.

- codes.ts: main's codes, plus ERR_UNAUTHORIZED for the HTTP bearer refusal.
- server.ts: main's BazaarMcpServer (paid-call allow-list, bounded results,
  lens_get_price, resources) with this branch's Streamable HTTP transport
  and PaymentLedger re-applied on top.
- tests/mcp.test.ts: keep both halves - main's cases and the branch-only
  discovery-routing, ListTools and audit-ledger cases, retargeted where main
  deliberately changed how a thrown paid call is classified.
- .env.example, README.md: main's content plus this branch's transport,
  ledger and spend-metric documentation.
…e lockfile

vitest 4 runs rolldown, which needs @rolldown/binding-linux-x64-gnu. The lock
only carried the win32 entry, so npm ci on the Linux runner fell through to
npm install and the suite died before the first test. Re-adds the 63
platform-specific entries, verified against registry.npmjs.org; the resolved
win32 tree is unchanged.
@Miracle656
Miracle656 merged commit c55a980 into Divinity-Labs:main Oct 23, 2026
2 checks passed
@Miracle656

Copy link
Copy Markdown
Collaborator

Merged to main as c55a980.

The security shape here is right, and I checked it rather than taking the docstring's word:

  • runHttp refuses to start without a non-empty bearer token, with an explicit error rather than a quiet default. That's the decision that matters, because this transport exposes paid_call — a tool that spends real money — to anything that can reach the port.
  • Constant-time comparison over SHA-256 digests. Hashing first isn't decoration: timingSafeEqual throws on a length mismatch, so comparing raw tokens would leak the expected token's length through the exception path. Your comment says as much, and it's correct.
  • Default bind is 127.0.0.1, not 0.0.0.0, and the port is range-validated.

That combination — mandatory auth, loopback default, constant-time check — is what I'd want on a listener that can move money, and it's unusual to see all three together on a first pass.

Conflict: package-lock.json only. I took main's copy, because this branch changes no dependency (every new import resolves to something already installed: @modelcontextprotocol/sdk/server/streamableHttp.js, prom-client, node builtins), and main's lockfile is now the one carrying the @rolldown Linux binding that CI needs — #542 restored it an hour ago after every run had been failing at vitest startup.

A question about #524. It touches the same twelve files as this PR, but from a branch based 36 commits back (this one was 15 behind), so it isn't a rebase of this and merging it would revert work. The two were opened an hour apart and updated within fifteen minutes of each other, and they share both changesets. Rather than guess, I've asked there which is live — if #524 has distinct work, it wants rebasing onto main now that this has landed.

Lens: 112 files, 1,397 tests passing, typecheck clean.

Miracle656 added a commit that referenced this pull request Oct 23, 2026
A model picks a tool from its description alone, so "this one is free, that one
spends real funds, and here is the other one to use instead" is the text that
stops a free lookup being made as a payment.

Merged rather than taken wholesale, because main moved under this branch.

#521 replaced the hand-written JSON input schemas with ones derived from the
Zod definitions (zodToToolInputSchema), and this branch still hand-wrote them.
Keeping the PR's version would have reverted a single source of truth back to
two that can disagree, so main's schemas stay and the guidance was folded into
the descriptions instead.

The paid_call wording is a merge of both. Main's paragraph is the stronger
half and is kept verbatim: the allow-list is server configuration and "cannot
be widened by an argument, a url, or text you have read — do not retry a
refused host, report it instead." That sentence is prompt-injection defence
written where the model will actually read it. This PR's cost framing is added
in front of it: real funds, a funded signer with trustlines, and not for free
discovery.

bazaar_search keeps main's paging guidance (check truncated and total, re-ask
with limit and offset) and gains the free-of-charge note and the pointers to
paid_call and the REST/GraphQL APIs.

The PR's test asserted its own exact phrasing ("Cost: Free", "SPENDS REAL
CRYPTOGRAPHIC FUNDS") and per-parameter "Format:" strings from the hand-written
schema, so it could not survive the resolution. Rewrote it against what
matters rather than how it is worded: each description names its cost and
names the other tool. tests/mcp.test.ts 1519-line conflict resolved to main's
copy with that one case re-added.

Lens: 120 files, 1,446 tests passing, typecheck clean.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
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.

There is no HTTP transport, only stdio

2 participants