feat(builder)!: remove deprecated SSE transport - Streamable HTTP only (2.0.0)
BREAKING CHANGE: GET /sse and POST /messages now answer 404. Every client
connects over Streamable HTTP at /mcp. The sse remote is removed from
server.json.
- Add unauthenticated GET /health ({status, version}); no session, no
rate-limit touch.
- Add any-origin CORS across the whole HTTP surface, incl. OPTIONS
preflight and error responses; mcp-session-id exposed to browsers.
- Unify every version string at 2.0.0; both McpServer identities now read
the version from package.json at build time instead of hardcoding it.
- Raise @modelcontextprotocol/sdk floor to ^1.30.0 and sync the local
install with the lockfile.
- Tests: retire the SSE-shaped auth-gating suite (its unique
process-liveness test is ported to streamable-http-gating), move the
shared harness and rate-limit suite to StreamableHTTPClientTransport,
and add http-surface.test.ts for CORS//health//sse-404 coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
37
CHANGELOG.md
Normal file
37
CHANGELOG.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## 2.0.0 - 2026-08-17
|
||||
|
||||
### Breaking
|
||||
|
||||
- **The legacy SSE transport is removed.** `GET /sse` and `POST /messages` now answer 404.
|
||||
Every client connects over Streamable HTTP at `/mcp`:
|
||||
`claude mcp add --transport http vector-mcp <url>/mcp`. The `sse` remote was removed from
|
||||
the MCP Registry manifest (`server.json`).
|
||||
|
||||
### Added
|
||||
|
||||
- `GET /health`: unauthenticated liveness probe, returns
|
||||
`{"status":"ok","version":"<version>"}`. It creates no session and bypasses the rate
|
||||
limiter.
|
||||
- Any-origin CORS on the whole HTTP surface, including preflight (`OPTIONS` → 204) and error
|
||||
responses, with `mcp-session-id` exposed to browser callers. Browser-based MCP clients can
|
||||
connect directly; authorization remains bearer-token only. Note for self-hosters: with
|
||||
`MCP_AUTH_TOKENS` unset, any web page can drive your instance from a visitor's browser,
|
||||
sharing that visitor's per-IP rate-limit bucket - set `MCP_AUTH_TOKENS` on any instance
|
||||
reachable by anyone but you.
|
||||
|
||||
### Changed
|
||||
|
||||
- All package versions, `server.json`, and both advertised MCP server identities are unified
|
||||
at 2.0.0; the identities now read the version from `package.json` at build time instead of
|
||||
hardcoding it.
|
||||
- `@modelcontextprotocol/sdk` floor raised to `^1.30.0`.
|
||||
|
||||
### Known limits
|
||||
|
||||
- POST responses stream as SSE frames (the spec-default mode) with no keepalive bytes while a
|
||||
tool call computes. Behind a proxy that cuts idle responses (Cloudflare's edge does at
|
||||
~100 s), a single tool call that stays silent longer than that window is dropped by the
|
||||
proxy. Long waits (e.g. `vector_await_transaction` on a congested chain) should poll in
|
||||
shorter calls.
|
||||
41
README.md
41
README.md
@@ -25,28 +25,23 @@ Hosted instances run on both networks, exposing all 24 tools:
|
||||
> accepts a mnemonic from any caller, with or without a token. Self-hosted deployments can
|
||||
> still gate access with `MCP_AUTH_TOKENS` - see [Configuration](#configuration).
|
||||
|
||||
> **Transport: both hosted instances serve `/mcp`, the modern Streamable HTTP endpoint.**
|
||||
> It is the recommended transport and the endpoint listed in the table above. The legacy SSE
|
||||
> pair (`/sse` + `/messages`) remains available on both hosted instances for clients that need
|
||||
> it, deprecated by the MCP spec (2026-07-28 revision) and retained for compatibility.
|
||||
> **Transport: Streamable HTTP only, as of 2.0.0.** The legacy SSE pair (`/sse` +
|
||||
> `/messages`), deprecated by the MCP spec (2026-07-28 revision), was removed in this release -
|
||||
> those paths now answer 404. Every client connects to `/mcp`, the endpoint the table above
|
||||
> lists. This is a breaking change for SSE clients: reconnect with `--transport http` and the
|
||||
> `/mcp` URL (below). The server also answers any-origin CORS (browser MCP clients are
|
||||
> first-class callers) and exposes an unauthenticated `GET /health` liveness probe.
|
||||
> **This describes the code** - each hosted instance serves it once this release deploys there
|
||||
> (testnet automatically on push to `main`, mainnet on a deliberate cutover dispatch).
|
||||
|
||||
Connect from Claude Code in one command. No token needed for the hosted instances.
|
||||
|
||||
Modern transport (Streamable HTTP) - recommended:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http vector-mcp https://mcp.vector.mainnet.apexfusion.org/mcp
|
||||
```
|
||||
|
||||
Legacy SSE transport - still answered by both hosted instances:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport sse vector-mcp https://mcp.vector.mainnet.apexfusion.org/sse
|
||||
```
|
||||
|
||||
Swap in the testnet host for either command to connect to testnet instead. Self-hosting (below)
|
||||
serves both transports too, and gives every caller open access to your own instance unless you
|
||||
set `MCP_AUTH_TOKENS`.
|
||||
Swap in the testnet host to connect to testnet instead. Self-hosting (below) gives every
|
||||
caller open access to your own instance unless you set `MCP_AUTH_TOKENS`.
|
||||
|
||||
> **Security notice: the non-custodial migration is complete, in this codebase and on both
|
||||
> hosted instances, as of the 2026-07-31 cutover deploy.** No tool in this repository accepts a
|
||||
@@ -98,7 +93,7 @@ limitations.
|
||||
- **Agent messaging** - send on-chain messages between agents via TX metadata (keyless)
|
||||
- **Self-Improvement Module** - browse, submit, critique, and endorse improvement proposals; the on-chain module is live on Vector mainnet, and every write tool is keyless in this codebase
|
||||
- **Safety controls** - per-identity rate limiting; spend limits for every family are enforced by the local signer, per user - the server holds no spend-limit state of its own
|
||||
- **Dual transport** - modern Streamable HTTP (`/mcp`) plus legacy SSE (`/sse` + `/messages`, deprecated by the MCP spec (2026-07-28 revision), retained for compatibility)
|
||||
- **Streamable HTTP transport** - the modern MCP transport at `/mcp`, with any-origin CORS for browser clients and an unauthenticated `GET /health` probe (the legacy SSE pair was removed in 2.0.0)
|
||||
|
||||
## MCP Tools (24)
|
||||
|
||||
@@ -199,15 +194,14 @@ npm start
|
||||
# Server listens on port 3000 (configurable via PORT env var)
|
||||
```
|
||||
|
||||
Connect with the modern transport:
|
||||
Connect:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http vector-mcp http://localhost:3000/mcp
|
||||
```
|
||||
|
||||
Add `--header "Authorization: Bearer <your-token>"` if you set `MCP_AUTH_TOKENS`. The legacy
|
||||
SSE pair (`/sse` + `/messages`, deprecated by the MCP spec, retained for compatibility) is
|
||||
still available at `http://localhost:3000/sse` for clients that need it.
|
||||
Add `--header "Authorization: Bearer <your-token>"` if you set `MCP_AUTH_TOKENS`. A liveness
|
||||
probe is at `GET /health` - unauthenticated, returns `{"status":"ok","version":"<version>"}`.
|
||||
|
||||
If this instance will be reachable by anyone but you, set `MCP_AUTH_TOKENS` first - see [Configuration](#configuration) below.
|
||||
|
||||
@@ -271,7 +265,10 @@ it, lives in your local signer instead - see `VECTOR_SIGNER_SPEND_LIMIT_PER_TX`
|
||||
[`packages/signer/README.md`](packages/signer/README.md#configuration).
|
||||
|
||||
> **Running a public instance?** Set `MCP_AUTH_TOKENS` to gate access to known callers, each
|
||||
> with its own rate-limit budget. With `MCP_AUTH_TOKENS` unset, callers are admitted
|
||||
> with its own rate-limit budget. Note that the server answers any-origin CORS by design: with
|
||||
> auth unset, any web page a browser user visits can drive your instance from JavaScript,
|
||||
> sharing that user's per-IP rate-limit bucket - one more reason a public instance should set
|
||||
> `MCP_AUTH_TOKENS`. With `MCP_AUTH_TOKENS` unset, callers are admitted
|
||||
> anonymously with per-client-IP rate limits; the deployment's reverse proxy supplies the
|
||||
> client address via the rightmost X-Forwarded-For entry. Tracked identities are capped (LRU);
|
||||
> eviction resets a bucket's budget - a memory bound, not a security boundary, since an
|
||||
@@ -367,7 +364,7 @@ against live chain data. **Never submits.** Never runs in CI. See
|
||||
```
|
||||
┌──────────────────────┐ ┌──────────────────────────┐
|
||||
│ Claude / GPT / etc. │◄───────────►│ vector-mcp-server │
|
||||
│ (any MCP client) │ /sse + /mcp │ │
|
||||
│ (any MCP client) │ /mcp │ │
|
||||
└──────────────────────┘ │ ┌────────────────────┐ │
|
||||
│ │ Rate Limiter │ │
|
||||
│ │ (60 calls/min) │ │
|
||||
|
||||
@@ -206,7 +206,7 @@ TDD throughout: failing test before implementation, every PR.
|
||||
| C | Submission error handling |
|
||||
| D | `min(1)` APEX floor → real dust floor |
|
||||
| E | Endpoint URLs in error text |
|
||||
| F | SSE → Streamable HTTP |
|
||||
| F | SSE → Streamable HTTP (done in 2.0.0: `/mcp` is the only transport; the deprecated `/sse` + `/messages` pair was removed outright) |
|
||||
| G | Float → bigint amount math |
|
||||
| H | Dependency updates |
|
||||
|
||||
|
||||
53
package-lock.json
generated
53
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@apexfusion/mcp-server-monorepo",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@apexfusion/mcp-server-monorepo",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
@@ -1754,9 +1754,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1771,9 +1768,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1788,9 +1782,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1805,9 +1796,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1822,9 +1810,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1839,9 +1824,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1856,9 +1838,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1873,9 +1852,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1890,9 +1866,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1907,9 +1880,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1924,9 +1894,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1941,9 +1908,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1958,9 +1922,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5290,11 +5251,11 @@
|
||||
},
|
||||
"packages/builder": {
|
||||
"name": "@apexfusion/vector-mcp-builder",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1027.0",
|
||||
"@lucid-evolution/lucid": "^0.4.29",
|
||||
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@noble/hashes": "^1.7.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"zod": "^3.24.1"
|
||||
@@ -5311,7 +5272,7 @@
|
||||
},
|
||||
"packages/shared": {
|
||||
"name": "@apexfusion/vector-mcp-shared",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@lucid-evolution/lucid": "^0.4.29",
|
||||
"@noble/hashes": "^1.7.1"
|
||||
@@ -5322,11 +5283,11 @@
|
||||
},
|
||||
"packages/signer": {
|
||||
"name": "@apexfusion/vector-mcp-signer",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@anastasia-labs/cardano-multiplatform-lib-nodejs": "6.0.2-3",
|
||||
"@lucid-evolution/wallet": "^0.1.72",
|
||||
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@apexfusion/mcp-server-monorepo",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": ["packages/*"],
|
||||
@@ -15,7 +15,7 @@
|
||||
"test:smoke": "npm run build && node --import tsx --test packages/builder/test/smoke/*.test.ts",
|
||||
"test:smoke:signer": "npm run build && node --import tsx --test packages/signer/test/smoke/*.test.ts",
|
||||
"test:integration": "npm run build && node --import tsx --test packages/builder/test/integration/*.test.ts",
|
||||
"test:integration:auth": "npm run build && node --import tsx --test packages/builder/test/integration/auth-gating.test.ts packages/builder/test/integration/rate-limit-gating.test.ts packages/builder/test/integration/streamable-http-gating.test.ts",
|
||||
"test:integration:auth": "npm run build && node --import tsx --test packages/builder/test/integration/http-surface.test.ts packages/builder/test/integration/rate-limit-gating.test.ts packages/builder/test/integration/streamable-http-gating.test.ts",
|
||||
"test:integration:signer": "node --import tsx --test packages/signer/test/integration/*.test.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@apexfusion/vector-mcp-builder",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"description": "MCP server for Vector - AI agent interface for the Apex Fusion eUTXO L2",
|
||||
@@ -17,7 +17,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1027.0",
|
||||
"@lucid-evolution/lucid": "^0.4.29",
|
||||
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@noble/hashes": "^1.7.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -25,17 +25,20 @@ if (existsSync(envPath)) {
|
||||
}
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { registerVectorTools } from "./vector/index.js";
|
||||
import { loadAuthConfig, resolveIdentity, clientIpOf } from "./auth.js";
|
||||
import { intEnv } from "./env.js";
|
||||
import { RATE_LIMIT_PER_MINUTE } from "./vector/rate-limiter.js";
|
||||
// Single source of truth for the advertised server version. tsup/esbuild
|
||||
// resolves this named JSON import at build time and inlines only the field,
|
||||
// so the built artifact has no runtime dependency on package.json's location.
|
||||
import { version as PKG_VERSION } from "../package.json";
|
||||
|
||||
function createMcpServer(identity: string) {
|
||||
const server = new McpServer({
|
||||
name: "vector-mcp-server",
|
||||
version: "1.0.0",
|
||||
version: PKG_VERSION,
|
||||
});
|
||||
registerVectorTools(server, identity);
|
||||
return server;
|
||||
@@ -52,18 +55,15 @@ if (authConfig.enabled) {
|
||||
}
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3000');
|
||||
const transports = new Map<string, SSEServerTransport>();
|
||||
const sessionIdentities = new Map<string, string>();
|
||||
const streamableTransports = new Map<string, StreamableHTTPServerTransport>();
|
||||
const streamableIdentities = new Map<string, string>();
|
||||
// Last activity per /mcp session, bumped on every request that resolves to a
|
||||
// session (initialize, and every subsequent handleRequest). The SSE pair
|
||||
// self-limits - a dropped socket fires res.on('close') and drains its maps
|
||||
// with no help from us. /mcp has no such signal: a caller can POST
|
||||
// initialize, take the session id, and drop the connection without ever
|
||||
// sending DELETE. Nothing in the SDK notices - the transport, its McpServer,
|
||||
// and its 24 registered tools sit in memory until process exit. This map
|
||||
// plus the reaper below is what bounds that.
|
||||
// session (initialize, and every subsequent handleRequest). /mcp has no
|
||||
// disconnect signal: a caller can POST initialize, take the session id, and
|
||||
// drop the connection without ever sending DELETE. Nothing in the SDK
|
||||
// notices - the transport, its McpServer, and its 24 registered tools sit in
|
||||
// memory until process exit. This map plus the reaper below is what bounds
|
||||
// that.
|
||||
const streamableLastSeen = new Map<string, number>();
|
||||
|
||||
// Upper bound on concurrent /mcp sessions per identity. A caller that forgets
|
||||
@@ -151,55 +151,45 @@ const streamableReapInterval = setInterval(() => {
|
||||
}, STREAMABLE_SESSION_SWEEP_MS);
|
||||
streamableReapInterval.unref();
|
||||
|
||||
// Any-origin CORS by design: browser-based MCP clients are first-class
|
||||
// callers, and Origin carries no security signal for a public server -
|
||||
// authorization is the bearer token alone. Applied via setHeader at the top
|
||||
// of the handler because Node merges prior setHeader() values into any later
|
||||
// writeHead(status, obj): that one insertion point covers the 401/403/404
|
||||
// branches below AND the responses the SDK transport writes itself inside
|
||||
// handleRequest.
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Authorization, Content-Type, mcp-session-id, mcp-protocol-version, last-event-id',
|
||||
'Access-Control-Expose-Headers': 'mcp-session-id',
|
||||
};
|
||||
|
||||
const httpServer = createServer(async (req, res) => {
|
||||
const url = new URL(req.url!, `http://localhost:${PORT}`);
|
||||
for (const [name, value] of Object.entries(CORS_HEADERS)) res.setHeader(name, value);
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/sse') {
|
||||
const auth = resolveIdentity(req.headers.authorization, authConfig, clientIpOf(req));
|
||||
if (!auth.ok) {
|
||||
res.writeHead(401, { 'Content-Type': 'text/plain', 'WWW-Authenticate': 'Bearer' });
|
||||
res.end(auth.reason);
|
||||
// Preflight, before the auth gate: browsers never attach Authorization to
|
||||
// an OPTIONS request, so a preflight inside the authed branch would 401.
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
const session = createMcpServer(auth.identity);
|
||||
sessionIdentities.set(transport.sessionId, auth.identity);
|
||||
transports.set(transport.sessionId, transport);
|
||||
res.on('close', () => {
|
||||
transports.delete(transport.sessionId);
|
||||
sessionIdentities.delete(transport.sessionId);
|
||||
});
|
||||
await session.connect(transport);
|
||||
} else if (req.method === 'POST' && url.pathname === '/messages') {
|
||||
const auth = resolveIdentity(req.headers.authorization, authConfig, clientIpOf(req));
|
||||
if (!auth.ok) {
|
||||
res.writeHead(401, { 'Content-Type': 'text/plain', 'WWW-Authenticate': 'Bearer' });
|
||||
res.end(auth.reason);
|
||||
|
||||
// Unauthenticated liveness probe. Creates no session and touches no rate
|
||||
// limit (the per-identity limiter lives inside the tool handlers, which
|
||||
// this never reaches).
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok', version: PKG_VERSION }));
|
||||
return;
|
||||
}
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
if (!sessionId) {
|
||||
res.writeHead(400);
|
||||
res.end('Missing sessionId');
|
||||
return;
|
||||
}
|
||||
const transport = transports.get(sessionId);
|
||||
if (!transport) {
|
||||
res.writeHead(404);
|
||||
res.end('Session not found');
|
||||
return;
|
||||
}
|
||||
// A valid token for identity A must not be able to drive identity B's session.
|
||||
if (sessionIdentities.get(sessionId) !== auth.identity) {
|
||||
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
||||
res.end('This session belongs to a different identity.');
|
||||
return;
|
||||
}
|
||||
await transport.handlePostMessage(req, res);
|
||||
} else if (url.pathname === '/mcp') {
|
||||
// Modern Streamable HTTP transport (MCP spec: SSE is deprecated). Same auth
|
||||
// gate and the same invariant as the SSE pair: a valid token for identity A
|
||||
// must not be able to drive identity B's session.
|
||||
|
||||
if (url.pathname === '/mcp') {
|
||||
// Streamable HTTP transport - the only MCP transport since 2.0.0 (the
|
||||
// deprecated SSE pair /sse + /messages was removed). A valid token for
|
||||
// identity A must not be able to drive identity B's session.
|
||||
const auth = resolveIdentity(req.headers.authorization, authConfig, clientIpOf(req));
|
||||
if (!auth.ok) {
|
||||
res.writeHead(401, { 'Content-Type': 'text/plain', 'WWW-Authenticate': 'Bearer' });
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import { describe, test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import type { IncomingHttpHeaders, IncomingMessage } from 'node:http';
|
||||
import { spawnServer, stopRawServer } from '../setup.ts';
|
||||
import type { RawServerHandle } from '../setup.ts';
|
||||
|
||||
// Covers the auth gate in packages/builder/src/index.ts against a real spawned
|
||||
// server. No unit test can see this: it's entirely in the (untyped-by-tsc-only-
|
||||
// in-the-sense-that-test-files-aren't-checked, but otherwise ordinary) HTTP
|
||||
// request handler wiring - who returns after a 401, what order the checks run
|
||||
// in, whether a token ever leaks. See .superpowers/sdd/pr5-integration-tests-report.md
|
||||
// for the mutation-testing evidence that these assertions are load-bearing.
|
||||
//
|
||||
// Deliberately built on raw node:http, not the MCP SDK Client/SSEClientTransport:
|
||||
// this file's whole point is exercising exact status codes, exact headers, and
|
||||
// - for the cross-identity case - sending a *different* Authorization header on
|
||||
// a POST than the one that opened the SSE session. SSEClientTransport fixes its
|
||||
// headers per transport instance and hides the session id entirely, so it can't
|
||||
// express that scenario at all.
|
||||
|
||||
const TOKEN_ALICE = 'tok-alice-9f2d7c1a';
|
||||
const TOKEN_BOB = 'tok-bob-4e8b02f5';
|
||||
const AUTH_ENV = { MCP_AUTH_TOKENS: `alice:${TOKEN_ALICE},bob:${TOKEN_BOB}` };
|
||||
|
||||
interface RawResponse {
|
||||
status: number;
|
||||
headers: IncomingHttpHeaders;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface SseResponse extends RawResponse {
|
||||
sessionId: string | null;
|
||||
/** Ends the underlying request/response so the server's res.on('close', ...) cleanup fires. */
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw GET /sse. Resolves as soon as either (a) a 200 arrives and the
|
||||
* `event: endpoint` line has been read (session established, connection left
|
||||
* open for the caller to POST against or destroy), or (b) the response ends
|
||||
* without ever reaching (a) (the 401/etc. cases - short body, stream closes
|
||||
* immediately since index.ts calls res.end() right after writeHead()).
|
||||
*/
|
||||
function getSse(port: number, headers: Record<string, string> = {}): Promise<SseResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = httpRequest({ host: '127.0.0.1', port, path: '/sse', method: 'GET', headers }, (res: IncomingMessage) => {
|
||||
let buf = '';
|
||||
let settled = false;
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk: string) => {
|
||||
buf += chunk;
|
||||
if (!settled && res.statusCode === 200 && buf.includes('event: endpoint')) {
|
||||
settled = true;
|
||||
const m = buf.match(/data:[^\n]*[?&]sessionId=([0-9a-fA-F-]+)/);
|
||||
resolve({ status: res.statusCode!, headers: res.headers, body: buf, sessionId: m ? m[1] : null, destroy: () => req.destroy() });
|
||||
}
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve({ status: res.statusCode ?? 0, headers: res.headers, body: buf, sessionId: null, destroy: () => {} });
|
||||
}
|
||||
});
|
||||
res.on('error', (err) => { if (!settled) { settled = true; reject(err); } });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function postMessages(port: number, path: string, headers: Record<string, string>, payload: string): Promise<RawResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = Buffer.from(payload, 'utf8');
|
||||
const req = httpRequest({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': String(body.length), ...headers },
|
||||
}, (res: IncomingMessage) => {
|
||||
let buf = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk: string) => { buf += chunk; });
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: buf }));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
// A minimal, genuinely valid JSON-RPC notification (matches the SDK's
|
||||
// JSONRPCNotificationSchema: {jsonrpc, method, params?} with .strict()). This
|
||||
// is the real message a client sends right after initialize - using it (over
|
||||
// some arbitrary method name) means there's no doubt handlePostMessage's own
|
||||
// parsing could be what rejects it.
|
||||
const INIT_NOTIFICATION = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
||||
|
||||
// Every response this file observes, scanned once at the end of the
|
||||
// auth-enabled block for a leaked token. Populated by record().
|
||||
const seenBodies: string[] = [];
|
||||
const seenHeaderDumps: string[] = [];
|
||||
function record<T extends RawResponse>(r: T): T {
|
||||
seenBodies.push(r.body);
|
||||
seenHeaderDumps.push(JSON.stringify(r.headers));
|
||||
return r;
|
||||
}
|
||||
|
||||
describe('auth gating - MCP_AUTH_TOKENS set', () => {
|
||||
let ctx: RawServerHandle;
|
||||
|
||||
before(async () => {
|
||||
ctx = await spawnServer(AUTH_ENV);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopRawServer(ctx);
|
||||
});
|
||||
|
||||
test('GET /sse with no Authorization header is rejected with 401 and WWW-Authenticate: Bearer', async () => {
|
||||
const r = record(await getSse(ctx.port));
|
||||
assert.equal(r.status, 401);
|
||||
assert.equal(r.headers['www-authenticate'], 'Bearer');
|
||||
});
|
||||
|
||||
test('GET /sse with an unrecognised token is rejected with 401', async () => {
|
||||
const r = record(await getSse(ctx.port, { Authorization: 'Bearer not-a-real-token' }));
|
||||
assert.equal(r.status, 401);
|
||||
assert.equal(r.headers['www-authenticate'], 'Bearer');
|
||||
});
|
||||
|
||||
test('GET /sse with a valid token connects', async () => {
|
||||
const r = record(await getSse(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }));
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.sessionId, 'expected an "endpoint" SSE event carrying a sessionId');
|
||||
r.destroy();
|
||||
});
|
||||
|
||||
test('POST /messages with no Authorization header is rejected with 401, not 400 or 404', async () => {
|
||||
// No sessionId at all - not even a bogus one. If auth were checked *after*
|
||||
// session lookup (the ordering bug this test exists to pin), a missing
|
||||
// sessionId would surface as 400 instead of 401.
|
||||
const r = record(await postMessages(ctx.port, '/messages', {}, INIT_NOTIFICATION));
|
||||
assert.equal(r.status, 401);
|
||||
assert.equal(r.headers['www-authenticate'], 'Bearer');
|
||||
});
|
||||
|
||||
test('POST /messages with a valid token for a different identity than the session owner is rejected with 403', async () => {
|
||||
const sse = record(await getSse(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }));
|
||||
assert.equal(sse.status, 200);
|
||||
assert.ok(sse.sessionId);
|
||||
try {
|
||||
const r = record(await postMessages(ctx.port, `/messages?sessionId=${sse.sessionId}`, { Authorization: `Bearer ${TOKEN_BOB}` }, INIT_NOTIFICATION));
|
||||
assert.equal(r.status, 403);
|
||||
} finally {
|
||||
sse.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /messages with the session's own identity is accepted", async () => {
|
||||
const sse = record(await getSse(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }));
|
||||
assert.equal(sse.status, 200);
|
||||
assert.ok(sse.sessionId);
|
||||
try {
|
||||
const r = record(await postMessages(ctx.port, `/messages?sessionId=${sse.sessionId}`, { Authorization: `Bearer ${TOKEN_ALICE}` }, INIT_NOTIFICATION));
|
||||
assert.equal(r.status, 202);
|
||||
} finally {
|
||||
sse.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('no token value leaked into any captured response body, response header, or stderr', () => {
|
||||
const stderr = ctx.stderr();
|
||||
for (const body of seenBodies) {
|
||||
assert.ok(!body.includes(TOKEN_ALICE), 'a response body must never contain the alice token');
|
||||
assert.ok(!body.includes(TOKEN_BOB), 'a response body must never contain the bob token');
|
||||
}
|
||||
for (const dump of seenHeaderDumps) {
|
||||
assert.ok(!dump.includes(TOKEN_ALICE), 'a response header must never contain the alice token');
|
||||
assert.ok(!dump.includes(TOKEN_BOB), 'a response header must never contain the bob token');
|
||||
}
|
||||
assert.ok(!stderr.includes(TOKEN_ALICE), "server stderr must never contain the alice token");
|
||||
assert.ok(!stderr.includes(TOKEN_BOB), "server stderr must never contain the bob token");
|
||||
});
|
||||
|
||||
test('the server process is still alive and accepting new connections after all of the above', async () => {
|
||||
// The regression this whole file exists to catch: a missing early `return`
|
||||
// after a 401 falls through into a second writeHead() on an already-ended
|
||||
// response, throws ERR_HTTP_HEADERS_SENT as an unhandled rejection inside
|
||||
// the request handler, and takes the whole process down with it - every
|
||||
// other in-flight and future connection drops. A per-request status-code
|
||||
// assertion can pass even when this happened to a *different* request, so
|
||||
// this checks the process itself, then proves liveness by actually using it.
|
||||
assert.equal(ctx.process.exitCode, null, 'server process must not have exited');
|
||||
assert.equal(ctx.process.killed, false, 'server process must not have been killed');
|
||||
const r = await getSse(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` });
|
||||
assert.equal(r.status, 200, 'server must still accept new connections');
|
||||
r.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth gating - MCP_AUTH_TOKENS unset (pre-auth behavior unchanged)', () => {
|
||||
let ctx: RawServerHandle;
|
||||
|
||||
before(async () => {
|
||||
// Explicit empty string, not just "don't pass the key": makes the
|
||||
// disabled state independent of whatever the ambient shell/CI environment
|
||||
// happens to have set, rather than relying on it happening to be unset.
|
||||
ctx = await spawnServer({ MCP_AUTH_TOKENS: '' });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopRawServer(ctx);
|
||||
});
|
||||
|
||||
test('GET /sse with no Authorization header still connects, exactly as before auth existed', async () => {
|
||||
const r = await getSse(ctx.port);
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.sessionId);
|
||||
r.destroy();
|
||||
});
|
||||
});
|
||||
185
packages/builder/test/integration/http-surface.test.ts
Normal file
185
packages/builder/test/integration/http-surface.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { IncomingHttpHeaders, IncomingMessage } from 'node:http';
|
||||
import { spawnServer, stopRawServer } from '../setup.ts';
|
||||
import type { RawServerHandle } from '../setup.ts';
|
||||
|
||||
// Covers the non-MCP HTTP surface of packages/builder/src/index.ts against a
|
||||
// real spawned server: the any-origin CORS contract (preflight, success
|
||||
// responses, and - the part a naive per-branch implementation drops - error
|
||||
// responses), the unauthenticated /health probe, and the hard 404 where the
|
||||
// removed SSE pair (/sse + /messages) used to answer. The gating suite
|
||||
// (streamable-http-gating.test.ts) owns everything behind the auth gate;
|
||||
// this file owns everything in front of it.
|
||||
|
||||
const TOKEN_ALICE = 'tok-alice-surface-5b1d90aa';
|
||||
const AUTH_ENV = { MCP_AUTH_TOKENS: `alice:${TOKEN_ALICE}` };
|
||||
|
||||
// The exact contract index.ts advertises. Asserted value-for-value, not just
|
||||
// presence: a broadened or narrowed header list is a behavior change for
|
||||
// every browser client and must show up as a diff here.
|
||||
const EXPECTED_CORS: Record<string, string> = {
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'access-control-allow-headers': 'Authorization, Content-Type, mcp-session-id, mcp-protocol-version, last-event-id',
|
||||
'access-control-expose-headers': 'mcp-session-id',
|
||||
};
|
||||
|
||||
const BUILDER_VERSION = JSON.parse(
|
||||
readFileSync(resolve(import.meta.dirname!, '../../package.json'), 'utf-8'),
|
||||
).version as string;
|
||||
|
||||
interface RawResponse {
|
||||
status: number;
|
||||
headers: IncomingHttpHeaders;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function rawRequest(port: number, method: string, path: string, headers: Record<string, string> = {}, body?: string): Promise<RawResponse> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const payload = body !== undefined ? Buffer.from(body, 'utf8') : undefined;
|
||||
const req = httpRequest({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
method,
|
||||
headers: payload ? { ...headers, 'Content-Length': String(payload.length) } : headers,
|
||||
}, (res: IncomingMessage) => {
|
||||
let buf = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk: string) => { buf += chunk; });
|
||||
res.on('end', () => resolvePromise({ status: res.statusCode ?? 0, headers: res.headers, body: buf }));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (payload) req.end(payload); else req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function initializeRequest() {
|
||||
return JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'http-surface-test', version: '1.0.0' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function postMcp(port: number, headers: Record<string, string>, payload: string): Promise<RawResponse> {
|
||||
return rawRequest(port, 'POST', '/mcp', {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
...headers,
|
||||
}, payload);
|
||||
}
|
||||
|
||||
function assertCors(headers: IncomingHttpHeaders, context: string): void {
|
||||
for (const [name, value] of Object.entries(EXPECTED_CORS)) {
|
||||
assert.equal(headers[name], value, `${context}: ${name} must be "${value}"`);
|
||||
}
|
||||
}
|
||||
|
||||
describe('HTTP surface - CORS, /health, and removed SSE routes (auth enabled)', () => {
|
||||
let ctx: RawServerHandle;
|
||||
|
||||
before(async () => {
|
||||
ctx = await spawnServer(AUTH_ENV);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopRawServer(ctx);
|
||||
});
|
||||
|
||||
test('OPTIONS /mcp with no Authorization answers 204 with the full CORS contract (preflight runs before the auth gate)', async () => {
|
||||
// Browsers never attach Authorization to a preflight. If OPTIONS were
|
||||
// dispatched inside the authed /mcp branch, every browser client would
|
||||
// die here with a 401 before its real request was ever sent.
|
||||
const r = await rawRequest(ctx.port, 'OPTIONS', '/mcp', {
|
||||
Origin: 'https://example.com',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
'Access-Control-Request-Headers': 'authorization, content-type, mcp-session-id',
|
||||
});
|
||||
assert.equal(r.status, 204);
|
||||
assertCors(r.headers, 'OPTIONS /mcp');
|
||||
});
|
||||
|
||||
test('OPTIONS /health answers 204 with the CORS contract', async () => {
|
||||
const r = await rawRequest(ctx.port, 'OPTIONS', '/health', { Origin: 'https://example.com' });
|
||||
assert.equal(r.status, 204);
|
||||
assertCors(r.headers, 'OPTIONS /health');
|
||||
});
|
||||
|
||||
test('GET /health needs no Authorization, reports the package version, and creates no session', async () => {
|
||||
const r = await rawRequest(ctx.port, 'GET', '/health');
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.headers['content-type']?.includes('application/json'));
|
||||
assert.deepEqual(JSON.parse(r.body), { status: 'ok', version: BUILDER_VERSION });
|
||||
assert.equal(r.headers['mcp-session-id'], undefined, '/health must not mint a session');
|
||||
assertCors(r.headers, 'GET /health');
|
||||
});
|
||||
|
||||
test('a 401 from a tokenless POST /mcp still carries the CORS headers (a browser can read the auth failure)', async () => {
|
||||
// The load-bearing case for the setHeader-at-handler-top placement:
|
||||
// error branches use writeHead(status, obj), which would silently drop
|
||||
// CORS if the headers were only attached per-branch. Without CORS on the
|
||||
// 401, a browser client sees an opaque network error instead of the
|
||||
// status code.
|
||||
const r = await postMcp(ctx.port, {}, initializeRequest());
|
||||
assert.equal(r.status, 401);
|
||||
assert.equal(r.headers['www-authenticate'], 'Bearer');
|
||||
assertCors(r.headers, '401 POST /mcp');
|
||||
});
|
||||
|
||||
test('a successful initialize exposes mcp-session-id to browser callers', async () => {
|
||||
// Access-Control-Expose-Headers is what lets a browser script read the
|
||||
// session id off the response; without it the SDK's browser build cannot
|
||||
// continue past initialize even though the header is on the wire.
|
||||
const r = await postMcp(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }, initializeRequest());
|
||||
assert.equal(r.status, 200);
|
||||
assert.ok(r.headers['mcp-session-id'], 'initialize must return a session id');
|
||||
assertCors(r.headers, '200 POST /mcp initialize');
|
||||
});
|
||||
|
||||
test('the removed SSE pair is gone: GET /sse and POST /messages both 404, still carrying CORS headers', async () => {
|
||||
const sse = await rawRequest(ctx.port, 'GET', '/sse', { Authorization: `Bearer ${TOKEN_ALICE}`, Accept: 'text/event-stream' });
|
||||
assert.equal(sse.status, 404);
|
||||
assertCors(sse.headers, 'GET /sse');
|
||||
|
||||
const messages = await rawRequest(ctx.port, 'POST', '/messages', {
|
||||
Authorization: `Bearer ${TOKEN_ALICE}`,
|
||||
'Content-Type': 'application/json',
|
||||
}, JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }));
|
||||
assert.equal(messages.status, 404);
|
||||
assertCors(messages.headers, 'POST /messages');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP surface - /health bypasses the rate limiter', () => {
|
||||
let ctx: RawServerHandle;
|
||||
|
||||
before(async () => {
|
||||
// Budget of 1: if /health consumed rate-limit budget (or were bucketed
|
||||
// at the HTTP layer at all), the second call here would already be
|
||||
// refused. The limiter lives inside tool handlers, which /health never
|
||||
// reaches - this pins that placement.
|
||||
ctx = await spawnServer({ MCP_AUTH_TOKENS: '', VECTOR_RATE_LIMIT_PER_MINUTE: '1' });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopRawServer(ctx);
|
||||
});
|
||||
|
||||
test('repeated /health probes all answer 200 under a rate-limit budget of 1', async () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const r = await rawRequest(ctx.port, 'GET', '/health');
|
||||
assert.equal(r.status, 200, `/health probe ${i}/5 must not be rate-limited`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { spawnServer, stopRawServer } from '../setup.ts';
|
||||
import type { RawServerHandle } from '../setup.ts';
|
||||
|
||||
@@ -54,7 +54,7 @@ const RATE_LIMIT_ENV = {
|
||||
const RATE_LIMIT_PATTERN = /^Rate limit exceeded\. Retry after \d+ms\.$/;
|
||||
|
||||
async function connect(port: number, token: string): Promise<Client> {
|
||||
const transport = new SSEClientTransport(new URL(`http://localhost:${port}/sse`), {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(`http://localhost:${port}/mcp`), {
|
||||
requestInit: { headers: { Authorization: `Bearer ${token}` } },
|
||||
});
|
||||
const client = new Client({ name: 'rate-limit-integration-test', version: '1.0.0' });
|
||||
|
||||
@@ -7,46 +7,45 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
||||
import { spawnServer, stopRawServer } from '../setup.ts';
|
||||
import type { RawServerHandle } from '../setup.ts';
|
||||
|
||||
// Covers the /mcp Streamable HTTP route in packages/builder/src/index.ts - the
|
||||
// modern, dual-stack sibling of the deprecated /sse + /messages pair that
|
||||
// auth-gating.test.ts covers. Same two things at stake, same reason no unit
|
||||
// test can see them: the exact status-code/header wiring against a real
|
||||
// spawned server (401 checked before session lookup, 404 on an unknown
|
||||
// session, 403 on cross-identity - on both POST and GET), and whether
|
||||
// Covers the /mcp Streamable HTTP route in packages/builder/src/index.ts -
|
||||
// the server's only MCP transport since 2.0.0 removed the deprecated
|
||||
// /sse + /messages pair. Two things at stake, and no unit test can see
|
||||
// either: the exact status-code/header wiring against a real spawned server
|
||||
// (401 checked before session lookup, 404 on an unknown session, 403 on
|
||||
// cross-identity - on both POST and GET), and whether
|
||||
// streamableTransports/streamableIdentities/streamableLastSeen actually
|
||||
// drain on every teardown path: DELETE (onsessionclosed, then
|
||||
// transport.onclose - redundant with each other on that one path), the idle
|
||||
// reaper, and the per-identity session cap's eviction (the latter two only
|
||||
// ever go through transport.onclose - see index.ts's own comment on that
|
||||
// callback for why onsessionclosed does not cover them). The reaper and the
|
||||
// cap exist because the SSE pair self-limits on an abrupt disconnect
|
||||
// (res.on('close') drains its maps with no help from us) and /mcp does not:
|
||||
// nothing tells the server a caller dropped its socket without DELETE, so an
|
||||
// un-reaped session - transport, McpServer, and its 24 tools - would
|
||||
// otherwise sit in memory until process exit. Both bounds are exercised
|
||||
// below with env-shrunk thresholds (VECTOR_MCP_SESSION_IDLE_MS /
|
||||
// VECTOR_MCP_SESSION_SWEEP_MS / VECTOR_MCP_MAX_SESSIONS_PER_IDENTITY), never
|
||||
// the real multi-minute defaults.
|
||||
// cap exist because /mcp has no disconnect signal: nothing tells the server
|
||||
// a caller dropped its socket without DELETE, so an un-reaped session -
|
||||
// transport, McpServer, and its 24 tools - would otherwise sit in memory
|
||||
// until process exit. Both bounds are exercised below with env-shrunk
|
||||
// thresholds (VECTOR_MCP_SESSION_IDLE_MS / VECTOR_MCP_SESSION_SWEEP_MS /
|
||||
// VECTOR_MCP_MAX_SESSIONS_PER_IDENTITY), never the real multi-minute
|
||||
// defaults.
|
||||
//
|
||||
// Mixed transport strategy, deliberately:
|
||||
// - Case 1 uses the real SDK Client + StreamableHTTPClientTransport, to prove
|
||||
// a real client can drive this route end to end (not just that our own raw
|
||||
// assertions are self-consistent).
|
||||
// - Every other case uses raw node:http, for the same reason auth-gating.test.ts
|
||||
// does: this file's point is exact status codes and exact headers, plus -
|
||||
// for the cross-identity case - presenting a *different* Authorization
|
||||
// header than the one that opened the session, and reusing one session's id
|
||||
// under another identity's headers. StreamableHTTPClientTransport fixes its
|
||||
// Authorization header per transport instance and manages mcp-session-id
|
||||
// internally, so it cannot express either scenario.
|
||||
// - Every other case uses raw node:http: this file's point is exact status
|
||||
// codes and exact headers, plus - for the cross-identity case - presenting
|
||||
// a *different* Authorization header than the one that opened the session,
|
||||
// and reusing one session's id under another identity's headers.
|
||||
// StreamableHTTPClientTransport fixes its Authorization header per
|
||||
// transport instance and manages mcp-session-id internally, so it cannot
|
||||
// express either scenario.
|
||||
//
|
||||
// One wrinkle the SSE pair never had: the route does not set
|
||||
// enableJsonResponse, so a POST that carries a JSON-RPC *request* (as opposed
|
||||
// to a bare notification) gets back a 200 with Content-Type: text/event-stream
|
||||
// and the JSON-RPC response embedded in a single "data: ..." line, not a
|
||||
// plain application/json body. extractJsonRpc() below unwraps that. Responses
|
||||
// this route writes itself - 401/403/404/400 - are always short plain-text
|
||||
// bodies (res.end(literal) before the transport is ever touched), never SSE.
|
||||
// One wrinkle: the route does not set enableJsonResponse, so a POST that
|
||||
// carries a JSON-RPC *request* (as opposed to a bare notification) gets back
|
||||
// a 200 with Content-Type: text/event-stream and the JSON-RPC response
|
||||
// embedded in a single "data: ..." line, not a plain application/json body.
|
||||
// extractJsonRpc() below unwraps that. Responses this route writes itself -
|
||||
// 401/403/404/400 - are always short plain-text bodies (res.end(literal)
|
||||
// before the transport is ever touched), never SSE.
|
||||
|
||||
const TOKEN_ALICE = 'tok-alice-shttp-3f8b21ec';
|
||||
const TOKEN_BOB = 'tok-bob-shttp-9a06d4c7';
|
||||
@@ -346,6 +345,22 @@ describe('streamable HTTP gating - MCP_AUTH_TOKENS set', () => {
|
||||
assert.ok(!stderr.includes(TOKEN_ALICE), 'server stderr must never contain the alice token');
|
||||
assert.ok(!stderr.includes(TOKEN_BOB), 'server stderr must never contain the bob token');
|
||||
});
|
||||
|
||||
test('the server process is still alive and accepting new sessions after all of the above', async () => {
|
||||
// Ported from the retired SSE auth-gating suite; the regression class is
|
||||
// transport-independent: a missing early `return` after a 401 falls
|
||||
// through into a second writeHead() on an already-ended response, throws
|
||||
// ERR_HTTP_HEADERS_SENT as an unhandled rejection inside the request
|
||||
// handler, and takes the whole process down with it - every other
|
||||
// in-flight and future connection drops. A per-request status-code
|
||||
// assertion can pass even when this happened to a *different* request,
|
||||
// so this checks the process itself, then proves liveness by actually
|
||||
// using it.
|
||||
assert.equal(ctx.process.exitCode, null, 'server process must not have exited');
|
||||
assert.equal(ctx.process.killed, false, 'server process must not have been killed');
|
||||
const r = await postMcp(ctx.port, { Authorization: `Bearer ${TOKEN_ALICE}` }, initializeRequest());
|
||||
assert.equal(r.status, 200, 'server must still accept a fresh initialize');
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamable HTTP gating - per-IP separation for anonymous callers', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { spawn, ChildProcess } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
|
||||
const PROJECT_ROOT = resolve(import.meta.dirname!, '..');
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function startServer(): Promise<ServerContext> {
|
||||
child.on('exit', (code) => { clearTimeout(timeout); reject(new Error(`Server exited with code ${code}`)); });
|
||||
});
|
||||
|
||||
const transport = new SSEClientTransport(new URL(`http://localhost:${port}/sse`));
|
||||
const transport = new StreamableHTTPClientTransport(new URL(`http://localhost:${port}/mcp`));
|
||||
const client = new Client({ name: 'integration-test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@apexfusion/vector-mcp-shared",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@apexfusion/vector-mcp-signer",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Local signing companion for the Vector MCP server. Holds the key; no network.",
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@anastasia-labs/cardano-multiplatform-lib-nodejs": "6.0.2-3",
|
||||
"@lucid-evolution/wallet": "^0.1.72",
|
||||
"@modelcontextprotocol/sdk": "^1.4.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -6,10 +6,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { registerSignerTools } from './tools.js';
|
||||
// Single source of truth for the advertised version; inlined at build time.
|
||||
import { version as PKG_VERSION } from '../package.json';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const server = new McpServer({ name: 'vector-mcp-signer', version: '0.1.0' });
|
||||
const server = new McpServer({ name: 'vector-mcp-signer', version: PKG_VERSION });
|
||||
registerSignerTools(server, config);
|
||||
|
||||
// stdout is the MCP channel - every diagnostic must go to stderr.
|
||||
|
||||
17
server.json
17
server.json
@@ -3,7 +3,7 @@
|
||||
"name": "io.github.Apex-Fusion/vector",
|
||||
"title": "Vector (Apex Fusion)",
|
||||
"description": "24 keyless tools for AI agents on Vector, the Apex Fusion eUTXO L2: wallets, contracts, registry.",
|
||||
"version": "0.1.0",
|
||||
"version": "2.0.0",
|
||||
"repository": {
|
||||
"url": "https://github.com/Apex-Fusion/mcp-server",
|
||||
"source": "github"
|
||||
@@ -24,21 +24,6 @@
|
||||
"default": "mainnet"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vector.{network}.apexfusion.org/sse",
|
||||
"variables": {
|
||||
"network": {
|
||||
"description": "Vector network to target",
|
||||
"isRequired": true,
|
||||
"choices": [
|
||||
"mainnet",
|
||||
"testnet"
|
||||
],
|
||||
"default": "mainnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user