From 6bd8a889d2fc321045c2de0d72c1e5cdf52e0f60 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:09:48 +0000 Subject: [PATCH 1/2] feat(mcp): runtime tool-pack switching with tools/list_changed Add list_tool_packs / set_tool_packs meta-tools so agents can enable or disable domain tool packs mid-session instead of only via the boot-time VCAD_MCP_PACKS env var. - On stdio/persistent transports the change is live: ListTools reflects it immediately and notifications/tools/list_changed is emitted (tools.listChanged capability now advertised). - On the stateless HTTP transport a signed-in user's choice is persisted keyed by user (mcp_tool_packs table / in-memory fake) and re-derived on the next request; anonymous callers fall back to VCAD_MCP_PACKS. - VCAD_MCP_PACKS remains the boot-time default; default with nothing set is still "all packs". Disabled-pack calls still return an actionable error, now naming set_tool_packs. Co-Authored-By: Claude Opus 4.8 --- .../2026-07-07-runtime-tool-packs.json | 10 + packages/mcp/README.md | 20 ++ .../src/__tests__/tool-packs-runtime.test.ts | 163 ++++++++++++ .../src/__tests__/tool-surface.fixture.json | 34 +++ packages/mcp/src/server.ts | 245 ++++++++++++++++-- packages/mcp/src/session-store.ts | 137 ++++++++++ supabase/migrations/030_mcp_tool_packs.sql | 18 ++ 7 files changed, 611 insertions(+), 16 deletions(-) create mode 100644 changelog/entries/2026-07-07-runtime-tool-packs.json create mode 100644 packages/mcp/src/__tests__/tool-packs-runtime.test.ts create mode 100644 supabase/migrations/030_mcp_tool_packs.sql diff --git a/changelog/entries/2026-07-07-runtime-tool-packs.json b/changelog/entries/2026-07-07-runtime-tool-packs.json new file mode 100644 index 000000000..de853298a --- /dev/null +++ b/changelog/entries/2026-07-07-runtime-tool-packs.json @@ -0,0 +1,10 @@ +{ + "id": "2026-07-07-runtime-tool-packs", + "version": "0.9.4", + "date": "2026-07-07", + "category": "feat", + "title": "Switch MCP tool packs at runtime", + "summary": "Agents can enable/disable tool packs mid-session via list_tool_packs / set_tool_packs, with live notifications/tools/list_changed on stdio.", + "features": ["mcp", "tool-packs"], + "mcpTools": ["list_tool_packs", "set_tool_packs"] +} diff --git a/packages/mcp/README.md b/packages/mcp/README.md index c0eff772a..dccec77ec 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -35,6 +35,26 @@ Unset, every pack is enabled. A smaller surface costs fewer schema tokens per request and measurably improves tool-selection accuracy for focused workflows. +### Switching packs at runtime + +`VCAD_MCP_PACKS` is only the boot-time default — an agent can also flip +packs mid-session with two always-on meta-tools: + +- **`list_tool_packs`** — the packs, whether each is currently enabled, + and its tool count. +- **`set_tool_packs`** — enable/disable packs by name. Pass `enable` + and/or `disable` arrays, or `set` to replace the enabled set outright + (an array of names, or `"all"` / `"none"`). + +On a **persistent transport (stdio)** the change is live: the next +`tools/list` reflects it and the server emits +`notifications/tools/list_changed` so the client refetches. On the +**stateless HTTP transport** (fresh server per request) there's no push +channel — instead, a signed-in user's choice is persisted (keyed by user +in the `mcp_tool_packs` table) and applied on the next request; anonymous +HTTP callers fall back to `VCAD_MCP_PACKS`. Calling a tool whose pack is +disabled returns an actionable error pointing at `set_tool_packs`. + ## Discord activity rollups The server can post a periodic activity summary to a Discord channel — diff --git a/packages/mcp/src/__tests__/tool-packs-runtime.test.ts b/packages/mcp/src/__tests__/tool-packs-runtime.test.ts new file mode 100644 index 000000000..6b0392478 --- /dev/null +++ b/packages/mcp/src/__tests__/tool-packs-runtime.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { Engine } from "@vcad/engine"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; +import { createServer } from "../server.js"; +import { resetInMemoryPackStore } from "../session-store.js"; +import type { AuthUser } from "../oauth.js"; + +/** + * Runtime tool-pack switching (issue #432): `set_tool_packs` flips the exposed + * surface at runtime. On a persistent transport (stdio) the change is live — + * ListTools reflects it and `notifications/tools/list_changed` fires. On the + * stateless HTTP transport a signed-in user's choice is persisted and applies + * on the next request; here we simulate that with the in-memory pack store fake. + */ + +type Json = { content: Array<{ type: string; text: string }>; isError?: boolean }; + +/** Drive a tool through an in-memory MCP client/server pair. Returns both the + * client (to list tools / observe notifications) and a call helper. */ +async function connect(user: AuthUser | null) { + const engine = await Engine.init(); + const server = await createServer(engine, { user }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "t", version: "0.0.0" }, { capabilities: {} }); + await Promise.all([client.connect(clientT), server.connect(serverT)]); + const call = async (name: string, args: Record = {}) => + (await client.callTool({ name, arguments: args })) as unknown as Json; + const names = async () => (await client.listTools()).tools.map((t) => t.name); + return { server, client, call, names }; +} + +describe("runtime tool packs", () => { + beforeAll(async () => { + await Engine.init(); + }); + + beforeEach(() => { + resetInMemoryPackStore(); + delete process.env.VCAD_MCP_PACKS; + }); + + afterEach(() => { + delete process.env.VCAD_MCP_PACKS; + }); + + it("list_tool_packs reports every pack enabled by default with tool counts", async () => { + const { client, call } = await connect(null); + const out = JSON.parse((await call("list_tool_packs")).content[0].text); + expect(out.core_always_on).toBe(true); + const packs: Array<{ name: string; enabled: boolean; tool_count: number }> = out.packs; + expect(packs.length).toBeGreaterThan(0); + expect(packs.every((p) => p.enabled)).toBe(true); + const ecad = packs.find((p) => p.name === "ecad"); + expect(ecad?.tool_count).toBeGreaterThan(0); + await client.close(); + }); + + it("stdio: set_tool_packs updates ListTools live and emits list_changed", async () => { + const { client, call, names } = await connect(null); + + let listChangedFired = false; + client.setNotificationHandler( + ToolListChangedNotificationSchema, + async () => { + listChangedFired = true; + }, + ); + + // Baseline: an ecad tool is present. + expect(await names()).toContain("run_drc"); + + // Disable everything but dfm. + const res = JSON.parse((await call("set_tool_packs", { set: ["dfm"] })).content[0].text); + expect(res.enabled).toEqual(["dfm"]); + expect(res.list_changed_sent).toBe(true); + + // Wait a tick for the notification to be delivered over the in-memory pair. + await new Promise((r) => setTimeout(r, 10)); + expect(listChangedFired).toBe(true); + + // ListTools reflects the change immediately: dfm stays, ecad is gone, core stays. + const after = await names(); + expect(after).toContain("dfm_check"); + expect(after).not.toContain("run_drc"); + expect(after).toContain("create_cad_loon"); + // Meta-tools are always-on core, never gated. + expect(after).toContain("list_tool_packs"); + expect(after).toContain("set_tool_packs"); + + await client.close(); + }); + + it("a disabled-pack call returns an actionable error naming set_tool_packs", async () => { + const { client, call } = await connect(null); + await call("set_tool_packs", { set: "none" }); + const err = await call("run_drc", { document_id: "x" }); + expect(err.isError).toBe(true); + expect(err.content[0].text).toContain("ecad"); + expect(err.content[0].text).toContain("set_tool_packs"); + await client.close(); + }); + + it("enable/disable deltas compose over the current set", async () => { + const { client, call } = await connect(null); + await call("set_tool_packs", { set: "none" }); + await call("set_tool_packs", { enable: ["ecad", "dfm"] }); + let state = JSON.parse((await call("list_tool_packs")).content[0].text).packs; + expect(state.find((p: { name: string }) => p.name === "ecad").enabled).toBe(true); + expect(state.find((p: { name: string }) => p.name === "dfm").enabled).toBe(true); + expect(state.find((p: { name: string }) => p.name === "physics").enabled).toBe(false); + + await call("set_tool_packs", { disable: ["ecad"] }); + state = JSON.parse((await call("list_tool_packs")).content[0].text).packs; + expect(state.find((p: { name: string }) => p.name === "ecad").enabled).toBe(false); + expect(state.find((p: { name: string }) => p.name === "dfm").enabled).toBe(true); + await client.close(); + }); + + it("rejects an unknown pack name without mutating state", async () => { + const { client, call, names } = await connect(null); + const before = await names(); + const res = await call("set_tool_packs", { enable: ["nope"] }); + expect(res.isError).toBe(true); + expect(res.content[0].text).toContain("Unknown pack(s): nope"); + expect(await names()).toEqual(before); + await client.close(); + }); + + it("stateless HTTP: a signed-in user's choice persists to the next request", async () => { + const user: AuthUser = { sub: "user-abc", email: "a@b.co" }; + + // Request 1: the user trims to dfm only. A fresh server (like a new + // stateless HTTP request) is used per connection. + const first = await connect(user); + const res = JSON.parse( + (await first.call("set_tool_packs", { set: ["dfm"] })).content[0].text, + ); + expect(res.enabled).toEqual(["dfm"]); + await first.client.close(); + await first.server.close(); + + // Request 2: a brand-new server for the same user re-derives the saved + // preference from the (in-memory fake) durable store. + const second = await connect(user); + const after = await second.names(); + expect(after).toContain("dfm_check"); + expect(after).not.toContain("run_drc"); + const info = JSON.parse( + (await second.call("server_info")).content[0].text, + ); + expect(info.packs).toBe("dfm"); + await second.client.close(); + await second.server.close(); + + // A different user is unaffected — still sees the full surface. + const other = await connect({ sub: "user-xyz", email: "x@y.co" }); + expect(await other.names()).toContain("run_drc"); + await other.client.close(); + await other.server.close(); + }); +}); diff --git a/packages/mcp/src/__tests__/tool-surface.fixture.json b/packages/mcp/src/__tests__/tool-surface.fixture.json index b393b5b67..b36267498 100644 --- a/packages/mcp/src/__tests__/tool-surface.fixture.json +++ b/packages/mcp/src/__tests__/tool-surface.fixture.json @@ -192,6 +192,40 @@ "properties": {} } }, + { + "name": "list_tool_packs", + "description": "List the optional tool packs and whether each is currently enabled, with its tool count. Packs gate large domain surfaces (ecad, physics, sheet_metal, dfm, \u2026) off the always-on core; a smaller surface costs fewer schema tokens and improves tool selection. Use set_tool_packs to enable/disable them at runtime.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "set_tool_packs", + "description": "Enable or disable optional tool packs at runtime (see list_tool_packs for names). Pass `enable` and/or `disable` as arrays of pack names, or `set` to replace the enabled set outright (an array, or the string \"all\" / \"none\"). On stdio/persistent connections the tool list updates immediately and emits notifications/tools/list_changed; on the stateless HTTP transport the choice is saved for a signed-in user and applies on the next request (no push notification there). Disabled-pack calls keep returning an actionable error.", + "inputSchema": { + "type": "object", + "properties": { + "enable": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Pack names to enable." + }, + "disable": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Pack names to disable." + }, + "set": { + "description": "Replace the enabled set: an array of pack names, or \"all\" / \"none\"." + } + } + } + }, { "name": "quote_manufacturing", "description": "Quote manufacturing a part: measures the design, runs light DFM, and returns margin-inclusive price options per fab (pcb/cnc/3dprint/sheet_metal/cast_metal). Pass `ir` (inline Document \u2014 stateless, no open_document needed, serverless-safe, parallel-safe) OR a `document_id` from an open session. Persists a quote + a QUOTED order. Phase 0 is quote-only \u2014 prices are estimates and ordering/payment ship next; no money moves. For sheet_metal the result includes `fab_handoff`: curated US instant-quote shops (SendCutSend/OSH Cut/Fabworks), the exact file recipe (DXF via sheet_metal_unfold or folded STEP via export_cad), and what to enter at upload \u2014 everything needed to finish the order on the fab's site today.", diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 790f9fad3..93c7da434 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -36,6 +36,7 @@ import { createSessionStore, createSessionEventStore, createShareStore, + createPackStore, sessionStoreInfo, warnIfSessionStoreNotDurable, } from "./session-store.js"; @@ -308,6 +309,8 @@ const LIST_TOOL_ORDER: readonly string[] = [ "branch_from", "continue_document", "server_info", + "list_tool_packs", + "set_tool_packs", // ── vcad Fabricate ───────────────────────────────────────── "quote_manufacturing", "get_order_status", @@ -492,17 +495,33 @@ function buildInstructions(kernelPrompt: string | null): string { return [header, ...picked].join("\n\n"); } -/** Tool names hidden by the `VCAD_MCP_PACKS` env var (empty = none). A tool is - * hidden when its `pack` is set and that pack isn't in the enabled list; - * `pack: null` tools (core) and the registry-tier kernel tools are never - * gated. Derived from each ToolDef's `pack` — no separate pack table. - * Exported for tests. */ -export function disabledToolNames(): Set { +/** Every distinct domain pack contributed by a ToolDef, sorted. Core + * (`pack: null`) tools and the registry-tier kernel tools are never packs. + * The runtime pack-switching meta-tools (`list_tool_packs`/`set_tool_packs`) + * validate names against this and report per-pack state from it. */ +export const ALL_PACKS: readonly string[] = Array.from( + new Set(STATIC_TOOL_DEFS.map((d) => d.pack).filter((p): p is string => !!p)), +).sort(); + +/** Parse `VCAD_MCP_PACKS` into the set of ENABLED packs, or null when the var + * is unset — the default, meaning "all packs". `none` yields the empty set + * (core only). This is the boot-time default; a connection may then flip it + * live via `set_tool_packs`. */ +export function parseEnvPacks(): Set | null { const env = process.env.VCAD_MCP_PACKS?.trim(); - if (!env) return new Set(); + if (!env) return null; const enabled = new Set( env.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean), ); + enabled.delete("none"); + return enabled; +} + +/** Tool names hidden given a set of ENABLED packs: a tool is hidden when its + * `pack` is set and not in `enabled`. `pack: null` core tools and the + * registry-tier kernel tools are never gated. Derived from each ToolDef's + * `pack` — no separate pack table. */ +export function packDisabledNames(enabled: Set): Set { const disabled = new Set(); for (const d of STATIC_TOOL_DEFS) { if (d.pack && !enabled.has(d.pack)) disabled.add(d.name); @@ -510,6 +529,14 @@ export function disabledToolNames(): Set { return disabled; } +/** Tool names hidden by the `VCAD_MCP_PACKS` env var (empty = none). `pack: + * null` tools (core) and the registry-tier kernel tools are never gated. + * Exported for tests. */ +export function disabledToolNames(): Set { + const enabled = parseEnvPacks(); + return enabled ? packDisabledNames(enabled) : new Set(); +} + /** * Single chokepoint for viewer `_meta`, derived from a tool's behavior flags — * so a new tool can never accidentally inherit the template; it has to set @@ -636,8 +663,43 @@ export async function createServer( behavior: behavior({ geometry: true, writesDoc: d.name !== "read" }), })); - // Tools hidden by VCAD_MCP_PACKS (resolved once at server creation). - const disabledTools = disabledToolNames(); + // ── Runtime tool packs ──────────────────────────────────────────────────── + // The enabled-pack set is mutable per connection: `set_tool_packs` flips it + // live. On stdio/persistent transports the flip takes effect immediately and + // emits notifications/tools/list_changed; on the stateless HTTP transport + // it's persisted for a signed-in user (packStore) and re-derived here on the + // next request. Initial value: the user's saved preference if any, else + // VCAD_MCP_PACKS, else all packs (unchanged default). `enabledPacks` and the + // derived `disabledTools` are `let` so the meta-tool can reassign them; every + // reader (assembleToolList, the CallTool gate, server_info) reads them at + // call time and so reflects the current state. + const packStore = createPackStore(context.user); + let enabledPacks: Set = await (async () => { + try { + const saved = await packStore.load(); + if (saved) return new Set(saved); + } catch { + // durable read is best-effort — fall back to env / all packs + } + return parseEnvPacks() ?? new Set(ALL_PACKS); + })(); + let disabledTools = packDisabledNames(enabledPacks); + + /** Compact summary of the enabled packs for `server_info`: "all", "none", or + * a sorted comma list. */ + const packsSummary = (): string => { + if (enabledPacks.size === ALL_PACKS.length) return "all"; + if (enabledPacks.size === 0) return "none"; + return Array.from(enabledPacks).sort().join(","); + }; + + /** Per-pack enabled state + tool count, for the pack meta-tools. */ + const packState = (): Array<{ name: string; enabled: boolean; tool_count: number }> => + ALL_PACKS.map((name) => ({ + name, + enabled: enabledPacks.has(name), + tool_count: STATIC_TOOL_DEFS.filter((d) => d.pack === name).length, + })); // Startup self-check: confirm the kernel WASM exposes the load-bearing // exports this build depends on. A stale/incomplete dist otherwise surfaces @@ -699,7 +761,147 @@ export async function createServer( : {}), kernel_tool_count: dispatchableTools.size, disabled_tool_count: disabledTools.size, - packs: process.env.VCAD_MCP_PACKS ?? "all", + packs: packsSummary(), + }), + }, + ], + }; + }, + behavior: behavior({}), + }; + + // ── Runtime tool-pack meta-tools ────────────────────────────────────────── + // Defined inline (like server_info) because they close over this + // connection's mutable `enabledPacks` / `disabledTools`, its `packStore`, and + // the `server` handle used to emit list_changed. + const listToolPacksDef: ToolDef = { + name: "list_tool_packs", + pack: null, + description: + "List the optional tool packs and whether each is currently enabled, " + + "with its tool count. Packs gate large domain surfaces (ecad, physics, " + + "sheet_metal, dfm, …) off the always-on core; a smaller surface costs " + + "fewer schema tokens and improves tool selection. Use set_tool_packs to " + + "enable/disable them at runtime.", + inputSchema: { type: "object", properties: {} }, + handler: async (): Promise => ({ + content: [ + { + type: "text", + text: JSON.stringify({ packs: packState(), core_always_on: true }), + }, + ], + }), + behavior: behavior({}), + }; + + const setToolPacksDef: ToolDef = { + name: "set_tool_packs", + pack: null, + description: + "Enable or disable optional tool packs at runtime (see list_tool_packs " + + "for names). Pass `enable` and/or `disable` as arrays of pack names, or " + + '`set` to replace the enabled set outright (an array, or the string ' + + '"all" / "none"). On stdio/persistent connections the tool list updates ' + + "immediately and emits notifications/tools/list_changed; on the stateless " + + "HTTP transport the choice is saved for a signed-in user and applies on " + + "the next request (no push notification there). Disabled-pack calls keep " + + "returning an actionable error.", + inputSchema: { + type: "object", + properties: { + enable: { + type: "array", + items: { type: "string" }, + description: "Pack names to enable.", + }, + disable: { + type: "array", + items: { type: "string" }, + description: "Pack names to disable.", + }, + set: { + description: + 'Replace the enabled set: an array of pack names, or "all" / "none".', + }, + }, + }, + handler: async (args): Promise => { + const err = (text: string): ToolResult => ({ + content: [{ type: "text", text }], + isError: true, + }); + const known = new Set(ALL_PACKS); + const bad = new Set(); + const asNames = (v: unknown): string[] => + Array.isArray(v) ? v.map((x) => String(x).trim().toLowerCase()) : []; + const noteUnknown = (names: string[]) => { + for (const n of names) if (!known.has(n)) bad.add(n); + }; + + let next: Set; + if (args.set !== undefined) { + if (args.set === "all") next = new Set(ALL_PACKS); + else if (args.set === "none") next = new Set(); + else if (Array.isArray(args.set)) { + const names = asNames(args.set); + noteUnknown(names); + next = new Set(names); + } else { + return err('`set` must be an array of pack names, or "all" / "none".'); + } + } else { + next = new Set(enabledPacks); + } + if (args.enable !== undefined) { + const names = asNames(args.enable); + noteUnknown(names); + for (const n of names) next.add(n); + } + if (args.disable !== undefined) { + const names = asNames(args.disable); + noteUnknown(names); + for (const n of names) next.delete(n); + } + if (bad.size > 0) { + return err( + `Unknown pack(s): ${Array.from(bad).join(", ")}. ` + + `Known packs: ${ALL_PACKS.join(", ")}.`, + ); + } + + enabledPacks = next; + disabledTools = packDisabledNames(enabledPacks); + + // Persist for a signed-in user so a stateless HTTP request re-derives it. + let persisted = false; + try { + await packStore.save(Array.from(enabledPacks).sort()); + persisted = packStore.durable && context.user !== null; + } catch { + // best-effort durable write + } + + // Live update on persistent transports. On the stateless HTTP transport + // there's no push channel, so this is a no-op / rejects — the next + // request advertises the new surface instead. + let listChangedSent = false; + try { + await server.sendToolListChanged(); + listChangedSent = true; + } catch { + // no notification channel (stateless transport / not connected) + } + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + packs: packState(), + enabled: Array.from(enabledPacks).sort(), + list_changed_sent: listChangedSent, + persisted, }), }, ], @@ -709,20 +911,25 @@ export async function createServer( }; // Every tool this connection can dispatch: static module defs + server_info + - // the generated registry defs. Name → def, for O(1) CallTool lookup. + // the pack meta-tools + the generated registry defs. Name → def, for O(1) + // CallTool lookup. const dispatchMap = new Map(); for (const d of STATIC_TOOL_DEFS) dispatchMap.set(d.name, d); dispatchMap.set(serverInfoDef.name, serverInfoDef); + dispatchMap.set(listToolPacksDef.name, listToolPacksDef); + dispatchMap.set(setToolPacksDef.name, setToolPacksDef); for (const d of registryDefs) dispatchMap.set(d.name, d); // Boot-time drift guard: LIST_TOOL_ORDER must name every static def + - // server_info exactly once (registry defs are spliced separately). A missing - // or duplicated name is a wiring bug that would silently drop/duplicate a - // tool from ListTools. + // server_info + the pack meta-tools exactly once (registry defs are spliced + // separately). A missing or duplicated name is a wiring bug that would + // silently drop/duplicate a tool from ListTools. const orderSet = new Set(LIST_TOOL_ORDER); const staticNames = new Set([ ...STATIC_TOOL_DEFS.map((d) => d.name), serverInfoDef.name, + listToolPacksDef.name, + setToolPacksDef.name, ]); if (orderSet.size !== LIST_TOOL_ORDER.length) { throw new Error("[mcp] LIST_TOOL_ORDER contains a duplicate tool name"); @@ -784,7 +991,9 @@ export async function createServer( }, { capabilities: { - tools: {}, + // listChanged: `set_tool_packs` re-advertises the surface at runtime + // and emits notifications/tools/list_changed on persistent transports. + tools: { listChanged: true }, resources: {}, // Acknowledge MCP Apps UI extension so Claude Desktop renders the viewer iframe. // The extension key is not in the typed ServerCapabilities schema so we spread as object. @@ -924,11 +1133,15 @@ export async function createServer( const { name, arguments: args = {} } = request.params; if (disabledTools.has(name)) { + const pack = dispatchMap.get(name)?.pack; + const enableHint = pack + ? `Enable it with set_tool_packs({ enable: ["${pack}"] }) or set VCAD_MCP_PACKS.` + : "Enable its pack to use it."; const disabledResult: ToolResult = { content: [ { type: "text", - text: `Tool '${name}' belongs to a pack disabled by VCAD_MCP_PACKS. Enable its pack to use it.`, + text: `Tool '${name}' belongs to a disabled tool pack${pack ? ` ('${pack}')` : ""}. ${enableHint}`, }, ], isError: true, diff --git a/packages/mcp/src/session-store.ts b/packages/mcp/src/session-store.ts index c6b164aa3..0df227ac1 100644 --- a/packages/mcp/src/session-store.ts +++ b/packages/mcp/src/session-store.ts @@ -685,6 +685,143 @@ export function createShareStore(): ShareStore { : new NoopShareStore(); } +// ─── Tool-pack preference (runtime tool-pack switching, issue #432) ─────────── +// +// `set_tool_packs` flips which domain packs a connection exposes. On a +// persistent transport (stdio) the flip lives in the server closure and emits +// notifications/tools/list_changed. On the STATELESS HTTP transport each +// request builds a fresh server, so the preference must be re-derivable per +// request: we persist it keyed by the authenticated user in a durable store and +// re-read it at the top of the next request. Push notifications don't apply to +// the stateless transport — the next request simply advertises the new surface. + +export interface PackStore { + /** True when saves survive across serverless instances (Supabase-backed). + * The in-memory impl persists only within one process/instance. */ + readonly durable: boolean; + /** The caller's saved enabled-pack list, or null when none is stored (fall + * back to VCAD_MCP_PACKS / all packs). Never throws — an outage is a null. */ + load(): Promise; + /** Persist the caller's enabled-pack list. Best-effort: errors are logged, + * never thrown, and a no-op when there's no user to key on. */ + save(packs: string[]): Promise; +} + +/** Process-global preference map for the in-memory store, keyed by user id. + * Survives across per-request `createServer` calls WITHIN one instance, so a + * signed-in user's pack choice applies on their next request on the same warm + * instance (and is the fake the HTTP persistence test drives). */ +const inMemoryPackPrefs = new Map(); + +/** Test hook: clear the process-global in-memory pack preferences. */ +export function resetInMemoryPackStore(): void { + inMemoryPackPrefs.clear(); +} + +/** + * In-memory pack store = stdio/local and anonymous HTTP: persists to a + * process-global map keyed by user id (a no-op when there's no user). Not + * durable across serverless instances — that needs Supabase. + */ +export class InMemoryPackStore implements PackStore { + readonly durable = false; + constructor(private userId: string | null) {} + async load(): Promise { + if (!this.userId) return null; + const v = inMemoryPackPrefs.get(this.userId); + return v ? [...v] : null; + } + async save(packs: string[]): Promise { + if (!this.userId) return; + inMemoryPackPrefs.set(this.userId, [...packs]); + } +} + +/** + * Cloud-backed pack store over the `mcp_tool_packs` table (service role, one + * row per user). Durable across serverless instances, so a stateless HTTP + * request re-derives the user's pack choice regardless of which instance serves + * it. Same best-effort discipline as SupabaseSessionStore: a read failure + * degrades to "no preference", a write failure is logged, neither throws. + */ +export class SupabasePackStore implements PackStore { + readonly durable = true; + constructor( + private cfg: { supabaseUrl: string; serviceRoleKey: string; userId: string }, + ) {} + + private headers(extra: Record = {}): Record { + return { + apikey: this.cfg.serviceRoleKey, + Authorization: `Bearer ${this.cfg.serviceRoleKey}`, + "Content-Type": "application/json", + ...extra, + }; + } + + async load(): Promise { + try { + const res = await sessionFetch( + `${this.cfg.supabaseUrl}/rest/v1/mcp_tool_packs` + + `?user_id=eq.${encodeURIComponent(this.cfg.userId)}&select=packs&limit=1`, + { + method: "GET", + headers: this.headers({ Accept: "application/vnd.pgrst.object+json" }), + }, + ); + if (!res.ok) return null; // 406 = zero rows → no saved preference + const row = (await res.json()) as { packs?: unknown }; + return Array.isArray(row?.packs) ? row.packs.map(String) : null; + } catch (err) { + console.error("[pack-store] load failed:", err); + return null; + } + } + + async save(packs: string[]): Promise { + try { + const res = await sessionFetch( + `${this.cfg.supabaseUrl}/rest/v1/mcp_tool_packs?on_conflict=user_id`, + { + method: "POST", + headers: this.headers({ + Prefer: "resolution=merge-duplicates,return=minimal", + }), + body: JSON.stringify([{ user_id: this.cfg.userId, packs }]), + }, + ); + if (!res.ok) { + console.error( + "[pack-store] save failed:", + res.status, + await res.text().catch(() => ""), + ); + } + } catch (err) { + console.error("[pack-store] save failed:", err); + } + } +} + +/** + * Choose the pack store from env + user: Supabase-backed (durable across + * instances) for a signed-in user when the service-role key is present, else + * the in-memory store (stdio/local, or anonymous HTTP). Mirrors + * `createSessionStore`'s env/user gating. + */ +export function createPackStore(user: AuthUser | null): PackStore { + const url = (process.env.SUPABASE_URL || "").replace(/\/+$/, ""); + const key = process.env.SUPABASE_SERVICE_ROLE_KEY || ""; + if (url && key && user) { + return new SupabasePackStore({ + supabaseUrl: url, + serviceRoleKey: key, + userId: user.sub, + }); + } + return new InMemoryPackStore(user?.sub ?? null); +} + /** * Resolve a session's Document IR by session id ALONE, via the service role — * for the capability-keyed live geometry endpoint, which has no logged-in user. diff --git a/supabase/migrations/030_mcp_tool_packs.sql b/supabase/migrations/030_mcp_tool_packs.sql new file mode 100644 index 000000000..c6d00fee8 --- /dev/null +++ b/supabase/migrations/030_mcp_tool_packs.sql @@ -0,0 +1,18 @@ +-- Runtime tool-pack switching (issue #432). +-- +-- The MCP server can enable/disable optional tool packs at runtime via +-- `set_tool_packs`. The hosted HTTP transport is stateless (a fresh Server per +-- request), so the choice can't live in process memory — it's persisted here, +-- keyed by the authenticated user, and re-read at the top of the next request. +-- +-- One row per user; `packs` is the enabled-pack list (empty = core only). The +-- server writes with the service-role key, so RLS is enabled with no policies +-- (service role bypasses RLS; no anon/authenticated access is granted). + +create table if not exists public.mcp_tool_packs ( + user_id text primary key, + packs text[] not null default '{}', + updated_at timestamptz not null default now() +); + +alter table public.mcp_tool_packs enable row level security; From 2741da30a47c610204306593f9bc73694d23d120 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 01:09:59 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Merge=20main;=20renumber=20mcp=5Ftool=5Fpac?= =?UTF-8?q?ks=20migration=20030=20=E2=86=92=20032?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supabase's schema_migrations keys on the numeric version prefix: 030 (live_broadcast_gate) and 031 (documents_realtime) landed on main first, so the pack-preferences migration takes the next free slot. Also absorbs #453, clearing the nightly clippy red on this branch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01M5Jh8P571762nh3812kj76 --- .../migrations/{030_mcp_tool_packs.sql => 032_mcp_tool_packs.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{030_mcp_tool_packs.sql => 032_mcp_tool_packs.sql} (100%) diff --git a/supabase/migrations/030_mcp_tool_packs.sql b/supabase/migrations/032_mcp_tool_packs.sql similarity index 100% rename from supabase/migrations/030_mcp_tool_packs.sql rename to supabase/migrations/032_mcp_tool_packs.sql