Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions changelog/entries/2026-07-07-runtime-tool-packs.json
Original file line number Diff line number Diff line change
@@ -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"]
}
20 changes: 20 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
163 changes: 163 additions & 0 deletions packages/mcp/src/__tests__/tool-packs-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) =>
(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();
});
});
34 changes: 34 additions & 0 deletions packages/mcp/src/__tests__/tool-surface.fixture.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading
Loading