From 7751e0b964e3c26b8b2db60225d93d4751e21e63 Mon Sep 17 00:00:00 2001 From: signal-forge-lab <61216086+signal-forge-lab@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:58:01 +0900 Subject: [PATCH] feat(mcp): support 2026-07-28 protocol --- README.md | 2 + package-lock.json | 48 ++++++++ package.json | 4 +- src/artifact-tools.ts | 2 +- src/mcp-modern-server.test.ts | 179 +++++++++++++++++++++++++++ src/mcp-modern-server.ts | 76 ++++++++++++ src/server.test.ts | 226 +++++++++++++++++++++++++++++++++- src/server.ts | 83 +++++++++++-- 8 files changed, 607 insertions(+), 13 deletions(-) create mode 100644 src/mcp-modern-server.test.ts create mode 100644 src/mcp-modern-server.ts diff --git a/README.md b/README.md index 26853cfa2..443d0dacf 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ DevSpace is a self-hosted MCP server that lets ChatGPT read, edit, search, and run code in your real local projects — your files, your tools, your terminal — without uploading anything to a third party. You run it on your machine, expose it through a tunnel you control, and approve the connection with a password only you have. +The same `/mcp` endpoint supports existing sessionful MCP clients and the 2026-07-28 per-request protocol. + ## Sponsors and Special Thanks diff --git a/package-lock.json b/package-lock.json index 79993030d..35bdd4884 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "@clack/prompts": "^1.5.1", "@earendil-works/pi-coding-agent": "^0.80.3", "@modelcontextprotocol/ext-apps": "^1.7.2", + "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "^2.0.0", "@openai/codex-sdk": "^0.142.5", "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", @@ -2555,6 +2557,18 @@ "hono": "^4" } }, + "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/ext-apps": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.2.tgz", @@ -2584,6 +2598,27 @@ } } }, + "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", @@ -2624,6 +2659,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/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", diff --git a/package.json b/package.json index 129839120..03b6c6582 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/mcp-modern-server.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], @@ -40,7 +40,9 @@ "@clack/prompts": "^1.5.1", "@earendil-works/pi-coding-agent": "^0.80.3", "@modelcontextprotocol/ext-apps": "^1.7.2", + "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "^2.0.0", "@openai/codex-sdk": "^0.142.5", "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", diff --git a/src/artifact-tools.ts b/src/artifact-tools.ts index fe9a195af..e7fc35dfa 100644 --- a/src/artifact-tools.ts +++ b/src/artifact-tools.ts @@ -83,7 +83,7 @@ interface ArtifactDestination { } export function registerArtifactTools( - server: McpServer, + server: Pick, { config, workspaces, diff --git a/src/mcp-modern-server.test.ts b/src/mcp-modern-server.test.ts new file mode 100644 index 000000000..b1b7a534b --- /dev/null +++ b/src/mcp-modern-server.test.ts @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { registerAppResource, registerAppTool } from "@modelcontextprotocol/ext-apps/server"; +import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; +import * as z from "zod/v4"; +import { createModernMcpServerAdapter } from "./mcp-modern-server.js"; + +test("strict modern handler answers the 2026-07-28 discovery probe", async (t) => { + const handler = createMcpHandler(() => new McpServer( + { name: "devspace-modern-test", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ), { legacy: "reject" }); + t.after(async () => handler.close()); + + const response = await handler.fetch(modernRequest("server/discover", {})); + + assert.equal(response.status, 200); + const body = await response.json() as { + result?: { supportedVersions?: string[] }; + }; + assert.ok(body.result?.supportedVersions?.includes("2026-07-28")); +}); + +test("modern registration adapter preserves tools and request metadata", async (t) => { + const handler = createMcpHandler(() => { + const adapter = createModernMcpServerAdapter({ + name: "devspace-modern-test", + version: "1.0.0", + }); + registerAppTool( + adapter.registrationTarget, + "echo_scope", + { + description: "Echo the modern request scope.", + inputSchema: { value: z.string() }, + _meta: {}, + }, + async ({ value }, { _meta }) => ({ + content: [{ + type: "text", + text: `${value}:${String(_meta?.["openai/session"] ?? "missing")}`, + }], + }), + ); + return adapter.server; + }, { legacy: "reject" }); + t.after(async () => handler.close()); + + const listed = await handler.fetch(modernRequest("tools/list", {})); + assert.equal(listed.status, 200); + const listBody = await listed.json() as { + result?: { tools?: Array<{ name?: string }> }; + }; + assert.ok(listBody.result?.tools?.some((tool) => tool.name === "echo_scope")); + + const called = await handler.fetch(modernRequest("tools/call", { + name: "echo_scope", + arguments: { value: "ok" }, + _meta: { "openai/session": "modern-chat" }, + })); + assert.equal(called.status, 200, await called.clone().text()); + const callBody = await called.json() as { + result?: { content?: Array<{ text?: string }> }; + }; + assert.equal(callBody.result?.content?.[0]?.text, "ok:modern-chat"); +}); + +test("modern registration adapter preserves progress notifications", async (t) => { + const handler = createMcpHandler(() => { + const adapter = createModernMcpServerAdapter({ + name: "devspace-modern-test", + version: "1.0.0", + }); + registerAppTool( + adapter.registrationTarget, + "progress_echo", + { + inputSchema: {}, + _meta: {}, + }, + async (_input, { sendNotification }) => { + await sendNotification({ + method: "notifications/progress", + params: { + progressToken: "modern-progress", + progress: 1, + total: 1, + }, + }); + return { content: [{ type: "text", text: "done" }] }; + }, + ); + return adapter.server; + }, { legacy: "reject" }); + t.after(async () => handler.close()); + + const response = await handler.fetch(modernRequest("tools/call", { + name: "progress_echo", + arguments: {}, + _meta: { progressToken: "modern-progress" }, + })); + + assert.equal(response.status, 200, await response.clone().text()); + assert.match(response.headers.get("content-type") ?? "", /text\/event-stream/); + const messages = (await response.text()) + .split(/\r?\n/) + .filter((line) => line.startsWith("data: ")) + .map((line) => JSON.parse(line.slice(6)) as Record); + assert.ok(messages.some((message) => message.method === "notifications/progress")); + assert.match(JSON.stringify(messages.at(-1)), /done/); +}); + +test("modern registration adapter preserves resources", async (t) => { + const handler = createMcpHandler(() => { + const adapter = createModernMcpServerAdapter({ + name: "devspace-modern-test", + version: "1.0.0", + }); + registerAppResource( + adapter.registrationTarget, + "Test resource", + "ui://devspace/test.html", + {}, + async (_uri, { _meta }) => ({ + contents: [{ + uri: "ui://devspace/test.html", + mimeType: "text/html", + text: `resource-ok:${String(_meta?.["openai/session"] ?? "missing")}`, + }], + }), + ); + return adapter.server; + }, { legacy: "reject" }); + t.after(async () => handler.close()); + + const response = await handler.fetch(modernRequest("resources/read", { + uri: "ui://devspace/test.html", + _meta: { "openai/session": "resource-chat" }, + })); + + assert.equal(response.status, 200, await response.clone().text()); + assert.match(await response.text(), /resource-ok:resource-chat/); +}); + +function modernRequest(method: string, params: Record): Request { + const mcpName = typeof params.name === "string" + ? params.name + : typeof params.uri === "string" + ? params.uri + : undefined; + return new Request("https://example.test/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + "mcp-method": method, + "mcp-protocol-version": "2026-07-28", + ...(mcpName ? { "mcp-name": mcpName } : {}), + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: `modern-${method}`, + method, + params: { + ...params, + _meta: { + ...objectValue(params._meta), + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }); +} + +function objectValue(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} diff --git a/src/mcp-modern-server.ts b/src/mcp-modern-server.ts new file mode 100644 index 000000000..d2c4e2d09 --- /dev/null +++ b/src/mcp-modern-server.ts @@ -0,0 +1,76 @@ +import { + McpServer, + type ServerContext, + type ServerOptions, +} from "@modelcontextprotocol/server"; +import type { McpServer as LegacyMcpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Implementation as LegacyImplementation } from "@modelcontextprotocol/sdk/types.js"; + +export type McpRegistrationTarget = Pick< + LegacyMcpServer, + "registerTool" | "registerResource" +>; + +export interface ModernMcpServerAdapter { + server: McpServer; + registrationTarget: McpRegistrationTarget; +} + +type ModernRegisterTool = ( + name: string, + definition: Record, + handler: (input: unknown, context: ServerContext) => unknown, +) => unknown; + +type ModernRegisterResource = (...args: unknown[]) => unknown; + +export function createModernMcpServerAdapter( + serverInfo: LegacyImplementation, + options?: ServerOptions, +): ModernMcpServerAdapter { + const server = new McpServer(serverInfo, options); + const registerModernTool = server.registerTool.bind(server) as unknown as ModernRegisterTool; + const registerModernResource = server.registerResource.bind(server) as unknown as ModernRegisterResource; + const registrationTarget: McpRegistrationTarget = { + registerTool: (( + name: string, + definition: Record, + handler: (input: unknown, extra: Record) => unknown, + ) => registerModernTool( + name, + definition, + async (input, context) => handler(input, legacyToolHandlerExtra(context)), + )) as LegacyMcpServer["registerTool"], + registerResource: ((...args: unknown[]) => { + const callback = args.at(-1) as (...callbackArgs: unknown[]) => unknown; + return registerModernResource( + ...args.slice(0, -1), + (...callbackArgs: unknown[]) => { + const context = callbackArgs.at(-1) as ServerContext; + return callback( + ...callbackArgs.slice(0, -1), + legacyToolHandlerExtra(context), + ); + }, + ); + }) as unknown as LegacyMcpServer["registerResource"], + }; + + return { + server, + registrationTarget, + }; +} + +function legacyToolHandlerExtra(context: ServerContext): Record { + return { + signal: context.mcpReq.signal, + authInfo: context.http?.authInfo, + sessionId: context.sessionId, + _meta: context.mcpReq._meta, + requestId: context.mcpReq.id, + requestInfo: context.http?.req, + sendNotification: context.mcpReq.notify, + sendRequest: context.mcpReq.send, + }; +} diff --git a/src/server.test.ts b/src/server.test.ts index 73eaf03b9..f5ee9b26d 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,7 +11,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { loadConfig, type ServerConfig } from "./config.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { ProcessSessionManager } from "./process-sessions.js"; -import { createMcpServer } from "./server.js"; +import { createMcpServer, createServer } from "./server.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; @@ -167,6 +168,110 @@ test("checkout reuse and context suppression survive a registry restart", async } }); +test("HTTP endpoint serves modern MCP while preserving legacy sessions", async (t) => { + const root = await mkdtemp(join(tmpdir(), "devspace-modern-http-test-")); + const ownerToken = "test-owner-token-that-is-long-enough"; + const config = loadConfig({ + DEVSPACE_CONFIG_DIR: join(root, ".config"), + DEVSPACE_ALLOWED_ROOTS: root, + DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), + DEVSPACE_STATE_DIR: join(root, ".state"), + DEVSPACE_OAUTH_OWNER_TOKEN: ownerToken, + DEVSPACE_PUBLIC_BASE_URL: "https://example.test", + PORT: "1", + }); + const running = createServer(config, { incomingArtifactAdapters: [] }); + const httpServer = running.app.listen(0, "127.0.0.1"); + await new Promise((resolve) => httpServer.once("listening", resolve)); + + t.after(async () => { + await new Promise((resolve, reject) => { + httpServer.close((error) => error ? reject(error) : resolve()); + }); + await running.close(); + await rm(root, { recursive: true, force: true }); + }); + + const address = httpServer.address(); + assert.ok(address && typeof address === "object"); + const localBaseUrl = `http://127.0.0.1:${address.port}`; + const accessToken = await issueTestAccessToken( + localBaseUrl, + config.publicBaseUrl, + ownerToken, + ); + + const unauthenticated = await postModernMcp( + localBaseUrl, + undefined, + "tools/list", + {}, + ); + assert.equal(unauthenticated.status, 401, await unauthenticated.clone().text()); + + const discovery = await postAuthenticatedModernMcp( + localBaseUrl, + accessToken, + "server/discover", + {}, + ); + assert.equal(discovery.status, 200, await discovery.clone().text()); + const discoveryBody = await discovery.json() as { + result?: { supportedVersions?: string[] }; + }; + assert.ok(discoveryBody.result?.supportedVersions?.includes("2026-07-28")); + + const listed = await postAuthenticatedModernMcp( + localBaseUrl, + accessToken, + "tools/list", + {}, + ); + assert.equal(listed.status, 200, await listed.clone().text()); + const listBody = await listed.json() as { + result?: { tools?: Array<{ name?: string }> }; + }; + assert.ok(listBody.result?.tools?.some((tool) => tool.name === "open_workspace")); + + const called = await postAuthenticatedModernMcp( + localBaseUrl, + accessToken, + "tools/call", + { + name: "open_workspace", + arguments: { path: root }, + _meta: { "openai/session": "modern-http-test" }, + }, + ); + assert.equal(called.status, 200, await called.clone().text()); + const callBody = await called.json() as { + result?: { structuredContent?: { workspaceId?: string } }; + }; + assert.equal(typeof callBody.result?.structuredContent?.workspaceId, "string"); + + const legacy = await fetch(`${localBaseUrl}/mcp`, { + method: "POST", + headers: { + authorization: `Bearer ${accessToken}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "legacy-initialize", + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "devspace-legacy-test", version: "1.0.0" }, + }, + }), + }); + assert.equal(legacy.status, 200, await legacy.clone().text()); + assert.ok(legacy.headers.get("mcp-session-id")); + assert.match(await legacy.text(), /"protocolVersion"/); +}); + interface ServerFixture { client: Client; project: string; @@ -251,6 +356,125 @@ async function git(cwd: string, args: string[]): Promise { await execFileAsync("git", args, { cwd }); } +async function issueTestAccessToken( + localBaseUrl: string, + publicBaseUrl: string, + ownerToken: string, +): Promise { + const redirectUri = "http://127.0.0.1/callback"; + const resource = new URL("/mcp", publicBaseUrl).href; + const verifier = "devspace-modern-protocol-test-verifier-0123456789"; + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const registration = await fetch(`${localBaseUrl}/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "DevSpace modern protocol test", + redirect_uris: [redirectUri], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + }); + assert.equal(registration.status, 201, await registration.clone().text()); + const client = await registration.json() as { client_id?: string }; + assert.ok(client.client_id); + + const approval = await fetch(`${localBaseUrl}/authorize`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: client.client_id, + redirect_uri: redirectUri, + response_type: "code", + code_challenge: challenge, + code_challenge_method: "S256", + scope: "devspace", + resource, + state: "modern-test", + owner_token: ownerToken, + }), + redirect: "manual", + }); + assert.equal(approval.status, 302, await approval.clone().text()); + const location = approval.headers.get("location"); + assert.ok(location); + const code = new URL(location).searchParams.get("code"); + assert.ok(code); + + const exchange = await fetch(`${localBaseUrl}/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: client.client_id, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + resource, + }), + }); + assert.equal(exchange.status, 200, await exchange.clone().text()); + const tokens = await exchange.json() as { access_token?: string }; + assert.ok(tokens.access_token); + return tokens.access_token; +} + +function postAuthenticatedModernMcp( + localBaseUrl: string, + accessToken: string, + method: string, + params: Record, +): Promise { + return postModernMcp(localBaseUrl, accessToken, method, params); +} + +function postModernMcp( + localBaseUrl: string, + accessToken: string | undefined, + method: string, + params: Record, +): Promise { + const mcpName = typeof params.name === "string" + ? params.name + : typeof params.uri === "string" + ? params.uri + : undefined; + return fetch(`${localBaseUrl}/mcp`, { + method: "POST", + headers: { + ...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}), + "content-type": "application/json", + "mcp-method": method, + "mcp-protocol-version": "2026-07-28", + ...(mcpName ? { "mcp-name": mcpName } : {}), + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: `modern-${method}`, + method, + params: { + ...params, + _meta: { + ...recordValue(params._meta), + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + name: "devspace-modern-http-test", + version: "1.0.0", + }, + }, + }, + }), + }); +} + +function recordValue(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + async function callOpen( client: Client, path: string, diff --git a/src/server.ts b/src/server.ts index 840594ab7..00f4918e1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,8 @@ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middlew import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; +import { createMcpHandler, isLegacyRequest } from "@modelcontextprotocol/server"; +import { toNodeHandler, toWebRequest } from "@modelcontextprotocol/node"; import { registerAppResource, registerAppTool, @@ -44,6 +46,10 @@ import { writeFileTool, } from "./pi-tools.js"; import { SingleUserOAuthProvider } from "./oauth-provider.js"; +import { + createModernMcpServerAdapter, + type McpRegistrationTarget, +} from "./mcp-modern-server.js"; import { McpSessionRegistry, type McpSessionCloseResult, @@ -88,6 +94,16 @@ const SHELL_TOOL_ANNOTATIONS = { openWorldHint: true, }; +function mcpServerInfo() { + return { + name: "devspace", + title: "DevSpace", + version: "0.1.0", + description: + "Coding tools for project workspaces. Open each project or worktree once, then reuse its workspaceId.", + }; +} + interface RunningServer { app: ReturnType; config: ServerConfig; @@ -554,7 +570,7 @@ function processToolResponse( } function registerCodexProcessTools( - server: McpServer, + server: Pick, config: ServerConfig, workspaces: WorkspaceRegistry, processSessions: ProcessSessionManager, @@ -706,18 +722,33 @@ export function createMcpServer( incomingArtifactAdapters: readonly IncomingArtifactAdapter[], ): McpServer { const server = new McpServer( - { - name: "devspace", - title: "DevSpace", - version: "0.1.0", - description: - "Coding tools for project workspaces. Open each project or worktree once, then reuse its workspaceId.", - }, + mcpServerInfo(), { instructions: serverInstructions(config), }, ); + registerMcpSurface( + server, + config, + workspaces, + reviewCheckpoints, + processSessions, + localAgentProviders, + incomingArtifactAdapters, + ); + return server; +} + +function registerMcpSurface( + server: McpRegistrationTarget, + config: ServerConfig, + workspaces: WorkspaceRegistry, + reviewCheckpoints: ReturnType, + processSessions: ProcessSessionManager, + localAgentProviders: LocalAgentProviderAvailability[], + incomingArtifactAdapters: readonly IncomingArtifactAdapter[], +): void { registerAppResource( server, "DevSpace Diff Card", @@ -1657,8 +1688,6 @@ export function createMcpServer( incomingArtifactAdapters, }); } - - return server; } export interface CreateServerOptions { @@ -1694,6 +1723,27 @@ export function createServer( const localAgentProviders = config.subagents ? getLocalAgentProviderAvailabilitySnapshot() : []; + const modernMcpHandler = createMcpHandler(() => { + const adapter = createModernMcpServerAdapter( + mcpServerInfo(), + { instructions: serverInstructions(config) }, + ); + registerMcpSurface( + adapter.registrationTarget, + config, + workspaces, + reviewCheckpoints, + processSessions, + localAgentProviders, + incomingArtifactAdapters, + ); + return adapter.server; + }, { legacy: "reject" }); + const modernNodeHandler = toNodeHandler(modernMcpHandler, { + onerror: (error) => logEvent(config.logging, "error", "mcp_modern_adapter_error", { + error: error.message, + }), + }); const logSessionCloseResults = ( reason: "idle_timeout" | "server_shutdown", @@ -1817,6 +1867,12 @@ export function createServer( }); try { + const webRequest = await toWebRequest(req, req.body); + if (!await isLegacyRequest(webRequest, req.body)) { + await modernNodeHandler(req, res, req.body); + return; + } + let transport: Transport | undefined; if (sessionId) { @@ -1882,6 +1938,13 @@ export function createServer( close: () => { closePromise ??= (async () => { clearInterval(sessionCleanupTimer); + try { + await modernMcpHandler.close(); + } catch (error) { + logEvent(config.logging, "warn", "mcp_modern_handler_close_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } const results = await transports.closeAll(); logSessionCloseResults("server_shutdown", results); processSessions.shutdown();