Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/host-selfhost/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"devDependencies": {
"@effect/vitest": "catalog:",
"@executor-js/vite-plugin": "workspace:*",
"@modelcontextprotocol/client": "2.0.0",
"@tailwindcss/vite": "catalog:",
"@tanstack/router-plugin": "^1.167.12",
"@tanstack/virtual-file-routes": "^1.162.0",
Expand Down
7 changes: 6 additions & 1 deletion apps/host-selfhost/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => {
// plane's decorator is wired in mcp/session-store.ts's stack layer).
decorator: SelfHostAnalyticsEngineDecorator,
},
mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter },
mcp: {
auth: mcp.auth,
sessions: mcp.sessions,
modern: mcp.modern,
reporter: mcp.reporter,
},
plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig },
errorCapture: ErrorCaptureLive,
},
Expand Down
12 changes: 10 additions & 2 deletions apps/host-selfhost/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { IdentityProvider } from "@executor-js/api/server";
import type {
McpAuthProvider,
McpErrorReporter,
McpModernServerBuilder,
McpSessionStore,
Principal,
} from "@executor-js/host-mcp";
Expand All @@ -13,13 +14,15 @@ import type { SelfHostDbHandle } from "../db/self-host-db";
import { selfHostMcpAuth } from "./auth";
import {
makeSelfHostMcpSessionStore,
makeSelfHostMcpModernServerBuilder,
selfHostMcpReporter,
selfHostMcpSessions,
} from "./session-store";

export { selfHostMcpAuth } from "./auth";
export {
makeSelfHostMcpSessionStore,
makeSelfHostMcpModernServerBuilder,
selfHostMcpReporter,
selfHostMcpSessions,
McpEngineBuildError,
Expand All @@ -34,13 +37,15 @@ export {
// own auth + session handling and is mounted OUTSIDE the API's execution
// middleware, like /api/auth.
//
// Self-host provides the TWO envelope seams plus an error-reporter override:
// Self-host provides both era seams plus auth and an error-reporter override:
// - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still
// requires `IdentityProvider`, which `make` provides from
// the resolved identity seam.
// - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns
// dispatch (create + forward + ownership) and builds its
// engine internally over the shared SelfHostDb.
// - McpModernServerBuilder -> one stateless SDK v2 server per request over
// the same scoped execution stack and tool config.
// - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the
// host's console capture.
//
Expand All @@ -53,6 +58,8 @@ export interface SelfHostMcpSeams {
readonly auth: Layer.Layer<McpAuthProvider, never, IdentityProvider>;
/** The in-process session store seam (dispatch + lifetime). */
readonly sessions: Layer.Layer<McpSessionStore>;
/** Stateless SDK v2 server construction for modern requests. */
readonly modern: Layer.Layer<McpModernServerBuilder>;
/** Route 500 defects through the host's console `ErrorCapture`. */
readonly reporter: Layer.Layer<McpErrorReporter>;
/**
Expand Down Expand Up @@ -126,7 +133,7 @@ const makeApprovalHandler =
* Build the self-host MCP serving seams over the long-lived DB handle. The auth
* seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth
* instance provided; it still requires `IdentityProvider` from the resolved
* identity seam. Returns the three seam Layers plus the `close()` lifetime hook
* identity seam. Returns the four seam Layers plus the `close()` lifetime hook
* the app wires into shutdown.
*/
export const makeSelfHostMcpSeams = (
Expand All @@ -141,6 +148,7 @@ export const makeSelfHostMcpSeams = (
return {
auth,
sessions: selfHostMcpSessions(sessionStore),
modern: makeSelfHostMcpModernServerBuilder(dbHandle),
reporter: selfHostMcpReporter,
approvalHandler: makeApprovalHandler(sessionStore, betterAuth),
close: sessionStore.close,
Expand Down
35 changes: 35 additions & 0 deletions apps/host-selfhost/src/mcp/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterAll, expect, test } from "@effect/vitest";
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";

import { mintInviteCode } from "../testing/mint-invite";

Expand Down Expand Up @@ -87,6 +88,40 @@ test("an authenticated MCP client initializes, lists tools, and executes code",
expect(JSON.stringify(await call.json())).toContain("42");
});

test("an authenticated modern MCP client discovers, lists tools, and executes code", async () => {
const token = await signUp("[email protected]");
const seenMethods: string[] = [];
const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`), {
fetch: async (input, init) => {
const request =
input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
const body = (await request.clone().json()) as { readonly method?: string };
if (body.method) seenMethods.push(body.method);
const headers = new Headers(request.headers);
headers.set("authorization", `Bearer ${token}`);
return handler(new Request(request, { headers }));
},
});
const client = new Client(
{ name: "selfhost-modern-test", version: "1.0.0" },
{ capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } },
);

await client.connect(transport);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the authenticated modern client
try {
expect(seenMethods).toContain("server/discover");
expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
const result = await client.callTool({
name: "execute",
arguments: { code: "export default 6 * 7" },
});
expect(JSON.stringify(result)).toContain("42");
} finally {
await client.close();
}
});

test("an MCP session cannot be reused by another user, and unauth is rejected", async () => {
const alice = await signUp("[email protected]");
const bob = await signUp("[email protected]");
Expand Down
24 changes: 22 additions & 2 deletions apps/host-selfhost/src/mcp/session-store.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Layer } from "effect";

import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server";
import type { McpErrorReporter } from "@executor-js/host-mcp";
import {
makeConsoleMcpErrorReporter,
makeMcpBuildServer,
makeMcpBuildServerV2,
} from "@executor-js/api/server";
import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp";
import {
inMemoryMcpSessionsLayer,
makeInMemoryMcpSessionStore,
Expand Down Expand Up @@ -51,6 +55,22 @@ export const makeSelfHostMcpSessionStore = (
{ webBaseUrl },
);

/** Build the stateless SDK v2 server seam over the same self-host stack/config. */
export const makeSelfHostMcpModernServerBuilder = (
db: SelfHostDbHandle,
): Layer.Layer<McpModernServerBuilder> =>
Layer.succeed(McpModernServerBuilder)({
build: makeMcpBuildServerV2(
SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))),
{
loadAppShellHtml: loadMcpAppsShellHtml,
smokeRenderArtifact,
onArtifactUsage: (action) =>
selfHostAnalytics.record(`artifact_${action}`, { via: "agent" }),
},
),
});

/** The `McpSessionStore` envelope seam over a freshly built in-process store. */
export const selfHostMcpSessions = inMemoryMcpSessionsLayer;

Expand Down
2 changes: 2 additions & 0 deletions apps/host-selfhost/src/testing/test-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import executorConfig from "../../executor.config";
import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config";
import {
makeSelfHostMcpModernServerBuilder,
makeSelfHostMcpSessionStore,
selfHostMcpReporter,
selfHostMcpSessions,
Expand Down Expand Up @@ -236,6 +237,7 @@ export const makeSelfHostTestApp = async (
mcp: {
auth: stubMcpAuth,
sessions: selfHostMcpSessions(sessionStore),
modern: makeSelfHostMcpModernServerBuilder(dbHandle),
reporter: selfHostMcpReporter,
},
plugins: { provider: pluginsProvider, config: SelfHostHostConfig },
Expand Down
2 changes: 2 additions & 0 deletions apps/local/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@executor-js/vite-plugin": "workspace:*",
"@libsql/client": "catalog:",
"@modelcontextprotocol/sdk": "^1.29.0",
"@modelcontextprotocol/server": "2.0.0",
"@tanstack/react-router": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
Expand All @@ -55,6 +56,7 @@
"react-dom": "catalog:"
},
"devDependencies": {
"@modelcontextprotocol/client": "2.0.0",
"@rhyssul/portless": "^0.13.0",
"@tailwindcss/vite": "catalog:",
"@tanstack/router-plugin": "^1.167.12",
Expand Down
54 changes: 54 additions & 0 deletions apps/local/src/mcp-modern.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "@effect/vitest";
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { Effect } from "effect";

import type { ExecutionEngine } from "@executor-js/execution";

import { createMcpRequestHandler } from "./mcp";

const engine: ExecutionEngine = {
execute: (code) => Effect.succeed({ result: `ran: ${code}` }),
executeWithPause: (code) =>
Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }),
resume: () => Effect.succeed(null),
isExecutionSettled: () => Effect.succeed(false),
getPausedExecution: () => Effect.succeed(null),
pausedExecutionCount: () => Effect.succeed(0),
hasPausedExecutions: () => Effect.succeed(false),
getDescription: Effect.succeed("local modern MCP test executor"),
};

describe("local modern MCP HTTP", () => {
it("discovers, lists tools, and executes without creating a legacy session", async () => {
const mcp = createMcpRequestHandler({ engine });
const sessionHeaders: Array<string | null> = [];
const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), {
fetch: async (input, init) => {
const request =
input instanceof Request ? new Request(input, init) : new Request(input.toString(), init);
const response = await mcp.handleRequest(request);
sessionHeaders.push(response.headers.get("mcp-session-id"));
return response;
},
});
const client = new Client(
{ name: "local-modern-test", version: "1.0.0" },
{ capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } },
);

await client.connect(transport);
// oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the client and local handler
try {
expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute");
const result = await client.callTool({
name: "execute",
arguments: { code: "2 + 2" },
});
expect(result.content).toEqual([{ type: "text", text: "ran: 2 + 2" }]);
expect(sessionHeaders.every((sessionId) => sessionId === null)).toBe(true);
} finally {
await client.close();
await mcp.close();
}
});
});
70 changes: 69 additions & 1 deletion apps/local/src/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { Effect, type Cause } from "effect";
import {
createMcpHandler,
isLegacyRequest,
type McpHttpHandler,
} from "@modelcontextprotocol/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
Expand All @@ -13,6 +18,12 @@ import {
createExecutorMcpServer,
type ExecutorMcpServerConfig,
} from "@executor-js/host-mcp/tool-server";
import {
appsEnabledForClientCapabilities,
buildMcpServerV2,
clientCapabilitiesFromRequest,
requestBodyFromRequest,
} from "@executor-js/host-mcp/tool-server-v2";
import {
approvalUrlForRequest,
decodeResumeResponse,
Expand Down Expand Up @@ -129,8 +140,13 @@ export const createMcpRequestHandler = (
const resources = new Map<string, McpResource>();
const sessionEngines = new Map<string, AnyExecutionEngine>();
const sessionClosers = new Map<string, () => Promise<void>>();
const modernHandlers = new Map<string, McpHttpHandler>();
const approvals = makeInProcessBrowserApprovalStore();
const defaultEngine = engineFromConfig(handlerConfig.defaultConfig);
let requestStateSigningKey: Uint8Array | undefined;

const signingKey = (): Uint8Array =>
(requestStateSigningKey ??= crypto.getRandomValues(new Uint8Array(32)));

const pausedDetail = (
sessionId: string,
Expand Down Expand Up @@ -164,10 +180,58 @@ export const createMcpRequestHandler = (
await ignoreClose(close);
};

const modernHandlerFor = (resource: McpResource): McpHttpHandler => {
const key = mcpResourceKey(resource);
const cached = modernHandlers.get(key);
if (cached) return cached;

const handler = createMcpHandler(
(context) => {
const request = context.requestInfo;
if (!request) {
// oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party McpServerFactory Promise contract has no typed failure channel; missing documented request context is an SDK defect
return Effect.runPromise(Effect.die("Modern MCP request context has no request"));
}
return Effect.runPromise(
Effect.gen(function* () {
const resourceConfig = yield* Effect.promise(() => configForResource(resource));
const clientCapabilities = yield* clientCapabilitiesFromRequest(request);
const server = yield* buildMcpServerV2({
...resourceConfig.config,
artifactsEnabled: readArtifactsEnabled(request),
appsEnabled: appsEnabledForClientCapabilities(clientCapabilities),
requestStateSigningKey: signingKey(),
requestStatePrincipal: "local",
});
if (resourceConfig.close) {
const closeServer = server.close.bind(server);
const closeConfig = resourceConfig.close;
let closed = false;
server.close = async () => {
if (closed) return;
closed = true;
await ignoreClose(closeServer);
await ignoreClose(closeConfig);
};
}
return server;
}),
);
},
{ legacy: "reject" },
);
modernHandlers.set(key, handler);
return handler;
};

return {
handleRequest: async (request) => {
const resource = resourceFromRequest(request);
if (!resource) return jsonError(404, -32001, "MCP resource not found");
if (!(await isLegacyRequest(request))) {
const parsedBody = await Effect.runPromise(requestBodyFromRequest(request));
return modernHandlerFor(resource).fetch(request, { parsedBody });
}
const sessionId = request.headers.get("mcp-session-id");

if (sessionId) {
Expand Down Expand Up @@ -283,7 +347,10 @@ export const createMcpRequestHandler = (

close: async () => {
const ids = new Set([...transports.keys(), ...servers.keys()]);
await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true })));
await Promise.all([
...[...ids].map((id) => dispose(id, { transport: true, server: true })),
...[...modernHandlers.values()].map((handler) => handler.close()),
]);
},
};
};
Expand All @@ -295,6 +362,7 @@ export const createMcpRequestHandler = (
export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise<void> => {
startIntegrationsRefresh();

// Deliberately v1-only in this release; modern stdio clients use their probe fallback policy.
const server = await Effect.runPromise(createExecutorMcpServer(config));
const transport = new StdioServerTransport();

Expand Down
Loading
Loading