diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13f44bcf..b586d6e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,11 @@ on: pull_request: branches: [main, master, feature/**] +permissions: + contents: read + pull-requests: read + statuses: read + jobs: # Consumer compatibility: the standalone SDK lockfile on every supported # Node version, with no workspace tooling involved. @@ -118,3 +123,140 @@ jobs: run: npm run lint --workspace @terminal49/mcp - name: Check API gateway (Vite+ and anti-slop) run: npm run lint:api + + mcp-protocol-compat: + name: MCP protocol ${{ matrix.protocol-version }} + runs-on: ${{ (startsWith(vars.CI_RUNNER, 'blacksmith-') && vars.CI_RUNNER) || 'blacksmith-4vcpu-ubuntu-2404' }} + strategy: + fail-fast: false + matrix: + protocol-version: + - '2026-07-28' + - '2025-11-25' + - '2025-06-18' + - '2025-03-26' + - '2024-11-05' + - '2024-10-07' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + cache-dependency-path: package-lock.json + - name: Install workspace dependencies + run: npm ci + - name: Build SDK dependency + run: npm run build --workspace @terminal49/sdk + - name: Build MCP server + run: npm run build --workspace @terminal49/mcp + - name: POST protocol handshake to built MCP server + env: + MCP_PROTOCOL_VERSION: ${{ matrix.protocol-version }} + run: npm run test:protocol --workspace @terminal49/mcp + + mcp-preview-protocol: + name: MCP preview ${{ matrix.protocol-version }} + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + github.actor != 'dependabot[bot]' + needs: [mcp, mcp-protocol-compat] + runs-on: ${{ (startsWith(vars.CI_RUNNER, 'blacksmith-') && vars.CI_RUNNER) || 'blacksmith-4vcpu-ubuntu-2404' }} + strategy: + fail-fast: false + matrix: + protocol-version: + - '2026-07-28' + - '2025-11-25' + steps: + - name: Check preview credential availability + id: credential + env: + MCP_EVAL_TOKEN: ${{ secrets.MCP_EVAL_TOKEN }} + run: | + if [[ -n "$MCP_EVAL_TOKEN" ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping authenticated preview smoke because MCP_EVAL_TOKEN is unavailable" + fi + - uses: actions/checkout@v4 + if: steps.credential.outputs.available == 'true' + - uses: actions/setup-node@v6 + if: steps.credential.outputs.available == 'true' + with: + node-version: 24 + cache: 'npm' + cache-dependency-path: package-lock.json + - name: Install workspace dependencies + if: steps.credential.outputs.available == 'true' + run: npm ci + - name: Wait for this commit's Vercel preview + if: steps.credential.outputs.available == 'true' + id: vercel + env: + GH_TOKEN: ${{ github.token }} + PREVIEW_SHA: ${{ github.event.pull_request.head.sha }} + run: | + state=pending + for attempt in {1..60}; do + state="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${PREVIEW_SHA}/status" \ + --jq '[.statuses[] | select(.context == "Vercel")][0].state // "pending"')" + if [[ "$state" == "success" ]]; then + break + fi + if [[ "$state" == "failure" || "$state" == "error" ]]; then + echo "Vercel preview failed for ${PREVIEW_SHA}" + exit 1 + fi + sleep 10 + done + if [[ "$state" != "success" ]]; then + echo "Timed out waiting for Vercel preview for ${PREVIEW_SHA}" + exit 1 + fi + inspector_url="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${PREVIEW_SHA}/status" \ + --jq '[.statuses[] | select(.context == "Vercel")][0].target_url // empty')" + if [[ -z "$inspector_url" ]]; then + echo "Vercel status for ${PREVIEW_SHA} has no deployment URL" + exit 1 + fi + echo "inspector-url=${inspector_url}" >> "$GITHUB_OUTPUT" + - name: Resolve Vercel preview endpoint + if: steps.credential.outputs.available == 'true' + id: preview + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + VERCEL_INSPECTOR_URL: ${{ steps.vercel.outputs.inspector-url }} + run: | + preview_url="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --paginate --slurp | jq -r --arg inspector "$VERCEL_INSPECTOR_URL" \ + 'flatten | [.[] | select((.user.login == "vercel[bot]" or .user.login == "vercel") and (.body | contains($inspector)))][-1].body // "" | (try capture("\\[Preview\\]\\((?https://[^)]+\\.vercel\\.app)\\)") catch {}) | .url // empty')" + if [[ -z "$preview_url" ]]; then + echo "Could not resolve a Vercel preview URL for ${VERCEL_INSPECTOR_URL}" + exit 1 + fi + echo "endpoint=${preview_url}/mcp" >> "$GITHUB_OUTPUT" + - name: POST handshake and tools/list to Vercel preview + if: steps.credential.outputs.available == 'true' + env: + MCP_HTTP_ENDPOINT: ${{ steps.preview.outputs.endpoint }} + MCP_HTTP_TOKEN: ${{ secrets.MCP_EVAL_TOKEN }} + MCP_PROTOCOL_VERSION: ${{ matrix.protocol-version }} + run: npm run test:http-protocol --workspace @terminal49/mcp + - name: Verify preview still belongs to this commit + if: steps.credential.outputs.available == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + VERCEL_INSPECTOR_URL: ${{ steps.vercel.outputs.inspector-url }} + run: | + matches="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ + --paginate --slurp | jq -r --arg inspector "$VERCEL_INSPECTOR_URL" \ + 'flatten | [.[] | select((.user.login == "vercel[bot]" or .user.login == "vercel") and (.body | contains($inspector)))] | length')" + if [[ "$matches" != "1" ]]; then + echo "Vercel preview changed while the smoke test was running" + exit 1 + fi diff --git a/api/mcp.ts b/api/mcp.ts index 2fb3cfed..3f1eabf2 100644 --- a/api/mcp.ts +++ b/api/mcp.ts @@ -8,8 +8,11 @@ import '../packages/mcp/src/instrument.js'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { randomUUID, timingSafeEqual } from 'node:crypto'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { + createMcpHandler, + type McpHttpHandler, +} from '@modelcontextprotocol/server'; +import { toNodeHandler } from '@modelcontextprotocol/node'; import * as Sentry from '@sentry/node'; import { createTerminal49McpServer } from '../packages/mcp/src/server.js'; import { flushPostHogEvents } from '../packages/mcp/src/posthog.js'; @@ -37,7 +40,7 @@ function setCorsHeaders(res: ResponseLike): void { res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); res.setHeader( 'Access-Control-Allow-Headers', - 'Content-Type, Authorization, MCP-Protocol-Version, Mcp-Session-Id', + 'Content-Type, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id', ); } @@ -385,8 +388,7 @@ export default async function handler( return; } - let server: McpServer | undefined; - let transport: StreamableHTTPServerTransport | undefined; + let mcpHandler: McpHttpHandler | undefined; let cleanupPromise: Promise | null = null; let shouldFlushSentry = false; @@ -399,21 +401,12 @@ export default async function handler( const cleanupErrors: string[] = []; logLifecycle('mcp.request.cleanup.start', requestId, { reason }); - if (transport?.close) { + if (mcpHandler) { try { - await transport.close(); + await mcpHandler.close(); } catch (error) { const err = error as Error; - cleanupErrors.push(`transport.close: ${err.message}`); - } - } - - if (server?.close) { - try { - await server.close(); - } catch (error) { - const err = error as Error; - cleanupErrors.push(`server.close: ${err.message}`); + cleanupErrors.push(`handler.close: ${err.message}`); } } @@ -552,15 +545,32 @@ export default async function handler( setCorsHeaders(res); - // Create MCP server and per-request transport. - server = createTerminal49McpServer( - resolvedTerminal49Auth.apiToken, - process.env.T49_API_BASE_URL, - resolvedTerminal49Auth.accountId, + const observeMcpError = (error: Error): void => { + captureMcpException(error); + shouldFlushSentry = true; + logLifecycle('mcp.request.error', requestId, { + error: error.name, + message: error.message, + }); + }; + + // The v2 HTTP entry serves the 2026-07-28 per-request protocol and keeps + // the established stateless 2025-era path for older clients. + mcpHandler = createMcpHandler( + () => + createTerminal49McpServer( + resolvedTerminal49Auth.apiToken, + process.env.T49_API_BASE_URL, + resolvedTerminal49Auth.accountId, + ), + { + legacy: 'stateless', + responseMode: 'json', + onerror: observeMcpError, + }, ); - transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, // Stateless mode - enableJsonResponse: true, // Return JSON instead of SSE + const nodeHandler = toNodeHandler(mcpHandler, { + onerror: observeMcpError, }); // Clean up on response lifecycle and also in finally to guarantee closure. @@ -571,9 +581,7 @@ export default async function handler( scheduleCleanup('response_finish'); }); - // Connect server to transport and handle request - await server.connect(transport); - await transport.handleRequest(req, res, req.body); + await nodeHandler(req, res, req.body); logLifecycle('mcp.request.complete', requestId, { reason: 'handled' }); } catch (error) { const err = error as Error; diff --git a/package-lock.json b/package-lock.json index cc27bbfd..d0d920f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -254,6 +254,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -271,6 +272,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -288,6 +290,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -305,6 +308,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -322,6 +326,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -339,6 +344,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -356,6 +362,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -373,6 +380,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -390,6 +398,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -407,6 +416,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -424,6 +434,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -441,6 +452,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -458,6 +470,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -475,6 +488,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -492,6 +506,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -509,6 +524,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -526,6 +542,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -543,6 +560,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -560,6 +578,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -577,6 +596,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -594,6 +614,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -611,6 +632,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -628,6 +650,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -645,6 +668,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -662,6 +686,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -679,6 +704,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -3182,11 +3208,65 @@ "zod": "^3.20.0" } }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.0.0.tgz", + "integrity": "sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "hono": "^4.11.4" + }, + "peerDependenciesMeta": { + "hono": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -3222,6 +3302,19 @@ } } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.9", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", @@ -4191,34 +4284,43 @@ } }, "node_modules/@posthog/mcp": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@posthog/mcp/-/mcp-0.10.1.tgz", - "integrity": "sha512-TMe6BvDCzMMaWP30jMuJKWDbqrDZ6xLjknzQFGXkaD5EPiq4EcU8FImjZBSZ6l1ipQdnPmkx2Z+R4bKhlx9Z6A==", + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/@posthog/mcp/-/mcp-0.11.7.tgz", + "integrity": "sha512-hDokMScYN1LAYbQdO4vBSF7/hpnC0WvEz0xpC/m1zru7jaNXFIy8AMm4F6K40J3BX0EE9LzNdAPPA1bp5ntxsQ==", "license": "MIT", "dependencies": { - "@posthog/core": "^1.45.1" + "@posthog/core": "^1.48.2" }, "engines": { "node": "^20.20.0 || >=22.22.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": ">=1.26.0", + "@modelcontextprotocol/server": ">=2.0.0", "posthog-node": "^5.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + }, + "@modelcontextprotocol/server": { + "optional": true + } } }, "node_modules/@posthog/mcp/node_modules/@posthog/core": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.1.tgz", - "integrity": "sha512-mxw31XdYgt/SnlwqLPAcltK67q+QmsiYjVLGQ4GbBc8OJ7O4yRFSDwAXt8QsokHokeyEZtTRWM6jDHL7LYMx1A==", + "version": "1.48.6", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.6.tgz", + "integrity": "sha512-lvSO1nrxxakrAfB51fetHC29gSqdDtT+AyRrGlnc5nDSWhiBGtyqKNjDqsbnlTQoj14bAaWXS7RtzuGoTo5IsQ==", "license": "MIT", "dependencies": { - "@posthog/types": "^1.404.1" + "@posthog/types": "^1.405.0" } }, "node_modules/@posthog/types": { - "version": "1.404.1", - "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.404.1.tgz", - "integrity": "sha512-i2Gei6ARfOSBeTN4s2yUP1p97s2UNI+1NWmtLjhnR/V6t3RFOfI1sBWKcJNWHjtoOCCWoAFU+PNPY6SgT2VtEQ==", + "version": "1.405.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz", + "integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==", "license": "MIT" }, "node_modules/@puppeteer/browsers": { @@ -6190,6 +6292,8 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -6848,6 +6952,8 @@ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", @@ -7556,6 +7662,8 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" }, @@ -7603,6 +7711,8 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6.6.0" } @@ -8693,6 +8803,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "devOptional": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -8705,6 +8816,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -8788,6 +8900,8 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -8831,6 +8945,8 @@ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ip-address": "^10.2.0" }, @@ -9057,6 +9173,8 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", @@ -9176,6 +9294,8 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.8" } @@ -9973,6 +10093,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -10830,7 +10951,9 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/is-regex": { "version": "1.2.1", @@ -11134,7 +11257,9 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "optional": true, + "peer": true }, "node_modules/jsona": { "version": "1.12.1", @@ -12073,6 +12198,8 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.8" } @@ -12082,6 +12209,8 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" }, @@ -12902,6 +13031,8 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -12911,6 +13042,8 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -13339,6 +13472,8 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.6" } @@ -14083,6 +14218,8 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", + "optional": true, + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -14141,6 +14278,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=16.20.0" @@ -14651,6 +14789,8 @@ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -15382,6 +15522,8 @@ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", @@ -15550,6 +15692,8 @@ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", @@ -15607,6 +15751,8 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -16861,6 +17007,8 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", @@ -16879,6 +17027,8 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" }, @@ -18345,6 +18495,8 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", + "optional": true, + "peer": true, "peerDependencies": { "zod": "^3.25.28 || ^4" } @@ -18363,14 +18515,16 @@ "name": "@terminal49/mcp", "version": "0.1.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", - "@posthog/mcp": "0.10.1", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "@posthog/mcp": "^0.11.7", "@sentry/node": "^10.55.0", "@terminal49/sdk": "0.3.1", "posthog-node": "^5.0.0", "zod": "~4.3.6" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/node": "^24.10.13", "@vitest/coverage-v8": "4.1.10", "tsx": "^4.22.4", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 10f5d36d..90753cfa 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -15,6 +15,8 @@ "build": "tsc", "test": "vp test", "test:coverage": "vp test --run --coverage", + "test:http-protocol": "node scripts/http-protocol-smoke.mjs", + "test:protocol": "node scripts/protocol-smoke.mjs", "lint": "vp lint src && vp fmt --check src", "format": "vp fmt --write src", "format:check": "vp fmt --check src", @@ -25,14 +27,16 @@ "eval:check": "tsc --noEmit -p tsconfig.eval.json" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", - "@posthog/mcp": "0.10.1", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "@posthog/mcp": "^0.11.7", "@sentry/node": "^10.55.0", "@terminal49/sdk": "0.3.1", "posthog-node": "^5.0.0", "zod": "~4.3.6" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.0.0", "@types/node": "^24.10.13", "@vitest/coverage-v8": "4.1.10", "tsx": "^4.22.4", diff --git a/packages/mcp/scripts/http-protocol-smoke.mjs b/packages/mcp/scripts/http-protocol-smoke.mjs new file mode 100644 index 00000000..cf4f69e0 --- /dev/null +++ b/packages/mcp/scripts/http-protocol-smoke.mjs @@ -0,0 +1,91 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; + +const endpoint = process.env.MCP_HTTP_ENDPOINT; +const protocolVersion = process.env.MCP_PROTOCOL_VERSION; +const token = process.env.MCP_HTTP_TOKEN; +const scheme = process.env.MCP_HTTP_AUTH_SCHEME || 'Token'; + +if (!endpoint) { + throw new Error('MCP_HTTP_ENDPOINT is required'); +} +if (!protocolVersion) { + throw new Error('MCP_PROTOCOL_VERSION is required'); +} +if (!token) { + throw new Error( + 'MCP_HTTP_TOKEN is required because the deployed Terminal49 MCP rejects unauthenticated handshakes', + ); +} + +const isModern = protocolVersion === '2026-07-28'; +const expectedHandshake = isModern ? 'server/discover' : 'initialize'; +const observedRequests = []; + +const client = new Client( + { name: 'terminal49-preview-protocol-ci', version: '1.0.0' }, + isModern + ? { versionNegotiation: { mode: { pin: protocolVersion } } } + : { supportedProtocolVersions: [protocolVersion] }, +); +const transport = new StreamableHTTPClientTransport(new URL(endpoint), { + requestInit: { + headers: { + Authorization: `${scheme} ${token}`, + }, + }, + fetch: async (url, init) => { + const requestBody = + typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + if (requestBody?.method) { + observedRequests.push(requestBody.method); + } + + const response = await fetch(url, init); + const responseBody = await response.clone().text(); + if ( + response.status === 400 || + /unsupported protocol version/i.test(responseBody) + ) { + throw new Error( + `Preview rejected ${protocolVersion} (${response.status}): ${responseBody}`, + ); + } + if (!response.ok) { + throw new Error( + `Preview request failed for ${protocolVersion} (${response.status}): ${responseBody}`, + ); + } + return response; + }, +}); + +try { + await client.connect(transport); + + if (client.getNegotiatedProtocolVersion() !== protocolVersion) { + throw new Error( + `Expected ${protocolVersion}, negotiated ${client.getNegotiatedProtocolVersion() ?? 'nothing'}`, + ); + } + if (!observedRequests.includes(expectedHandshake)) { + throw new Error( + `Expected ${expectedHandshake} POST for ${protocolVersion}; observed ${observedRequests.join(', ')}`, + ); + } + + const { tools } = await client.listTools(); + if (tools.length !== 10) { + throw new Error( + `Expected 10 preview tools over ${protocolVersion}, received ${tools.length}`, + ); + } + + console.log( + `Preview MCP ${protocolVersion}: ${expectedHandshake} accepted; ${tools.length} tools listed`, + ); +} finally { + await client.close(); +} diff --git a/packages/mcp/scripts/protocol-smoke.mjs b/packages/mcp/scripts/protocol-smoke.mjs new file mode 100644 index 00000000..afb53a25 --- /dev/null +++ b/packages/mcp/scripts/protocol-smoke.mjs @@ -0,0 +1,82 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import { createMcpHandler } from '@modelcontextprotocol/server'; +import { createTerminal49McpServer } from '../dist/server.js'; + +const protocolVersion = process.env.MCP_PROTOCOL_VERSION; +if (!protocolVersion) { + throw new Error('MCP_PROTOCOL_VERSION is required'); +} + +const isModern = protocolVersion === '2026-07-28'; +const expectedHandshake = isModern ? 'server/discover' : 'initialize'; +const observedRequests = []; + +const handler = createMcpHandler( + () => createTerminal49McpServer('ci-test-token', 'https://api.test'), + { + legacy: 'stateless', + responseMode: 'json', + }, +); +const client = new Client( + { name: 'terminal49-protocol-ci', version: '1.0.0' }, + isModern + ? { versionNegotiation: { mode: { pin: protocolVersion } } } + : { supportedProtocolVersions: [protocolVersion] }, +); +const transport = new StreamableHTTPClientTransport( + new URL('https://mcp.test/mcp'), + { + fetch: async (url, init) => { + const requestBody = + typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + if (requestBody?.method) { + observedRequests.push(requestBody.method); + } + + const response = await handler.fetch(new Request(url, init)); + const responseBody = await response.clone().text(); + if ( + response.status === 400 || + /unsupported protocol version/i.test(responseBody) + ) { + throw new Error( + `Protocol ${protocolVersion} was rejected (${response.status}): ${responseBody}`, + ); + } + return response; + }, + }, +); + +try { + await client.connect(transport); + + if (client.getNegotiatedProtocolVersion() !== protocolVersion) { + throw new Error( + `Expected ${protocolVersion}, negotiated ${client.getNegotiatedProtocolVersion() ?? 'nothing'}`, + ); + } + if (!observedRequests.includes(expectedHandshake)) { + throw new Error( + `Expected ${expectedHandshake} POST for ${protocolVersion}; observed ${observedRequests.join(', ')}`, + ); + } + + const { tools } = await client.listTools(); + if (tools.length !== 10) { + throw new Error( + `Expected 10 tools over ${protocolVersion}, received ${tools.length}`, + ); + } + + console.log( + `MCP ${protocolVersion}: ${expectedHandshake} accepted; ${tools.length} tools listed`, + ); +} finally { + await client.close(); + await handler.close(); +} diff --git a/packages/mcp/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts index b38472ec..56412eb8 100644 --- a/packages/mcp/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -1,4 +1,8 @@ -import { getCompleter } from '@modelcontextprotocol/sdk/server/completable.js'; +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import { createMcpHandler } from '@modelcontextprotocol/server'; import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { buildListContract, @@ -35,15 +39,6 @@ beforeEach(() => { containersList.mockReset(); }); -// The SDK stores prompt arg schemas as a Zod object. Zod v3 exposes `.shape`; -// Zod v4 keeps it on `_zod.def.shape`. Read it the way the SDK does so the -// completion test stays version-robust. -function getArgShape(argsSchema: any): Record { - const v4Shape = argsSchema?._zod?.def?.shape; - const v3Shape = argsSchema?.shape; - return (v4Shape ?? v3Shape) as Record; -} - function _hasResponseContract(schema: unknown): boolean { const typedSchema = schema as { _def?: { @@ -485,7 +480,7 @@ describe('MCP server wiring', () => { expect(instructions.length).toBeGreaterThan(400); }); - it('registers the completions capability so carrier SCAC completion is reachable', async () => { + it('returns carrier SCAC completion values over MCP', async () => { shippingLinesList.mockResolvedValue([ { scac: 'MAEU', name: 'Maersk', shortName: 'Maersk' }, { @@ -495,57 +490,49 @@ describe('MCP server wiring', () => { }, ]); - const server = createTerminal49McpServer('token'); - - // PRIMARY (registered-path) assertion. This is the actual HIGH-finding - // fix: `completable` must wrap the INNER string with `.optional()` applied - // AFTER, because the SDK unwraps ZodOptional before checking isCompletable - // when deciding whether to advertise `completions` and register a - // completion handler. With the previous OUTER-optional wiring the symbol - // sat on the ZodOptional, the SDK's unwrap missed it, and the capability - // was NEVER advertised — so these two assertions FAIL against the pre-fix - // wiring and PASS only once Fix 1 is applied. - const capabilities = (server as any).server.getCapabilities(); - expect(capabilities.completions).toBeDefined(); - expect((server as any)._completionHandlerInitialized).toBe(true); - - // SECONDARY (unit) assertion on the completion VALUES. We resolve the - // completer exactly the way the SDK's prompt registration does — unwrap - // the ZodOptional and read isCompletable/getCompleter off the inner - // string — then exercise it. (The SDK's prompt-completion *handler* checks - // isCompletable on the un-unwrapped optional field, so values are surfaced - // here via the same inner-string the registration keys off, rather than - // through handlePromptCompletion.) - const prompt = (server as any)._registeredPrompts['track-shipment']; - const carrierField = getArgShape(prompt.argsSchema).carrier as { - _def?: { innerType?: unknown }; - }; - // Symbol lives on the inner string, not the outer ZodOptional. - expect(getCompleter(carrierField as any)).toBeUndefined(); - const innerCompleter = getCompleter(carrierField._def?.innerType as any); - expect(innerCompleter).toBeTypeOf('function'); - - // "m" matches Maersk (MAEU) and Mediterranean (MSCU); "ma" matches only Maersk. - expect(await innerCompleter!('m', undefined)).toEqual(['MAEU', 'MSCU']); - expect(await innerCompleter!('ma', undefined)).toEqual(['MAEU']); - - // The completer reused the live supported-lines lookup, filtered by input. - expect(shippingLinesList).toHaveBeenCalled(); - }); - - it('carrier completion degrades to empty suggestions when the API errors', async () => { - shippingLinesList.mockRejectedValue(new Error('upstream unavailable')); - - const server = createTerminal49McpServer('token'); - const prompt = (server as any)._registeredPrompts['track-shipment']; - // Resolve the completer off the inner string (the ZodOptional wraps it), - // matching how the SDK keys completion off the unwrapped inner schema. - const carrierField = getArgShape(prompt.argsSchema).carrier as { - _def?: { innerType?: unknown }; - }; - const completer = getCompleter(carrierField._def?.innerType as any); + const handler = createMcpHandler( + () => createTerminal49McpServer('token', 'https://api.test'), + { + legacy: 'stateless', + responseMode: 'json', + }, + ); + const client = new Client( + { name: 'terminal49-completion-test', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } }, + ); + const transport = new StreamableHTTPClientTransport( + new URL('https://mcp.test/mcp'), + { + fetch: (url, init) => handler.fetch(new Request(url, init)), + }, + ); - await expect(completer!('ma', undefined)).resolves.toEqual([]); + try { + await client.connect(transport); + + const broadMatch = await client.complete({ + ref: { type: 'ref/prompt', name: 'track-shipment' }, + argument: { name: 'carrier', value: 'm' }, + }); + expect(broadMatch.completion.values).toEqual(['MAEU', 'MSCU']); + + const narrowMatch = await client.complete({ + ref: { type: 'ref/prompt', name: 'track-shipment' }, + argument: { name: 'carrier', value: 'ma' }, + }); + expect(narrowMatch.completion.values).toEqual(['MAEU']); + + shippingLinesList.mockRejectedValue(new Error('upstream unavailable')); + const degraded = await client.complete({ + ref: { type: 'ref/prompt', name: 'track-shipment' }, + argument: { name: 'carrier', value: 'ma' }, + }); + expect(degraded.completion.values).toEqual([]); + } finally { + await client.close(); + await handler.close(); + } }); it('list_containers result includes resource_link blocks with valid container URIs', async () => { diff --git a/packages/mcp/src/posthog.test.ts b/packages/mcp/src/posthog.test.ts index f8847b4d..74754d47 100644 --- a/packages/mcp/src/posthog.test.ts +++ b/packages/mcp/src/posthog.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from '@modelcontextprotocol/server'; const instrumentMock = vi.fn(); const postHogConstructed = vi.fn(); diff --git a/packages/mcp/src/posthog.ts b/packages/mcp/src/posthog.ts index 6f7c6c3d..46dbfb7b 100644 --- a/packages/mcp/src/posthog.ts +++ b/packages/mcp/src/posthog.ts @@ -16,8 +16,7 @@ * * @see https://posthog.com/docs/mcp-analytics/installation */ - -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from '@modelcontextprotocol/server'; import type { BeforeSendFn, UserIdentity } from '@posthog/mcp'; // `PostHog` is imported from `@posthog/mcp`'s re-export rather than from // `posthog-node` directly, and deliberately so. `posthog-node` is a *peer* diff --git a/packages/mcp/src/protocol-compat.test.ts b/packages/mcp/src/protocol-compat.test.ts new file mode 100644 index 00000000..f0d9742d --- /dev/null +++ b/packages/mcp/src/protocol-compat.test.ts @@ -0,0 +1,104 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import { createMcpHandler } from '@modelcontextprotocol/server'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { createTerminal49McpServer } from './server.js'; + +const LEGACY_PROTOCOL_VERSIONS = [ + '2025-11-25', + '2025-06-18', + '2025-03-26', + '2024-11-05', + '2024-10-07', +] as const; + +const openConnections: Array<{ + client: Client; + handler: ReturnType; +}> = []; + +async function connectClient( + options: + | { era: 'modern' } + | { + era: 'legacy'; + protocolVersion: (typeof LEGACY_PROTOCOL_VERSIONS)[number]; + }, +): Promise { + const handler = createMcpHandler( + () => createTerminal49McpServer('test-token', 'https://api.test'), + { + legacy: 'stateless', + responseMode: 'json', + }, + ); + const client = new Client( + { name: 'terminal49-protocol-test', version: '1.0.0' }, + options.era === 'modern' + ? { versionNegotiation: { mode: { pin: '2026-07-28' } } } + : { supportedProtocolVersions: [options.protocolVersion] }, + ); + const transport = new StreamableHTTPClientTransport( + new URL('https://mcp.test/mcp'), + { + fetch: (url, init) => handler.fetch(new Request(url, init)), + }, + ); + + await client.connect(transport); + openConnections.push({ client, handler }); + return client; +} + +afterEach(async () => { + await Promise.all( + openConnections.splice(0).map(async ({ client, handler }) => { + await client.close(); + await handler.close(); + }), + ); +}); + +describe('MCP protocol compatibility', () => { + it.each([ + { era: 'modern' as const, protocolVersion: '2026-07-28' }, + ...LEGACY_PROTOCOL_VERSIONS.map((protocolVersion) => ({ + era: 'legacy' as const, + protocolVersion, + })), + ])( + 'lists the complete server surface over $protocolVersion', + async ({ era, protocolVersion }) => { + const client = + era === 'modern' + ? await connectClient({ era }) + : await connectClient({ era, protocolVersion }); + + expect(client.getProtocolEra()).toBe(era); + expect(client.getNegotiatedProtocolVersion()).toBe(protocolVersion); + + const [{ tools }, { prompts }, { resources }, { resourceTemplates }] = + await Promise.all([ + client.listTools(), + client.listPrompts(), + client.listResources(), + client.listResourceTemplates(), + ]); + + expect(tools).toHaveLength(10); + expect(prompts).toHaveLength(3); + expect(resources).toHaveLength(3); + expect(resourceTemplates).toHaveLength(1); + + for (const tool of tools) { + expect(tool.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + openWorldHint: false, + }); + } + }, + ); +}); diff --git a/packages/mcp/src/sentry.ts b/packages/mcp/src/sentry.ts index c0571eed..efca4d1b 100644 --- a/packages/mcp/src/sentry.ts +++ b/packages/mcp/src/sentry.ts @@ -1,4 +1,4 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from '@modelcontextprotocol/server'; import * as Sentry from '@sentry/node'; type Environment = NodeJS.ProcessEnv; diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index afb29fd4..03734548 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,14 +1,13 @@ /** * Terminal49 MCP Server - * Implementation using @modelcontextprotocol/sdk with McpServer API + * Implementation using the MCP TypeScript SDK v2 McpServer API */ - +import { serveStdio } from '@modelcontextprotocol/server/stdio'; import { + completable, McpServer, ResourceTemplate, -} from '@modelcontextprotocol/sdk/server/mcp.js'; -import { completable } from '@modelcontextprotocol/sdk/server/completable.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +} from '@modelcontextprotocol/server'; import { z } from 'zod'; import { Terminal49Client } from '@terminal49/sdk'; import { executeGetContainer } from './tools/get-container.js'; @@ -37,7 +36,6 @@ import { import { instrumentMcpServerWithPostHog, registerPostHogExitHook, - shutdownPostHog, } from './posthog.js'; import { captureMcpException, @@ -1215,7 +1213,7 @@ export function createTerminal49McpServer( destructiveHint: false, openWorldHint: false, }, - inputSchema: { + inputSchema: z.object({ query: z .string() .min(1) @@ -1223,8 +1221,8 @@ export function createTerminal49McpServer( 'Search query - can be a container number, booking number, BL number, or reference number', ), intent: toolIntentSchema, - }, - outputSchema: { + }), + outputSchema: z.object({ containers: z.array( z.object({ id: z.string(), @@ -1247,7 +1245,7 @@ export function createTerminal49McpServer( ), total_results: z.number(), _response_contract: responseContractSchema, - }, + }), }, wrapToolWithContract( async ({ query }) => executeSearchContainer({ query }, client), @@ -1270,7 +1268,7 @@ export function createTerminal49McpServer( idempotentHint: false, openWorldHint: false, }, - inputSchema: { + inputSchema: z.object({ number: z .string() .optional() @@ -1300,8 +1298,8 @@ export function createTerminal49McpServer( .optional() .describe('Optional reference numbers for matching'), intent: toolIntentSchema, - }, - outputSchema: { + }), + outputSchema: z.object({ error: z.string().optional(), message: z.string().optional(), id: z.string().optional(), @@ -1310,7 +1308,7 @@ export function createTerminal49McpServer( tracking_request_created: z.boolean().optional(), infer_result: z.any().optional(), _response_contract: responseContractSchema.optional(), - }, + }), }, wrapToolWithContract( async ({ @@ -1354,7 +1352,7 @@ export function createTerminal49McpServer( destructiveHint: false, openWorldHint: false, }, - inputSchema: { + inputSchema: z.object({ id: z .string() .uuid() @@ -1370,7 +1368,7 @@ export function createTerminal49McpServer( '• transport_events: Full event history, rail tracking (heavy 50-100 events, use for journey/timeline questions)', ), intent: toolIntentSchema, - }, + }), outputSchema: z .object({ _response_contract: responseContractSchema, @@ -1397,7 +1395,7 @@ export function createTerminal49McpServer( destructiveHint: false, openWorldHint: false, }, - inputSchema: { + inputSchema: z.object({ id: z .string() .uuid() @@ -1410,7 +1408,7 @@ export function createTerminal49McpServer( 'Include list of containers in this shipment. Default: true', ), intent: toolIntentSchema, - }, + }), outputSchema: z .object({ _response_contract: responseContractSchema, @@ -1439,13 +1437,13 @@ export function createTerminal49McpServer( destructiveHint: false, openWorldHint: false, }, - inputSchema: { + inputSchema: z.object({ id: z .string() .uuid() .describe('The Terminal49 container ID (UUID format)'), intent: toolIntentSchema, - }, + }), outputSchema: z .object({ _response_contract: responseContractSchema, @@ -1472,14 +1470,14 @@ export function createTerminal49McpServer( destructiveHint: false, openWorldHint: false, }, - inputSchema: { + inputSchema: z.object({ search: z .string() .optional() .describe('Optional: Filter by carrier name or SCAC code'), intent: toolIntentSchema, - }, - outputSchema: { + }), + outputSchema: z.object({ total_lines: z.number(), shipping_lines: z.array( z.object({ @@ -1496,7 +1494,7 @@ export function createTerminal49McpServer( remediation: z.string().optional(), }), _response_contract: responseContractSchema, - }, + }), }, wrapToolWithContract( async ({ search }) => @@ -1520,13 +1518,13 @@ export function createTerminal49McpServer( destructiveHint: false, openWorldHint: false, }, - inputSchema: { + inputSchema: z.object({ id: z .string() .uuid() .describe('The Terminal49 container ID (UUID format)'), intent: toolIntentSchema, - }, + }), // Keep a single permissive schema because this tool can return either // route fields or feature-gating fields depending on account capability. outputSchema: z.object({ @@ -1604,7 +1602,7 @@ export function createTerminal49McpServer( destructiveHint: false, openWorldHint: false, }, - inputSchema: { + inputSchema: z.object({ status: z.string().optional().describe('Filter by shipment status'), port: z.string().optional().describe('Filter by POD port LOCODE'), carrier: z.string().optional().describe('Filter by shipping line SCAC'), @@ -1621,7 +1619,7 @@ export function createTerminal49McpServer( page: listPageSchema, page_size: listPageSizeSchema, intent: toolIntentSchema, - }, + }), outputSchema: z.object({ items: z.array(z.record(z.string(), z.any())), links: z.record(z.string(), z.string()).optional(), @@ -1652,7 +1650,7 @@ export function createTerminal49McpServer( destructiveHint: false, openWorldHint: false, }, - inputSchema: { + inputSchema: z.object({ status: z.string().optional().describe('Filter by container status'), port: z.string().optional().describe('Filter by POD port LOCODE'), carrier: z.string().optional().describe('Filter by shipping line SCAC'), @@ -1669,7 +1667,7 @@ export function createTerminal49McpServer( page: listPageSchema, page_size: listPageSizeSchema, intent: toolIntentSchema, - }, + }), outputSchema: z.object({ items: z.array(z.record(z.string(), z.any())), links: z.record(z.string(), z.string()).optional(), @@ -1704,7 +1702,7 @@ export function createTerminal49McpServer( destructiveHint: false, openWorldHint: false, }, - inputSchema: { + inputSchema: z.object({ filters: z .record(z.string(), z.string()) .optional() @@ -1720,7 +1718,7 @@ export function createTerminal49McpServer( page: listPageSchema, page_size: listPageSizeSchema, intent: toolIntentSchema, - }, + }), outputSchema: z.object({ items: z.array(z.record(z.string(), z.any())), links: z.record(z.string(), z.string()).optional(), @@ -1753,7 +1751,7 @@ export function createTerminal49McpServer( title: 'Track Container Shipment', description: 'Quick container tracking workflow with carrier autocomplete', - argsSchema: { + argsSchema: z.object({ container_number: z .string() .describe('Container number (e.g., CAIU1234567)'), @@ -1767,7 +1765,7 @@ export function createTerminal49McpServer( .describe('Shipping line SCAC code (e.g., MAEU for Maersk)'), completeCarrierScac, ).optional(), - }, + }), }, async ({ container_number, carrier }) => ({ messages: [ @@ -1790,9 +1788,9 @@ export function createTerminal49McpServer( { title: 'Check Demurrage Risk', description: 'Analyze demurrage/detention risk for a container', - argsSchema: { + argsSchema: z.object({ container_id: z.string().uuid().describe('Terminal49 container UUID'), - }, + }), }, async ({ container_id }) => ({ messages: [ @@ -1818,9 +1816,9 @@ export function createTerminal49McpServer( { title: 'Analyze Journey Delays', description: 'Identify delays and root causes in container journey', - argsSchema: { + argsSchema: z.object({ container_id: z.string().uuid().describe('Terminal49 container UUID'), - }, + }), }, async ({ container_id }) => ({ messages: [ @@ -1941,23 +1939,15 @@ export async function runStdioServer() { process.exit(1); } - const server = createTerminal49McpServer(apiToken, apiBaseUrl); - const transport = new StdioServerTransport(); - // Long-lived process: drain queued analytics on natural exit. No-ops (and // registers no listener at all) when PostHog is unconfigured. registerPostHogExitHook(); - // The client closing stdin ends the session; flush before we lose the events. - transport.onclose = () => { - void shutdownPostHog(); - }; - if (process.env.T49_MCP_STDIO_BANNER === '1') { console.error('Terminal49 MCP Server v1.0.0 running on stdio'); console.error('Available: 10 tools | 3 prompts | 4 resources'); - console.error('SDK: @modelcontextprotocol/sdk (McpServer API)'); + console.error('SDK: @modelcontextprotocol/server v2 (McpServer API)'); } - await server.connect(transport); + serveStdio(() => createTerminal49McpServer(apiToken, apiBaseUrl)); } diff --git a/packages/mcp/tests/api-handler.test.ts b/packages/mcp/tests/api-handler.test.ts index 8142d1a4..3f83cfc7 100644 --- a/packages/mcp/tests/api-handler.test.ts +++ b/packages/mcp/tests/api-handler.test.ts @@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; const mockState = vi.hoisted(() => ({ servers: [] as any[], - transports: [] as any[], + handlers: [] as any[], + handlerOptions: [] as any[], serverCreateArgs: [] as Array<{ apiToken: string; apiBaseUrl: string | undefined; @@ -18,7 +19,6 @@ vi.mock('../src/server.js', () => ({ createTerminal49McpServer: vi.fn( (apiToken: string, apiBaseUrl?: string, accountId?: string) => { const server = { - connect: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), }; @@ -29,24 +29,28 @@ vi.mock('../src/server.js', () => ({ ), })); -vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({ - StreamableHTTPServerTransport: class MockStreamableHTTPServerTransport { - handleRequest: ReturnType; - close: ReturnType; - - constructor() { - this.handleRequest = vi.fn( - async (req: unknown, res: unknown, body: unknown) => { - if (mockState.handleRequestImpl) { - await mockState.handleRequestImpl(req, res, body); - } - }, - ); - this.close = vi.fn().mockResolvedValue(undefined); - - mockState.transports.push(this); - } - }, +vi.mock('@modelcontextprotocol/server', () => ({ + createMcpHandler: vi.fn((factory: () => unknown, options: unknown) => { + const handler = { + close: vi.fn().mockResolvedValue(undefined), + factory, + }; + mockState.handlers.push(handler); + mockState.handlerOptions.push(options); + return handler; + }), +})); + +vi.mock('@modelcontextprotocol/node', () => ({ + toNodeHandler: vi.fn( + (handler: { factory: () => unknown }) => + async (req: unknown, res: unknown, body: unknown) => { + handler.factory(); + if (mockState.handleRequestImpl) { + await mockState.handleRequestImpl(req, res, body); + } + }, + ), })); class MockResponse extends EventEmitter { @@ -100,7 +104,8 @@ describe('api/mcp handler lifecycle', () => { vi.clearAllMocks(); vi.unstubAllGlobals(); mockState.servers.length = 0; - mockState.transports.length = 0; + mockState.handlers.length = 0; + mockState.handlerOptions.length = 0; mockState.serverCreateArgs.length = 0; mockState.handleRequestImpl = undefined; delete process.env.T49_API_TOKEN; @@ -117,7 +122,7 @@ describe('api/mcp handler lifecycle', () => { delete process.env.T49_MCP_ALLOWED_ORIGINS; }); - it('closes transport and server after successful request handling', async () => { + it('serves both protocol eras and closes the handler after a request', async () => { const { default: handler } = await import('../../../api/mcp.ts'); const req = createRequest(); const res = new MockResponse(); @@ -125,16 +130,14 @@ describe('api/mcp handler lifecycle', () => { await handler(req as any, res as any); expect(mockState.servers).toHaveLength(1); - expect(mockState.transports).toHaveLength(1); - - const server = mockState.servers[0]; - const transport = mockState.transports[0]; + expect(mockState.handlers).toHaveLength(1); expect(mockState.serverCreateArgs[0]?.apiToken).toBe('test-token'); - expect(server.connect).toHaveBeenCalledWith(transport); - expect(transport.handleRequest).toHaveBeenCalledWith(req, res, req.body); - expect(transport.close).toHaveBeenCalledTimes(1); - expect(server.close).toHaveBeenCalledTimes(1); + expect(mockState.handlerOptions[0]).toMatchObject({ + legacy: 'stateless', + responseMode: 'json', + }); + expect(mockState.handlers[0].close).toHaveBeenCalledTimes(1); }); it('returns 500 and still closes transport and server when handler throws', async () => { @@ -157,10 +160,7 @@ describe('api/mcp handler lifecycle', () => { // DEV-10663: the internal error message must never leak to clients. expect((res.payload as any)?.error?.data).toBeUndefined(); - const server = mockState.servers[0]; - const transport = mockState.transports[0]; - expect(transport.close).toHaveBeenCalledTimes(1); - expect(server.close).toHaveBeenCalledTimes(1); + expect(mockState.handlers[0].close).toHaveBeenCalledTimes(1); }); it('runs cleanup only once when response closes before finally executes', async () => { @@ -174,10 +174,7 @@ describe('api/mcp handler lifecycle', () => { await handler(req as any, res as any); - const server = mockState.servers[0]; - const transport = mockState.transports[0]; - expect(transport.close).toHaveBeenCalledTimes(1); - expect(server.close).toHaveBeenCalledTimes(1); + expect(mockState.handlers[0].close).toHaveBeenCalledTimes(1); }); it('accepts Authorization header using Token scheme', async () => { @@ -229,7 +226,7 @@ describe('api/mcp handler lifecycle', () => { error: 'Unauthorized', }); expect(mockState.servers).toHaveLength(0); - expect(mockState.transports).toHaveLength(0); + expect(mockState.handlers).toHaveLength(0); }); it('uses T49_API_TOKEN as upstream credential when Authorization is present', async () => { @@ -447,7 +444,7 @@ describe('api/mcp handler lifecycle', () => { message: 'Invalid client credentials.', }); expect(mockState.servers).toHaveLength(0); - expect(mockState.transports).toHaveLength(0); + expect(mockState.handlers).toHaveLength(0); }); it('returns 500 when T49_API_TOKEN is set without T49_MCP_CLIENT_SECRET', async () => { @@ -469,7 +466,7 @@ describe('api/mcp handler lifecycle', () => { error: 'Server misconfiguration', }); expect(mockState.servers).toHaveLength(0); - expect(mockState.transports).toHaveLength(0); + expect(mockState.handlers).toHaveLength(0); }); it('returns 401 when neither Authorization header nor T49_API_TOKEN is set', async () => { @@ -488,6 +485,6 @@ describe('api/mcp handler lifecycle', () => { error: 'Unauthorized', }); expect(mockState.servers).toHaveLength(0); - expect(mockState.transports).toHaveLength(0); + expect(mockState.handlers).toHaveLength(0); }); }); diff --git a/vercel.json b/vercel.json index 1a5e158c..fde83b03 100644 --- a/vercel.json +++ b/vercel.json @@ -53,7 +53,7 @@ }, { "key": "Access-Control-Allow-Headers", - "value": "Content-Type, Authorization, MCP-Protocol-Version, Mcp-Session-Id" + "value": "Content-Type, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id" } ] }, @@ -70,7 +70,7 @@ }, { "key": "Access-Control-Allow-Headers", - "value": "Content-Type, Authorization, MCP-Protocol-Version, Mcp-Session-Id" + "value": "Content-Type, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name, Mcp-Session-Id" } ] }