diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 22:21:55 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 22:23:39 +0900 |
| commit | c333fcec32b1f90bf0da6bb14d2609c20e38a74f (patch) | |
| tree | 0db3ec77a6838c4f800c362df0de3c6cd8544431 /src/features/mcp | |
| parent | 1285564f12238b22f6b39b9f3fbcecaca8456911 (diff) | |
| download | dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.tar.gz dispatch-web-c333fcec32b1f90bf0da6bb14d2609c20e38a74f.zip | |
style: switch from tabs to 2-space indentation (incl. svelte)
Diffstat (limited to 'src/features/mcp')
| -rw-r--r-- | src/features/mcp/index.ts | 4 | ||||
| -rw-r--r-- | src/features/mcp/logic/view-model.test.ts | 136 | ||||
| -rw-r--r-- | src/features/mcp/logic/view-model.ts | 128 | ||||
| -rw-r--r-- | src/features/mcp/ui/McpStatusView.svelte | 242 |
4 files changed, 256 insertions, 254 deletions
diff --git a/src/features/mcp/index.ts b/src/features/mcp/index.ts index a161992..9abc023 100644 --- a/src/features/mcp/index.ts +++ b/src/features/mcp/index.ts @@ -3,6 +3,6 @@ export { default as McpStatusView } from "./ui/McpStatusView.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { - name: "mcp", - description: "Per-conversation MCP (Model Context Protocol) server status", + name: "mcp", + description: "Per-conversation MCP (Model Context Protocol) server status", } as const; diff --git a/src/features/mcp/logic/view-model.test.ts b/src/features/mcp/logic/view-model.test.ts index 23bf20f..d1a77d1 100644 --- a/src/features/mcp/logic/view-model.test.ts +++ b/src/features/mcp/logic/view-model.test.ts @@ -3,86 +3,86 @@ import { describe, expect, it } from "vitest"; import { summarizeMcpServers, viewMcpServer, viewMcpServers } from "./view-model"; const server = (over: Partial<McpServerInfo> = {}): McpServerInfo => ({ - id: "freecad", - state: "connected", - toolCount: 12, - ...over, + id: "freecad", + state: "connected", + toolCount: 12, + ...over, }); describe("viewMcpServer", () => { - it("connected → success badge, not busy, no error, passes toolCount", () => { - const v = viewMcpServer(server({ toolCount: 5 })); - expect(v.badge).toBe("success"); - expect(v.statusLabel).toBe("Connected"); - expect(v.busy).toBe(false); - expect(v.error).toBeNull(); - expect(v.toolCount).toBe(5); - expect(v.configSource).toBeNull(); - }); + it("connected → success badge, not busy, no error, passes toolCount", () => { + const v = viewMcpServer(server({ toolCount: 5 })); + expect(v.badge).toBe("success"); + expect(v.statusLabel).toBe("Connected"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + expect(v.toolCount).toBe(5); + expect(v.configSource).toBeNull(); + }); - it("connecting → warning badge + busy (spinner)", () => { - const v = viewMcpServer(server({ state: "connecting" })); - expect(v.badge).toBe("warning"); - expect(v.statusLabel).toBe("Connecting…"); - expect(v.busy).toBe(true); - expect(v.error).toBeNull(); - }); + it("connecting → warning badge + busy (spinner)", () => { + const v = viewMcpServer(server({ state: "connecting" })); + expect(v.badge).toBe("warning"); + expect(v.statusLabel).toBe("Connecting…"); + expect(v.busy).toBe(true); + expect(v.error).toBeNull(); + }); - it("disconnected → neutral badge, not busy", () => { - const v = viewMcpServer(server({ state: "disconnected" })); - expect(v.badge).toBe("neutral"); - expect(v.statusLabel).toBe("Disconnected"); - expect(v.busy).toBe(false); - expect(v.error).toBeNull(); - }); + it("disconnected → neutral badge, not busy", () => { + const v = viewMcpServer(server({ state: "disconnected" })); + expect(v.badge).toBe("neutral"); + expect(v.statusLabel).toBe("Disconnected"); + expect(v.busy).toBe(false); + expect(v.error).toBeNull(); + }); - it("error → error badge + surfaces the reason (with a fallback)", () => { - const withReason = viewMcpServer(server({ state: "error", error: "ENOENT: npx" })); - expect(withReason.badge).toBe("error"); - expect(withReason.busy).toBe(false); - expect(withReason.error).toBe("ENOENT: npx"); + it("error → error badge + surfaces the reason (with a fallback)", () => { + const withReason = viewMcpServer(server({ state: "error", error: "ENOENT: npx" })); + expect(withReason.badge).toBe("error"); + expect(withReason.busy).toBe(false); + expect(withReason.error).toBe("ENOENT: npx"); - const noReason = viewMcpServer(server({ state: "error" })); - expect(noReason.error).toBe("Failed to connect"); - }); + const noReason = viewMcpServer(server({ state: "error" })); + expect(noReason.error).toBe("Failed to connect"); + }); - it("passes through configSource when present", () => { - const v = viewMcpServer(server({ configSource: ".dispatch/mcp.json" })); - expect(v.configSource).toBe(".dispatch/mcp.json"); - }); + it("passes through configSource when present", () => { + const v = viewMcpServer(server({ configSource: ".dispatch/mcp.json" })); + expect(v.configSource).toBe(".dispatch/mcp.json"); + }); - it("viewMcpServers maps a list preserving order", () => { - const views = viewMcpServers([server({ id: "a" }), server({ id: "b" })]); - expect(views.map((v) => v.id)).toEqual(["a", "b"]); - }); + it("viewMcpServers maps a list preserving order", () => { + const views = viewMcpServers([server({ id: "a" }), server({ id: "b" })]); + expect(views.map((v) => v.id)).toEqual(["a", "b"]); + }); }); describe("summarizeMcpServers", () => { - it("empty list", () => { - expect(summarizeMcpServers([])).toBe("No MCP servers"); - }); + it("empty list", () => { + expect(summarizeMcpServers([])).toBe("No MCP servers"); + }); - it("counts connected / connecting / disconnected / errors", () => { - expect(summarizeMcpServers([server({ state: "connected" })])).toBe("1 connected"); - expect( - summarizeMcpServers([ - server({ id: "a", state: "connected" }), - server({ id: "b", state: "error" }), - ]), - ).toBe("1 connected, 1 error"); - expect( - summarizeMcpServers([ - server({ id: "a", state: "connected" }), - server({ id: "b", state: "connecting" }), - server({ id: "c", state: "disconnected" }), - server({ id: "d", state: "error" }), - server({ id: "e", state: "error" }), - ]), - ).toBe("1 connected, 1 connecting, 1 disconnected, 2 errors"); - }); + it("counts connected / connecting / disconnected / errors", () => { + expect(summarizeMcpServers([server({ state: "connected" })])).toBe("1 connected"); + expect( + summarizeMcpServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "error" }), + ]), + ).toBe("1 connected, 1 error"); + expect( + summarizeMcpServers([ + server({ id: "a", state: "connected" }), + server({ id: "b", state: "connecting" }), + server({ id: "c", state: "disconnected" }), + server({ id: "d", state: "error" }), + server({ id: "e", state: "error" }), + ]), + ).toBe("1 connected, 1 connecting, 1 disconnected, 2 errors"); + }); - it("lists only non-zero buckets", () => { - expect(summarizeMcpServers([server({ state: "disconnected" })])).toBe("1 disconnected"); - expect(summarizeMcpServers([server({ id: "a", state: "connecting" })])).toBe("1 connecting"); - }); + it("lists only non-zero buckets", () => { + expect(summarizeMcpServers([server({ state: "disconnected" })])).toBe("1 disconnected"); + expect(summarizeMcpServers([server({ id: "a", state: "connecting" })])).toBe("1 connecting"); + }); }); diff --git a/src/features/mcp/logic/view-model.ts b/src/features/mcp/logic/view-model.ts index 247f804..fdff78a 100644 --- a/src/features/mcp/logic/view-model.ts +++ b/src/features/mcp/logic/view-model.ts @@ -16,8 +16,8 @@ import type { McpServerInfo, McpServerState } from "@dispatch/transport-contract /** Outcome of `GET /conversations/:id/mcp`; `null` when no real conversation is focused. */ export type McpStatusResult = - | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly McpServerInfo[] } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly McpServerInfo[] } + | { readonly ok: false; readonly error: string }; export type LoadMcpStatus = () => Promise<McpStatusResult | null>; @@ -26,18 +26,18 @@ export type LoadMcpStatus = () => Promise<McpStatusResult | null>; export type Badge = "success" | "warning" | "error" | "neutral"; export interface McpServerView { - readonly id: string; - readonly state: McpServerState; - readonly statusLabel: string; - readonly badge: Badge; - /** True while the state is transient (show a spinner). */ - readonly busy: boolean; - /** The error reason when `state === "error"`, else null. */ - readonly error: string | null; - /** Number of tools discovered from this server. */ - readonly toolCount: number; - /** Which config source the server was resolved from, else null. */ - readonly configSource: string | null; + readonly id: string; + readonly state: McpServerState; + readonly statusLabel: string; + readonly badge: Badge; + /** True while the state is transient (show a spinner). */ + readonly busy: boolean; + /** The error reason when `state === "error"`, else null. */ + readonly error: string | null; + /** Number of tools discovered from this server. */ + readonly toolCount: number; + /** Which config source the server was resolved from, else null. */ + readonly configSource: string | null; } /** @@ -47,42 +47,42 @@ export interface McpServerView { * → error, and `disconnected` (a stable idle state) → neutral. */ export function viewMcpServer(server: McpServerInfo): McpServerView { - let statusLabel: string; - let badge: Badge; - let busy = false; - switch (server.state) { - case "connected": - statusLabel = "Connected"; - badge = "success"; - break; - case "connecting": - statusLabel = "Connecting…"; - badge = "warning"; - busy = true; - break; - case "disconnected": - statusLabel = "Disconnected"; - badge = "neutral"; - break; - case "error": - statusLabel = "Error"; - badge = "error"; - break; - } - return { - id: server.id, - state: server.state, - statusLabel, - badge, - busy, - error: server.state === "error" ? (server.error ?? "Failed to connect") : null, - toolCount: server.toolCount, - configSource: server.configSource ?? null, - }; + let statusLabel: string; + let badge: Badge; + let busy = false; + switch (server.state) { + case "connected": + statusLabel = "Connected"; + badge = "success"; + break; + case "connecting": + statusLabel = "Connecting…"; + badge = "warning"; + busy = true; + break; + case "disconnected": + statusLabel = "Disconnected"; + badge = "neutral"; + break; + case "error": + statusLabel = "Error"; + badge = "error"; + break; + } + return { + id: server.id, + state: server.state, + statusLabel, + badge, + busy, + error: server.state === "error" ? (server.error ?? "Failed to connect") : null, + toolCount: server.toolCount, + configSource: server.configSource ?? null, + }; } export function viewMcpServers(servers: readonly McpServerInfo[]): readonly McpServerView[] { - return servers.map(viewMcpServer); + return servers.map(viewMcpServer); } /** @@ -90,21 +90,21 @@ export function viewMcpServers(servers: readonly McpServerInfo[]): readonly McpS * 1 error". Only non-zero buckets are listed. */ export function summarizeMcpServers(servers: readonly McpServerInfo[]): string { - if (servers.length === 0) return "No MCP servers"; - let connected = 0; - let connecting = 0; - let disconnected = 0; - let errored = 0; - for (const s of servers) { - if (s.state === "connected") connected++; - else if (s.state === "error") errored++; - else if (s.state === "connecting") connecting++; - else disconnected++; - } - const parts: string[] = []; - if (connected > 0) parts.push(`${connected} connected`); - if (connecting > 0) parts.push(`${connecting} connecting`); - if (disconnected > 0) parts.push(`${disconnected} disconnected`); - if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); - return parts.join(", "); + if (servers.length === 0) return "No MCP servers"; + let connected = 0; + let connecting = 0; + let disconnected = 0; + let errored = 0; + for (const s of servers) { + if (s.state === "connected") connected++; + else if (s.state === "error") errored++; + else if (s.state === "connecting") connecting++; + else disconnected++; + } + const parts: string[] = []; + if (connected > 0) parts.push(`${connected} connected`); + if (connecting > 0) parts.push(`${connecting} connecting`); + if (disconnected > 0) parts.push(`${disconnected} disconnected`); + if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); + return parts.join(", "); } diff --git a/src/features/mcp/ui/McpStatusView.svelte b/src/features/mcp/ui/McpStatusView.svelte index 9ac40ba..ad51496 100644 --- a/src/features/mcp/ui/McpStatusView.svelte +++ b/src/features/mcp/ui/McpStatusView.svelte @@ -1,131 +1,133 @@ <script lang="ts"> - import { untrack } from "svelte"; - import { - type Badge, - type LoadMcpStatus, - type McpServerView, - summarizeMcpServers, - viewMcpServers, - } from "../logic/view-model"; + import { untrack } from "svelte"; + import { + type Badge, + type LoadMcpStatus, + type McpServerView, + summarizeMcpServers, + viewMcpServers, + } from "../logic/view-model"; - let { - cwd, - canView, - load, - }: { - /** The active conversation's cwd — the trigger to (re)load when it changes. */ - cwd: string | null; - /** Whether a real conversation is focused. */ - canView: boolean; - load: LoadMcpStatus; - } = $props(); + let { + cwd, + canView, + load, + }: { + /** The active conversation's cwd — the trigger to (re)load when it changes. */ + cwd: string | null; + /** Whether a real conversation is focused. */ + canView: boolean; + load: LoadMcpStatus; + } = $props(); - const badgeClass: Record<Badge, string> = { - success: "badge-success", - warning: "badge-warning", - error: "badge-error", - neutral: "badge-ghost", - }; + const badgeClass: Record<Badge, string> = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + neutral: "badge-ghost", + }; - let servers = $state<readonly McpServerView[]>([]); - let loading = $state(false); - let error = $state<string | null>(null); - let loadedCwd = $state<string | null>(null); - let hasLoaded = $state(false); - let summary = $state(""); + let servers = $state<readonly McpServerView[]>([]); + let loading = $state(false); + let error = $state<string | null>(null); + let loadedCwd = $state<string | null>(null); + let hasLoaded = $state(false); + let summary = $state(""); - async function refresh() { - if (!canView) return; - loading = true; - error = null; - const result = await load(); - loading = false; - if (result === null) return; - hasLoaded = true; - if (result.ok) { - servers = viewMcpServers(result.servers); - summary = summarizeMcpServers(result.servers); - loadedCwd = result.cwd; - } else { - error = result.error; - } - } + async function refresh() { + if (!canView) return; + loading = true; + error = null; + const result = await load(); + loading = false; + if (result === null) return; + hasLoaded = true; + if (result.ok) { + servers = viewMcpServers(result.servers); + summary = summarizeMcpServers(result.servers); + loadedCwd = result.cwd; + } else { + error = result.error; + } + } - // (Re)load on mount and whenever the conversation's cwd changes. The MCP GET - // lazily spawns/connects servers, so we avoid a redundant fetch when `cwd` - // resolves to the value we already loaded for. - $effect(() => { - const target = cwd; - const can = canView; - untrack(() => { - if (!can) return; - if (!hasLoaded || target !== loadedCwd) void refresh(); - }); - }); + // (Re)load on mount and whenever the conversation's cwd changes. The MCP GET + // lazily spawns/connects servers, so we avoid a redundant fetch when `cwd` + // resolves to the value we already loaded for. + $effect(() => { + const target = cwd; + const can = canView; + untrack(() => { + if (!can) return; + if (!hasLoaded || target !== loadedCwd) void refresh(); + }); + }); </script> <div class="flex flex-col gap-2"> - <div class="flex items-center justify-between gap-2"> - <span class="text-xs opacity-70"> - {#if loading} - Resolving… - {:else if hasLoaded && loadedCwd !== null} - {summary} - {:else} - MCP servers - {/if} - </span> - <button - type="button" - class="btn btn-ghost btn-xs" - disabled={!canView || loading} - onclick={() => refresh()} - aria-label="Refresh MCP server status" - > - {#if loading} - <span class="loading loading-spinner loading-xs"></span> - {:else} - Refresh - {/if} - </button> - </div> + <div class="flex items-center justify-between gap-2"> + <span class="text-xs opacity-70"> + {#if loading} + Resolving… + {:else if hasLoaded && loadedCwd !== null} + {summary} + {:else} + MCP servers + {/if} + </span> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={!canView || loading} + onclick={() => refresh()} + aria-label="Refresh MCP server status" + > + {#if loading} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Refresh + {/if} + </button> + </div> - {#if !canView} - <p class="text-xs opacity-60">Open or start a conversation to see its MCP servers.</p> - {:else if error} - <p class="text-xs text-error">{error}</p> - {:else if hasLoaded && loadedCwd === null} - <p class="text-xs opacity-60"> - Set a working directory in the Model panel to enable MCP servers. - </p> - {:else if hasLoaded && servers.length === 0 && !loading} - <p class="text-xs opacity-60">No MCP servers configured for this directory.</p> - {:else} - <ul class="flex flex-col gap-2"> - {#each servers as server (server.id)} - <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> - <div class="flex items-center justify-between gap-2"> - <span class="font-medium font-mono">{server.id}</span> - <span class="badge badge-sm {badgeClass[server.badge]} gap-1"> - {#if server.busy} - <span class="loading loading-spinner loading-xs"></span> - {/if} - {server.statusLabel} - </span> - </div> - <div class="flex items-center justify-between gap-2 text-xs opacity-60"> - {#if server.configSource} - <span class="font-mono" title="Config source">{server.configSource}</span> - {:else} - <span></span> - {/if} - <span title="Discovered tools">{server.toolCount} tool{server.toolCount === 1 ? "" : "s"}</span> - </div> - {#if server.error} - <span class="font-mono text-xs text-error">{server.error}</span> - {/if} - </li> - {/each} - </ul> - {/if} + {#if !canView} + <p class="text-xs opacity-60">Open or start a conversation to see its MCP servers.</p> + {:else if error} + <p class="text-xs text-error">{error}</p> + {:else if hasLoaded && loadedCwd === null} + <p class="text-xs opacity-60"> + Set a working directory in the Model panel to enable MCP servers. + </p> + {:else if hasLoaded && servers.length === 0 && !loading} + <p class="text-xs opacity-60">No MCP servers configured for this directory.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each servers as server (server.id)} + <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <div class="flex items-center justify-between gap-2"> + <span class="font-medium font-mono">{server.id}</span> + <span class="badge badge-sm {badgeClass[server.badge]} gap-1"> + {#if server.busy} + <span class="loading loading-spinner loading-xs"></span> + {/if} + {server.statusLabel} + </span> + </div> + <div class="flex items-center justify-between gap-2 text-xs opacity-60"> + {#if server.configSource} + <span class="font-mono" title="Config source">{server.configSource}</span> + {:else} + <span></span> + {/if} + <span title="Discovered tools" + >{server.toolCount} tool{server.toolCount === 1 ? "" : "s"}</span + > + </div> + {#if server.error} + <span class="font-mono text-xs text-error">{server.error}</span> + {/if} + </li> + {/each} + </ul> + {/if} </div> |
