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