summaryrefslogtreecommitdiffhomepage
path: root/src/features/mcp
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 10:55:51 +0900
committerAdam Malczewski <[email protected]>2026-06-25 10:55:51 +0900
commit38db3827870960f466be89afbc49f91238d46144 (patch)
tree24cb1b896dfadc31e72552dbe67f00530881242e /src/features/mcp
parent17ce47987e673b6618454d033885b17b2a01912e (diff)
downloaddispatch-web-38db3827870960f466be89afbc49f91238d46144.tar.gz
dispatch-web-38db3827870960f466be89afbc49f91238d46144.zip
feat: workspaces shell + cwd-lsp rename + mcp/settings/system-prompt features + app wiring
- workspaces: URL-driven conversation grouping (home listing at /, routing, store, http adapter, WorkspaceCard) wired into the App.svelte shell - rename features/workspace -> features/cwd-lsp (the cwd/lsp status feature) - new features: mcp (status view), settings (chat-limit field), system-prompt (prompt builder), all rendered via the generic surface host - chat: store + ChatView updates - tabs: tabs-store updates - app wiring: ErrorModal (full-screen error surface), app/App.svelte + store.svelte This commit makes HEAD typecheck clean for the first time: the prior HEAD (c95cc77) imported features/settings from app/App.svelte but never committed the feature, so only the full working tree was green.
Diffstat (limited to 'src/features/mcp')
-rw-r--r--src/features/mcp/index.ts8
-rw-r--r--src/features/mcp/logic/view-model.test.ts88
-rw-r--r--src/features/mcp/logic/view-model.ts110
-rw-r--r--src/features/mcp/ui/McpStatusView.svelte131
4 files changed, 337 insertions, 0 deletions
diff --git a/src/features/mcp/index.ts b/src/features/mcp/index.ts
new file mode 100644
index 0000000..a161992
--- /dev/null
+++ b/src/features/mcp/index.ts
@@ -0,0 +1,8 @@
+export type { LoadMcpStatus, McpStatusResult } from "./logic/view-model";
+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",
+} as const;
diff --git a/src/features/mcp/logic/view-model.test.ts b/src/features/mcp/logic/view-model.test.ts
new file mode 100644
index 0000000..23bf20f
--- /dev/null
+++ b/src/features/mcp/logic/view-model.test.ts
@@ -0,0 +1,88 @@
+import type { McpServerInfo } from "@dispatch/transport-contract";
+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,
+});
+
+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("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("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");
+ });
+
+ 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"]);
+ });
+});
+
+describe("summarizeMcpServers", () => {
+ 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("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
new file mode 100644
index 0000000..247f804
--- /dev/null
+++ b/src/features/mcp/logic/view-model.ts
@@ -0,0 +1,110 @@
+import type { McpServerInfo, McpServerState } from "@dispatch/transport-contract";
+
+/**
+ * Pure core for the mcp feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The mcp feature exposes the live status of the MCP (Model Context Protocol)
+ * servers configured for a conversation's working directory, fetched from
+ * `GET /conversations/:id/mcp`. This module holds the pure logic: the mapping
+ * of a backend `McpServerState` to a display badge + label, and a one-line
+ * server summary. The effect (the HTTP get MCP status) is INJECTED via the
+ * `LoadMcpStatus` port below; the composition root implements it.
+ */
+
+// ── Injected port (consumer-defines-port; the composition root adapts the
+// store's HTTP call to this shape). ──────────────────────────────────────────
+
+/** 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 };
+
+export type LoadMcpStatus = () => Promise<McpStatusResult | null>;
+
+// ── MCP server status → display view ───────────────────────────────────────────
+
+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;
+}
+
+/**
+ * Map a server's state to a display label + badge severity + busy flag. Mirrors
+ * the LSP status visual treatment: `connected` → success, `connecting` (the
+ * transient state, analogous to LSP's `starting`) → warning + spinner, `error`
+ * → 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,
+ };
+}
+
+export function viewMcpServers(servers: readonly McpServerInfo[]): readonly McpServerView[] {
+ return servers.map(viewMcpServer);
+}
+
+/**
+ * A short one-line summary, e.g. "2 connected" / "1 connected, 1 connecting,
+ * 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(", ");
+}
diff --git a/src/features/mcp/ui/McpStatusView.svelte b/src/features/mcp/ui/McpStatusView.svelte
new file mode 100644
index 0000000..9ac40ba
--- /dev/null
+++ b/src/features/mcp/ui/McpStatusView.svelte
@@ -0,0 +1,131 @@
+<script lang="ts">
+ 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();
+
+ 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("");
+
+ 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();
+ });
+ });
+</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>
+
+ {#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>