From cc6e0ce83c163eea5c6c631e42720f7a1fbe856b Mon Sep 17 00:00:00 2001 From: david Date: Mon, 17 Aug 2026 14:06:42 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 37 +++ README.md | 41 ++-- docs/architecture/non-custodial-split.md | 2 +- package-lock.json | 53 +---- package.json | 4 +- packages/builder/package.json | 4 +- packages/builder/src/index.ts | 104 ++++---- .../test/integration/auth-gating.test.ts | 223 ------------------ .../test/integration/http-surface.test.ts | 185 +++++++++++++++ .../integration/rate-limit-gating.test.ts | 4 +- .../streamable-http-gating.test.ts | 71 +++--- packages/builder/test/setup.ts | 4 +- packages/shared/package.json | 2 +- packages/signer/package.json | 4 +- packages/signer/src/index.ts | 4 +- server.json | 17 +- 16 files changed, 354 insertions(+), 405 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 packages/builder/test/integration/auth-gating.test.ts create mode 100644 packages/builder/test/integration/http-surface.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..97d264b --- /dev/null +++ b/CHANGELOG.md @@ -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 /mcp`. The `sse` remote was removed from + the MCP Registry manifest (`server.json`). + +### Added + +- `GET /health`: unauthenticated liveness probe, returns + `{"status":"ok","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. diff --git a/README.md b/README.md index 0df36f6..ea43851 100644 --- a/README.md +++ b/README.md @@ -25,28 +25,23 @@ Hosted instances run on both networks, exposing all 24 tools: > accepts a mnemonic from any caller, with or without a token. Self-hosted deployments can > still gate access with `MCP_AUTH_TOKENS` - see [Configuration](#configuration). -> **Transport: both hosted instances serve `/mcp`, the modern Streamable HTTP endpoint.** -> It is the recommended transport and the endpoint listed in the table above. The legacy SSE -> pair (`/sse` + `/messages`) remains available on both hosted instances for clients that need -> it, deprecated by the MCP spec (2026-07-28 revision) and retained for compatibility. +> **Transport: Streamable HTTP only, as of 2.0.0.** The legacy SSE pair (`/sse` + +> `/messages`), deprecated by the MCP spec (2026-07-28 revision), was removed in this release - +> those paths now answer 404. Every client connects to `/mcp`, the endpoint the table above +> lists. This is a breaking change for SSE clients: reconnect with `--transport http` and the +> `/mcp` URL (below). The server also answers any-origin CORS (browser MCP clients are +> first-class callers) and exposes an unauthenticated `GET /health` liveness probe. +> **This describes the code** - each hosted instance serves it once this release deploys there +> (testnet automatically on push to `main`, mainnet on a deliberate cutover dispatch). Connect from Claude Code in one command. No token needed for the hosted instances. -Modern transport (Streamable HTTP) - recommended: - ```bash claude mcp add --transport http vector-mcp https://mcp.vector.mainnet.apexfusion.org/mcp ``` -Legacy SSE transport - still answered by both hosted instances: - -```bash -claude mcp add --transport sse vector-mcp https://mcp.vector.mainnet.apexfusion.org/sse -``` - -Swap in the testnet host for either command to connect to testnet instead. Self-hosting (below) -serves both transports too, and gives every caller open access to your own instance unless you -set `MCP_AUTH_TOKENS`. +Swap in the testnet host to connect to testnet instead. Self-hosting (below) gives every +caller open access to your own instance unless you set `MCP_AUTH_TOKENS`. > **Security notice: the non-custodial migration is complete, in this codebase and on both > hosted instances, as of the 2026-07-31 cutover deploy.** No tool in this repository accepts a @@ -98,7 +93,7 @@ limitations. - **Agent messaging** - send on-chain messages between agents via TX metadata (keyless) - **Self-Improvement Module** - browse, submit, critique, and endorse improvement proposals; the on-chain module is live on Vector mainnet, and every write tool is keyless in this codebase - **Safety controls** - per-identity rate limiting; spend limits for every family are enforced by the local signer, per user - the server holds no spend-limit state of its own -- **Dual transport** - modern Streamable HTTP (`/mcp`) plus legacy SSE (`/sse` + `/messages`, deprecated by the MCP spec (2026-07-28 revision), retained for compatibility) +- **Streamable HTTP transport** - the modern MCP transport at `/mcp`, with any-origin CORS for browser clients and an unauthenticated `GET /health` probe (the legacy SSE pair was removed in 2.0.0) ## MCP Tools (24) @@ -199,15 +194,14 @@ npm start # Server listens on port 3000 (configurable via PORT env var) ``` -Connect with the modern transport: +Connect: ```bash claude mcp add --transport http vector-mcp http://localhost:3000/mcp ``` -Add `--header "Authorization: Bearer "` 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 "` if you set `MCP_AUTH_TOKENS`. A liveness +probe is at `GET /health` - unauthenticated, returns `{"status":"ok","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) │ │ diff --git a/docs/architecture/non-custodial-split.md b/docs/architecture/non-custodial-split.md index f5d7c74..02a4387 100644 --- a/docs/architecture/non-custodial-split.md +++ b/docs/architecture/non-custodial-split.md @@ -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 | diff --git a/package-lock.json b/package-lock.json index b6faaa2..9be832e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index e90e829..be31aae 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/builder/package.json b/packages/builder/package.json index 44395af..517a1c2 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -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" diff --git a/packages/builder/src/index.ts b/packages/builder/src/index.ts index 3508a06..a6de70f 100644 --- a/packages/builder/src/index.ts +++ b/packages/builder/src/index.ts @@ -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(); -const sessionIdentities = new Map(); const streamableTransports = new Map(); const streamableIdentities = new Map(); // 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(); // 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 = { + '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); - 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); - 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. + // 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; + } + + // 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; + } + + 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' }); diff --git a/packages/builder/test/integration/auth-gating.test.ts b/packages/builder/test/integration/auth-gating.test.ts deleted file mode 100644 index 13a48f7..0000000 --- a/packages/builder/test/integration/auth-gating.test.ts +++ /dev/null @@ -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 = {}): Promise { - 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, payload: string): Promise { - 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(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(); - }); -}); diff --git a/packages/builder/test/integration/http-surface.test.ts b/packages/builder/test/integration/http-surface.test.ts new file mode 100644 index 0000000..eb735ce --- /dev/null +++ b/packages/builder/test/integration/http-surface.test.ts @@ -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 = { + '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 = {}, body?: string): Promise { + 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, payload: string): Promise { + 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`); + } + }); +}); diff --git a/packages/builder/test/integration/rate-limit-gating.test.ts b/packages/builder/test/integration/rate-limit-gating.test.ts index 1a7d9b9..f23830c 100644 --- a/packages/builder/test/integration/rate-limit-gating.test.ts +++ b/packages/builder/test/integration/rate-limit-gating.test.ts @@ -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 { - 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' }); diff --git a/packages/builder/test/integration/streamable-http-gating.test.ts b/packages/builder/test/integration/streamable-http-gating.test.ts index e36006e..ff09c46 100644 --- a/packages/builder/test/integration/streamable-http-gating.test.ts +++ b/packages/builder/test/integration/streamable-http-gating.test.ts @@ -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', () => { diff --git a/packages/builder/test/setup.ts b/packages/builder/test/setup.ts index c0ec336..1f437f0 100644 --- a/packages/builder/test/setup.ts +++ b/packages/builder/test/setup.ts @@ -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 { 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); diff --git a/packages/shared/package.json b/packages/shared/package.json index d4b78f3..450ea36 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@apexfusion/vector-mcp-shared", - "version": "0.1.0", + "version": "2.0.0", "private": true, "type": "module", "exports": { diff --git a/packages/signer/package.json b/packages/signer/package.json index 5e327d3..2d2e03f 100644 --- a/packages/signer/package.json +++ b/packages/signer/package.json @@ -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": { diff --git a/packages/signer/src/index.ts b/packages/signer/src/index.ts index b29c122..76348c2 100644 --- a/packages/signer/src/index.ts +++ b/packages/signer/src/index.ts @@ -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. diff --git a/server.json b/server.json index cb2436e..f807175 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.Apex-Fusion/vector", "title": "Vector (Apex Fusion)", "description": "24 keyless tools for AI agents on Vector, the Apex Fusion eUTXO L2: wallets, contracts, registry.", - "version": "0.1.0", + "version": "2.0.0", "repository": { "url": "https://github.com/Apex-Fusion/mcp-server", "source": "github" @@ -24,21 +24,6 @@ "default": "mainnet" } } - }, - { - "type": "sse", - "url": "https://mcp.vector.{network}.apexfusion.org/sse", - "variables": { - "network": { - "description": "Vector network to target", - "isRequired": true, - "choices": [ - "mainnet", - "testnet" - ], - "default": "mainnet" - } - } } ] }