From 7535bb815c260f621c1d4b12a1a069de21ee2611 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 25 Jun 2026 07:41:41 +0900 Subject: feat(transport-http): add GET /conversations/:id/mcp status endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the existing GET /conversations/:id/lsp route exactly: gates on the persisted then effective cwd (null → empty servers), returns 503 when the MCP service isn't loaded, and maps McpServerStatus → McpServerInfo (conditionally including `error` per exactOptionalPropertyTypes). Wires mcpService into CreateServerOptions + extension activate via a plain host.getService (mirroring lspService; "mcp" added to dependsOn, route added to contributes.routes), adds the @dispatch/mcp workspace dep, and re-exports mcpServiceHandle / McpService / McpServerStatus from seam.ts. Adds 4 tests mirroring the LSP status tests. --- bun.lock | 3 +- packages/transport-http/package.json | 1 + packages/transport-http/src/app.test.ts | 169 +++++++++++++++++++++++++++++++ packages/transport-http/src/app.ts | 52 ++++++++++ packages/transport-http/src/extension.ts | 5 + packages/transport-http/src/seam.ts | 2 + 6 files changed, 231 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 3671dee..f7c0418 100644 --- a/bun.lock +++ b/bun.lock @@ -280,7 +280,7 @@ }, "packages/transport-contract": { "name": "@dispatch/transport-contract", - "version": "0.21.0", + "version": "0.22.0", "dependencies": { "@dispatch/ui-contract": "workspace:*", "@dispatch/wire": "workspace:*", @@ -294,6 +294,7 @@ "@dispatch/credential-store": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/lsp": "workspace:*", + "@dispatch/mcp": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", "@dispatch/system-prompt": "workspace:*", "@dispatch/throughput-store": "workspace:*", diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json index e39a992..d95436b 100644 --- a/packages/transport-http/package.json +++ b/packages/transport-http/package.json @@ -10,6 +10,7 @@ "@dispatch/credential-store": "workspace:*", "@dispatch/kernel": "workspace:*", "@dispatch/lsp": "workspace:*", + "@dispatch/mcp": "workspace:*", "@dispatch/session-orchestrator": "workspace:*", "@dispatch/throughput-store": "workspace:*", "@dispatch/transport-contract": "workspace:*", diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index a84fa44..c7b7d31 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -29,6 +29,7 @@ import type { ConversationStore, CredentialStore, LspService, + McpService, SessionOrchestrator, SystemPromptService, WarmService, @@ -397,6 +398,41 @@ function createCapturingLspService( }; } +function createFakeMcpService( + statuses: readonly { + readonly id: string; + readonly state: "connecting" | "connected" | "error" | "disconnected"; + readonly error?: string; + readonly toolCount: number; + }[] = [], +): McpService { + return { + async status() { + return statuses; + }, + }; +} + +function createCapturingMcpService( + statuses: readonly { + readonly id: string; + readonly state: "connecting" | "connected" | "error" | "disconnected"; + readonly error?: string; + readonly toolCount: number; + }[] = [], +): McpService & { readonly statusCalls: readonly string[] } { + const calls: string[] = []; + return { + get statusCalls() { + return calls; + }, + async status(cwd) { + calls.push(cwd); + return statuses; + }, + }; +} + function createFakeSystemPromptService( template: string = "custom template", ): SystemPromptService & { @@ -2384,6 +2420,139 @@ describe("GET /conversations/:id/lsp", () => { }); }); +describe("GET /conversations/:id/mcp", () => { + it("MCP: returns null+empty when no persisted cwd — mcpService.status NOT called", async () => { + const cwdStore = new Map(); // no persisted cwd + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const mcp = createCapturingMcpService([ + { + id: "freecad", + state: "connected" as const, + toolCount: 3, + }, + ]); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + mcpService: mcp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/mcp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly unknown[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBeNull(); + expect(body.servers).toEqual([]); + expect(mcp.statusCalls).toEqual([]); // status NOT called + }); + + it("MCP: maps service statuses to McpServerInfo[] when cwd is set (error omitted when undefined)", async () => { + const cwdStore = new Map([["conv1", "/home/user/project"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const mcpStatuses = [ + { + id: "freecad", + state: "connected" as const, + toolCount: 5, + }, + { + id: "broken", + state: "error" as const, + toolCount: 0, + error: "spawn failed", + }, + ]; + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + mcpService: createFakeMcpService(mcpStatuses), + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/mcp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { + readonly id: string; + readonly state: string; + readonly toolCount: number; + readonly error?: string; + }[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBe("/home/user/project"); + expect(body.servers).toHaveLength(2); + expect(body.servers[0]?.id).toBe("freecad"); + expect(body.servers[0]?.state).toBe("connected"); + expect(body.servers[0]?.toolCount).toBe(5); + expect(body.servers[0]?.error).toBeUndefined(); + expect(body.servers[1]?.id).toBe("broken"); + expect(body.servers[1]?.state).toBe("error"); + expect(body.servers[1]?.toolCount).toBe(0); + expect(body.servers[1]?.error).toBe("spawn failed"); + }); + + it("MCP: uses effectiveCwd when persisted cwd is set — status called with resolved cwd", async () => { + const cwdStore = new Map([["conv1", "subdir"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const resolvedStore: ConversationStore = { + ...store, + async getEffectiveCwd() { + return "/workspace/subdir"; + }, + }; + const mcp = createCapturingMcpService([ + { + id: "freecad", + state: "connected" as const, + toolCount: 2, + }, + ]); + const app = createApp({ + conversationStore: resolvedStore, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + mcpService: mcp, + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/mcp"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + conversationId: string; + cwd: string | null; + servers: readonly { readonly id: string }[]; + }; + expect(body.conversationId).toBe("conv1"); + expect(body.cwd).toBe("/workspace/subdir"); // effective, not persisted + expect(mcp.statusCalls).toEqual(["/workspace/subdir"]); + expect(body.servers).toHaveLength(1); + expect(body.servers[0]?.id).toBe("freecad"); + }); + + it("MCP: returns 503 when mcpService is undefined", async () => { + const cwdStore = new Map([["conv1", "/home/user/project"]]); + const store = createFakeConversationStore(new Map(), new Map(), cwdStore); + const app = createApp({ + conversationStore: store, + orchestrator: createFakeOrchestrator([]), + credentialStore: createFakeCredentialStore([]), + // mcpService intentionally omitted + logger: noopLogger, + }); + const res = await app.request("/conversations/conv1/mcp"); + expect(res.status).toBe(503); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("MCP service not available"); + }); +}); + describe("POST /chat reasoningEffort", () => { const allLevels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"]; diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index 2a98ea0..1d87383 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -13,6 +13,8 @@ import type { LastMessageResponse, LspServerInfo, LspStatusResponse, + McpServerInfo, + McpStatusResponse, ModelResponse, ModelsResponse, OpenConversationResponse, @@ -57,6 +59,8 @@ import { isValidWorkspaceSlug, type LspServerStatus, type LspService, + type McpServerStatus, + type McpService, type SessionOrchestrator, type SystemPromptService, ThroughputQueryError, @@ -71,6 +75,7 @@ export interface CreateServerOptions { readonly warmService?: WarmService; readonly compactionService?: CompactionService; readonly lspService?: LspService; + readonly mcpService?: McpService; /** Optional — system prompt builder service (GET/PUT template). */ readonly systemPromptService?: SystemPromptService; /** Optional — defaults to a no-op store (recording disabled, empty reports). */ @@ -720,6 +725,53 @@ export function createApp(opts: CreateServerOptions): Hono { } }); + // Mirrors GET /conversations/:id/lsp: gate on persisted then effective cwd, + // 503 when no MCP service, map McpServerStatus → McpServerInfo. + app.get("/conversations/:id/mcp", async (c) => { + const conversationId = c.req.param("id"); + try { + const persistedCwd = await opts.conversationStore.getCwd(conversationId); + if (persistedCwd === null) { + log.info("conversations: mcp status read (no cwd)", { conversationId }); + const body: McpStatusResponse = { conversationId, cwd: null, servers: [] }; + return c.json(body, 200); + } + + const effectiveCwd = await opts.conversationStore.getEffectiveCwd(conversationId); + if (effectiveCwd === null) { + log.info("conversations: mcp status read (no effective cwd)", { conversationId }); + const body: McpStatusResponse = { conversationId, cwd: null, servers: [] }; + return c.json(body, 200); + } + + if (opts.mcpService === undefined) { + log.warn("conversations: mcp service not available", { conversationId }); + return c.json({ error: "MCP service not available" }, 503); + } + + const statuses = await opts.mcpService.status(effectiveCwd); + const servers: McpServerInfo[] = statuses.map((s: McpServerStatus) => { + const info: McpServerInfo = { + id: s.id, + state: s.state, + toolCount: s.toolCount, + ...(s.error !== undefined ? { error: s.error } : {}), + }; + return info; + }); + log.info("conversations: mcp status read", { + conversationId, + cwd: effectiveCwd, + serverCount: servers.length, + }); + const body: McpStatusResponse = { conversationId, cwd: effectiveCwd, servers }; + return c.json(body, 200); + } catch (err) { + log.error("conversations: mcp status failure", { err }); + return c.json({ error: "Failed to read MCP status" }, 500); + } + }); + app.get("/conversations", async (c) => { try { // Optional `?status=` comma-separated filter (e.g. "active,idle"). diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index ac6553c..0f46e6b 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -6,6 +6,7 @@ import { conversationStoreHandle, credentialStoreHandle, lspServiceHandle, + mcpServiceHandle, sessionOrchestratorHandle, systemPromptHandle, throughputStoreHandle, @@ -21,6 +22,7 @@ export const manifest: Manifest = { "conversation-store", "credential-store", "lsp", + "mcp", "session-orchestrator", "throughput-store", ], @@ -37,6 +39,7 @@ export const manifest: Manifest = { "/conversations/:id/cwd", "/conversations/:id/last", "/conversations/:id/lsp", + "/conversations/:id/mcp", "/conversations/:id/open", "/conversations/:id/queue", "/conversations/:id/reasoning-effort", @@ -75,6 +78,7 @@ export function createTransportHttpExtension(): Extension & { const warmService = host.getService(cacheWarmHandle); const compactionService = host.getService(compactionHandle); const lspService = host.getService(lspServiceHandle); + const mcpService = host.getService(mcpServiceHandle); const systemPromptService = host.getService(systemPromptHandle); const logger = host.logger; @@ -86,6 +90,7 @@ export function createTransportHttpExtension(): Extension & { warmService, compactionService, lspService, + mcpService, systemPromptService, logger, emit: host.emit.bind(host), diff --git a/packages/transport-http/src/seam.ts b/packages/transport-http/src/seam.ts index 1b359e4..e9dc4ce 100644 --- a/packages/transport-http/src/seam.ts +++ b/packages/transport-http/src/seam.ts @@ -4,6 +4,8 @@ export type { CredentialStore } from "@dispatch/credential-store"; export { credentialStoreHandle } from "@dispatch/credential-store"; export type { LspServerStatus, LspService } from "@dispatch/lsp"; export { lspServiceHandle } from "@dispatch/lsp"; +export type { McpServerStatus, McpService } from "@dispatch/mcp"; +export { mcpServiceHandle } from "@dispatch/mcp"; export type { CompactionService, SessionOrchestrator, -- cgit v1.2.3