Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<table>
Expand Down
48 changes: 48 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/artifact-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ interface ArtifactDestination {
}

export function registerArtifactTools(
server: McpServer,
server: Pick<McpServer, "registerTool">,
{
config,
workspaces,
Expand Down
179 changes: 179 additions & 0 deletions src/mcp-modern-server.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>);
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<string, unknown>): 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<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
76 changes: 76 additions & 0 deletions src/mcp-modern-server.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
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<string, unknown>,
handler: (input: unknown, extra: Record<string, unknown>) => 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<string, unknown> {
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,
};
}
Loading