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:
david
2026-08-17 14:06:42 +02:00
parent e56f15700c
commit cc6e0ce83c
16 changed files with 354 additions and 405 deletions

37
CHANGELOG.md Normal file
View 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.

View File

@@ -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 > 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). > still gate access with `MCP_AUTH_TOKENS` - see [Configuration](#configuration).
> **Transport: both hosted instances serve `/mcp`, the modern Streamable HTTP endpoint.** > **Transport: Streamable HTTP only, as of 2.0.0.** The legacy SSE pair (`/sse` +
> It is the recommended transport and the endpoint listed in the table above. The legacy SSE > `/messages`), deprecated by the MCP spec (2026-07-28 revision), was removed in this release -
> pair (`/sse` + `/messages`) remains available on both hosted instances for clients that need > those paths now answer 404. Every client connects to `/mcp`, the endpoint the table above
> it, deprecated by the MCP spec (2026-07-28 revision) and retained for compatibility. > 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. Connect from Claude Code in one command. No token needed for the hosted instances.
Modern transport (Streamable HTTP) - recommended:
```bash ```bash
claude mcp add --transport http vector-mcp https://mcp.vector.mainnet.apexfusion.org/mcp claude mcp add --transport http vector-mcp https://mcp.vector.mainnet.apexfusion.org/mcp
``` ```
Legacy SSE transport - still answered by both hosted instances: 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`.
```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`.
> **Security notice: the non-custodial migration is complete, in this codebase and on both > **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 > 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) - **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 - **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 - **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) ## MCP Tools (24)
@@ -199,15 +194,14 @@ npm start
# Server listens on port 3000 (configurable via PORT env var) # Server listens on port 3000 (configurable via PORT env var)
``` ```
Connect with the modern transport: Connect:
```bash ```bash
claude mcp add --transport http vector-mcp http://localhost:3000/mcp 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 Add `--header "Authorization: Bearer <your-token>"` if you set `MCP_AUTH_TOKENS`. A liveness
SSE pair (`/sse` + `/messages`, deprecated by the MCP spec, retained for compatibility) is probe is at `GET /health` - unauthenticated, returns `{"status":"ok","version":"<version>"}`.
still available at `http://localhost:3000/sse` for clients that need it.
If this instance will be reachable by anyone but you, set `MCP_AUTH_TOKENS` first - see [Configuration](#configuration) below. 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). [`packages/signer/README.md`](packages/signer/README.md#configuration).
> **Running a public instance?** Set `MCP_AUTH_TOKENS` to gate access to known callers, each > **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 > 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); > 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 > 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 │ │ Claude / GPT / etc. │◄───────────►│ vector-mcp-server │
│ (any MCP client) │ /sse + /mcp │ │ │ (any MCP client) │ /mcp │ │
└──────────────────────┘ │ ┌────────────────────┐ │ └──────────────────────┘ │ ┌────────────────────┐ │
│ │ Rate Limiter │ │ │ │ Rate Limiter │ │
│ │ (60 calls/min) │ │ │ │ (60 calls/min) │ │

View File

@@ -206,7 +206,7 @@ TDD throughout: failing test before implementation, every PR.
| C | Submission error handling | | C | Submission error handling |
| D | `min(1)` APEX floor → real dust floor | | D | `min(1)` APEX floor → real dust floor |
| E | Endpoint URLs in error text | | 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 | | G | Float → bigint amount math |
| H | Dependency updates | | H | Dependency updates |

53
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@apexfusion/mcp-server-monorepo", "name": "@apexfusion/mcp-server-monorepo",
"version": "0.1.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@apexfusion/mcp-server-monorepo", "name": "@apexfusion/mcp-server-monorepo",
"version": "0.1.0", "version": "2.0.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "ISC", "license": "ISC",
"workspaces": [ "workspaces": [
@@ -1754,9 +1754,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1771,9 +1768,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1788,9 +1782,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1805,9 +1796,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1822,9 +1810,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1839,9 +1824,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1856,9 +1838,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1873,9 +1852,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1890,9 +1866,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1907,9 +1880,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1924,9 +1894,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1941,9 +1908,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1958,9 +1922,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -5290,11 +5251,11 @@
}, },
"packages/builder": { "packages/builder": {
"name": "@apexfusion/vector-mcp-builder", "name": "@apexfusion/vector-mcp-builder",
"version": "0.1.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1027.0", "@aws-sdk/client-s3": "^3.1027.0",
"@lucid-evolution/lucid": "^0.4.29", "@lucid-evolution/lucid": "^0.4.29",
"@modelcontextprotocol/sdk": "^1.4.1", "@modelcontextprotocol/sdk": "^1.30.0",
"@noble/hashes": "^1.7.1", "@noble/hashes": "^1.7.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"zod": "^3.24.1" "zod": "^3.24.1"
@@ -5311,7 +5272,7 @@
}, },
"packages/shared": { "packages/shared": {
"name": "@apexfusion/vector-mcp-shared", "name": "@apexfusion/vector-mcp-shared",
"version": "0.1.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@lucid-evolution/lucid": "^0.4.29", "@lucid-evolution/lucid": "^0.4.29",
"@noble/hashes": "^1.7.1" "@noble/hashes": "^1.7.1"
@@ -5322,11 +5283,11 @@
}, },
"packages/signer": { "packages/signer": {
"name": "@apexfusion/vector-mcp-signer", "name": "@apexfusion/vector-mcp-signer",
"version": "0.1.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@anastasia-labs/cardano-multiplatform-lib-nodejs": "6.0.2-3", "@anastasia-labs/cardano-multiplatform-lib-nodejs": "6.0.2-3",
"@lucid-evolution/wallet": "^0.1.72", "@lucid-evolution/wallet": "^0.1.72",
"@modelcontextprotocol/sdk": "^1.4.1", "@modelcontextprotocol/sdk": "^1.30.0",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"bin": { "bin": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@apexfusion/mcp-server-monorepo", "name": "@apexfusion/mcp-server-monorepo",
"version": "0.1.0", "version": "2.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": ["packages/*"], "workspaces": ["packages/*"],
@@ -15,7 +15,7 @@
"test:smoke": "npm run build && node --import tsx --test packages/builder/test/smoke/*.test.ts", "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: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": "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" "test:integration:signer": "node --import tsx --test packages/signer/test/integration/*.test.ts"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@apexfusion/vector-mcp-builder", "name": "@apexfusion/vector-mcp-builder",
"version": "0.1.0", "version": "2.0.0",
"private": false, "private": false,
"type": "module", "type": "module",
"description": "MCP server for Vector - AI agent interface for the Apex Fusion eUTXO L2", "description": "MCP server for Vector - AI agent interface for the Apex Fusion eUTXO L2",
@@ -17,7 +17,7 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1027.0", "@aws-sdk/client-s3": "^3.1027.0",
"@lucid-evolution/lucid": "^0.4.29", "@lucid-evolution/lucid": "^0.4.29",
"@modelcontextprotocol/sdk": "^1.4.1", "@modelcontextprotocol/sdk": "^1.30.0",
"@noble/hashes": "^1.7.1", "@noble/hashes": "^1.7.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"zod": "^3.24.1" "zod": "^3.24.1"

View File

@@ -25,17 +25,20 @@ if (existsSync(envPath)) {
} }
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 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 { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { registerVectorTools } from "./vector/index.js"; import { registerVectorTools } from "./vector/index.js";
import { loadAuthConfig, resolveIdentity, clientIpOf } from "./auth.js"; import { loadAuthConfig, resolveIdentity, clientIpOf } from "./auth.js";
import { intEnv } from "./env.js"; import { intEnv } from "./env.js";
import { RATE_LIMIT_PER_MINUTE } from "./vector/rate-limiter.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) { function createMcpServer(identity: string) {
const server = new McpServer({ const server = new McpServer({
name: "vector-mcp-server", name: "vector-mcp-server",
version: "1.0.0", version: PKG_VERSION,
}); });
registerVectorTools(server, identity); registerVectorTools(server, identity);
return server; return server;
@@ -52,18 +55,15 @@ if (authConfig.enabled) {
} }
const PORT = parseInt(process.env.PORT || '3000'); 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 streamableTransports = new Map<string, StreamableHTTPServerTransport>();
const streamableIdentities = new Map<string, string>(); const streamableIdentities = new Map<string, string>();
// Last activity per /mcp session, bumped on every request that resolves to a // Last activity per /mcp session, bumped on every request that resolves to a
// session (initialize, and every subsequent handleRequest). The SSE pair // session (initialize, and every subsequent handleRequest). /mcp has no
// self-limits - a dropped socket fires res.on('close') and drains its maps // disconnect signal: a caller can POST initialize, take the session id, and
// with no help from us. /mcp has no such signal: a caller can POST // drop the connection without ever sending DELETE. Nothing in the SDK
// initialize, take the session id, and drop the connection without ever // notices - the transport, its McpServer, and its 24 registered tools sit in
// sending DELETE. Nothing in the SDK notices - the transport, its McpServer, // memory until process exit. This map plus the reaper below is what bounds
// and its 24 registered tools sit in memory until process exit. This map // that.
// plus the reaper below is what bounds that.
const streamableLastSeen = new Map<string, number>(); const streamableLastSeen = new Map<string, number>();
// Upper bound on concurrent /mcp sessions per identity. A caller that forgets // Upper bound on concurrent /mcp sessions per identity. A caller that forgets
@@ -151,55 +151,45 @@ const streamableReapInterval = setInterval(() => {
}, STREAMABLE_SESSION_SWEEP_MS); }, STREAMABLE_SESSION_SWEEP_MS);
streamableReapInterval.unref(); 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 httpServer = createServer(async (req, res) => {
const url = new URL(req.url!, `http://localhost:${PORT}`); 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') { // Preflight, before the auth gate: browsers never attach Authorization to
const auth = resolveIdentity(req.headers.authorization, authConfig, clientIpOf(req)); // an OPTIONS request, so a preflight inside the authed branch would 401.
if (!auth.ok) { if (req.method === 'OPTIONS') {
res.writeHead(401, { 'Content-Type': 'text/plain', 'WWW-Authenticate': 'Bearer' }); res.writeHead(204);
res.end(auth.reason); res.end();
return; return;
} }
const transport = new SSEServerTransport('/messages', res);
const session = createMcpServer(auth.identity); // Unauthenticated liveness probe. Creates no session and touches no rate
sessionIdentities.set(transport.sessionId, auth.identity); // limit (the per-identity limiter lives inside the tool handlers, which
transports.set(transport.sessionId, transport); // this never reaches).
res.on('close', () => { if (req.method === 'GET' && url.pathname === '/health') {
transports.delete(transport.sessionId); res.writeHead(200, { 'Content-Type': 'application/json' });
sessionIdentities.delete(transport.sessionId); res.end(JSON.stringify({ status: 'ok', version: PKG_VERSION }));
});
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);
return; return;
} }
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) { if (url.pathname === '/mcp') {
res.writeHead(400); // Streamable HTTP transport - the only MCP transport since 2.0.0 (the
res.end('Missing sessionId'); // deprecated SSE pair /sse + /messages was removed). A valid token for
return; // identity A must not be able to drive identity B's session.
}
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.
const auth = resolveIdentity(req.headers.authorization, authConfig, clientIpOf(req)); const auth = resolveIdentity(req.headers.authorization, authConfig, clientIpOf(req));
if (!auth.ok) { if (!auth.ok) {
res.writeHead(401, { 'Content-Type': 'text/plain', 'WWW-Authenticate': 'Bearer' }); res.writeHead(401, { 'Content-Type': 'text/plain', 'WWW-Authenticate': 'Bearer' });

View File

@@ -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();
});
});

View 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`);
}
});
});

View File

@@ -1,7 +1,7 @@
import { describe, test, before, after } from 'node:test'; import { describe, test, before, after } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 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 { spawnServer, stopRawServer } from '../setup.ts';
import type { RawServerHandle } 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\.$/; const RATE_LIMIT_PATTERN = /^Rate limit exceeded\. Retry after \d+ms\.$/;
async function connect(port: number, token: string): Promise<Client> { 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}` } }, requestInit: { headers: { Authorization: `Bearer ${token}` } },
}); });
const client = new Client({ name: 'rate-limit-integration-test', version: '1.0.0' }); const client = new Client({ name: 'rate-limit-integration-test', version: '1.0.0' });

View File

@@ -7,46 +7,45 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
import { spawnServer, stopRawServer } from '../setup.ts'; import { spawnServer, stopRawServer } from '../setup.ts';
import type { RawServerHandle } from '../setup.ts'; import type { RawServerHandle } from '../setup.ts';
// Covers the /mcp Streamable HTTP route in packages/builder/src/index.ts - the // Covers the /mcp Streamable HTTP route in packages/builder/src/index.ts -
// modern, dual-stack sibling of the deprecated /sse + /messages pair that // the server's only MCP transport since 2.0.0 removed the deprecated
// auth-gating.test.ts covers. Same two things at stake, same reason no unit // /sse + /messages pair. Two things at stake, and no unit test can see
// test can see them: the exact status-code/header wiring against a real // either: the exact status-code/header wiring against a real spawned server
// spawned server (401 checked before session lookup, 404 on an unknown // (401 checked before session lookup, 404 on an unknown session, 403 on
// session, 403 on cross-identity - on both POST and GET), and whether // cross-identity - on both POST and GET), and whether
// streamableTransports/streamableIdentities/streamableLastSeen actually // streamableTransports/streamableIdentities/streamableLastSeen actually
// drain on every teardown path: DELETE (onsessionclosed, then // drain on every teardown path: DELETE (onsessionclosed, then
// transport.onclose - redundant with each other on that one path), the idle // 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 // 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 // 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 // callback for why onsessionclosed does not cover them). The reaper and the
// cap exist because the SSE pair self-limits on an abrupt disconnect // cap exist because /mcp has no disconnect signal: nothing tells the server
// (res.on('close') drains its maps with no help from us) and /mcp does not: // a caller dropped its socket without DELETE, so an un-reaped session -
// nothing tells the server a caller dropped its socket without DELETE, so an // transport, McpServer, and its 24 tools - would otherwise sit in memory
// un-reaped session - transport, McpServer, and its 24 tools - would // until process exit. Both bounds are exercised below with env-shrunk
// otherwise sit in memory until process exit. Both bounds are exercised // thresholds (VECTOR_MCP_SESSION_IDLE_MS / VECTOR_MCP_SESSION_SWEEP_MS /
// below with env-shrunk thresholds (VECTOR_MCP_SESSION_IDLE_MS / // VECTOR_MCP_MAX_SESSIONS_PER_IDENTITY), never the real multi-minute
// VECTOR_MCP_SESSION_SWEEP_MS / VECTOR_MCP_MAX_SESSIONS_PER_IDENTITY), never // defaults.
// the real multi-minute defaults.
// //
// Mixed transport strategy, deliberately: // Mixed transport strategy, deliberately:
// - Case 1 uses the real SDK Client + StreamableHTTPClientTransport, to prove // - 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 // a real client can drive this route end to end (not just that our own raw
// assertions are self-consistent). // assertions are self-consistent).
// - Every other case uses raw node:http, for the same reason auth-gating.test.ts // - Every other case uses raw node:http: this file's point is exact status
// does: this file's point is exact status codes and exact headers, plus - // codes and exact headers, plus - for the cross-identity case - presenting
// for the cross-identity case - presenting a *different* Authorization // a *different* Authorization header than the one that opened the session,
// header than the one that opened the session, and reusing one session's id // and reusing one session's id under another identity's headers.
// under another identity's headers. StreamableHTTPClientTransport fixes its // StreamableHTTPClientTransport fixes its Authorization header per
// Authorization header per transport instance and manages mcp-session-id // transport instance and manages mcp-session-id internally, so it cannot
// internally, so it cannot express either scenario. // express either scenario.
// //
// One wrinkle the SSE pair never had: the route does not set // One wrinkle: the route does not set enableJsonResponse, so a POST that
// enableJsonResponse, so a POST that carries a JSON-RPC *request* (as opposed // carries a JSON-RPC *request* (as opposed to a bare notification) gets back
// to a bare notification) gets back a 200 with Content-Type: text/event-stream // a 200 with Content-Type: text/event-stream and the JSON-RPC response
// and the JSON-RPC response embedded in a single "data: ..." line, not a // embedded in a single "data: ..." line, not a plain application/json body.
// plain application/json body. extractJsonRpc() below unwraps that. Responses // extractJsonRpc() below unwraps that. Responses this route writes itself -
// this route writes itself - 401/403/404/400 - are always short plain-text // 401/403/404/400 - are always short plain-text bodies (res.end(literal)
// bodies (res.end(literal) before the transport is ever touched), never SSE. // before the transport is ever touched), never SSE.
const TOKEN_ALICE = 'tok-alice-shttp-3f8b21ec'; const TOKEN_ALICE = 'tok-alice-shttp-3f8b21ec';
const TOKEN_BOB = 'tok-bob-shttp-9a06d4c7'; 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_ALICE), 'server stderr must never contain the alice token');
assert.ok(!stderr.includes(TOKEN_BOB), 'server stderr must never contain the bob 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', () => { describe('streamable HTTP gating - per-IP separation for anonymous callers', () => {

View File

@@ -2,7 +2,7 @@ import { spawn, ChildProcess } from 'node:child_process';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 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!, '..'); 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}`)); }); 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' }); const client = new Client({ name: 'integration-test', version: '1.0.0' });
await client.connect(transport); await client.connect(transport);

View File

@@ -1,6 +1,6 @@
{ {
"name": "@apexfusion/vector-mcp-shared", "name": "@apexfusion/vector-mcp-shared",
"version": "0.1.0", "version": "2.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": { "exports": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@apexfusion/vector-mcp-signer", "name": "@apexfusion/vector-mcp-signer",
"version": "0.1.0", "version": "2.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Local signing companion for the Vector MCP server. Holds the key; no network.", "description": "Local signing companion for the Vector MCP server. Holds the key; no network.",
@@ -12,7 +12,7 @@
"dependencies": { "dependencies": {
"@anastasia-labs/cardano-multiplatform-lib-nodejs": "6.0.2-3", "@anastasia-labs/cardano-multiplatform-lib-nodejs": "6.0.2-3",
"@lucid-evolution/wallet": "^0.1.72", "@lucid-evolution/wallet": "^0.1.72",
"@modelcontextprotocol/sdk": "^1.4.1", "@modelcontextprotocol/sdk": "^1.30.0",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -6,10 +6,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { loadConfig } from './config.js'; import { loadConfig } from './config.js';
import { registerSignerTools } from './tools.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 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); registerSignerTools(server, config);
// stdout is the MCP channel - every diagnostic must go to stderr. // stdout is the MCP channel - every diagnostic must go to stderr.

View File

@@ -3,7 +3,7 @@
"name": "io.github.Apex-Fusion/vector", "name": "io.github.Apex-Fusion/vector",
"title": "Vector (Apex Fusion)", "title": "Vector (Apex Fusion)",
"description": "24 keyless tools for AI agents on Vector, the Apex Fusion eUTXO L2: wallets, contracts, registry.", "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": { "repository": {
"url": "https://github.com/Apex-Fusion/mcp-server", "url": "https://github.com/Apex-Fusion/mcp-server",
"source": "github" "source": "github"
@@ -24,21 +24,6 @@
"default": "mainnet" "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"
}
}
} }
] ]
} }