summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 07:41:41 +0900
committerAdam Malczewski <[email protected]>2026-06-25 07:41:41 +0900
commit7535bb815c260f621c1d4b12a1a069de21ee2611 (patch)
tree4ff53503d571e3506303710fd9fa5f03562915da
parent4bc062c21a830dd58535252fd24ddb392d262c79 (diff)
downloaddispatch-7535bb815c260f621c1d4b12a1a069de21ee2611.tar.gz
dispatch-7535bb815c260f621c1d4b12a1a069de21ee2611.zip
feat(transport-http): add GET /conversations/:id/mcp status endpoint
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.
-rw-r--r--bun.lock3
-rw-r--r--packages/transport-http/package.json1
-rw-r--r--packages/transport-http/src/app.test.ts169
-rw-r--r--packages/transport-http/src/app.ts52
-rw-r--r--packages/transport-http/src/extension.ts5
-rw-r--r--packages/transport-http/src/seam.ts2
6 files changed, 231 insertions, 1 deletions
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<string, string>(); // 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<string, string>([["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<string, string>([["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<string, string>([["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,