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