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
> 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) │ │

View File

@@ -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
View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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"

View File

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

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 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' });

View File

@@ -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', () => {

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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.

View File

@@ -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"
}
}
}
]
}