diff --git a/README.md b/README.md index 7fcc09d..6f57972 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,9 @@ These limits protect the meaning of the output. A useful review can flag concern ## Learn more +[Local models](docs/LOCAL-MODELS.md) explains how to select a smaller tool catalog +for a client connected to a local model, including an 8 GB hardware evaluation checklist. + [Installation steps](docs/INSTALLATION.md) explain setup on Windows, macOS, Linux. [Shop workflows](docs/SHOP-FLOOR.md) explain setup notes, tool lists, NC comparisons, machine checks. diff --git a/docs/LOCAL-MODELS.md b/docs/LOCAL-MODELS.md new file mode 100644 index 0000000..b496364 --- /dev/null +++ b/docs/LOCAL-MODELS.md @@ -0,0 +1,74 @@ +# Using a smaller tool catalog with a local model + +Mastercam MCP supplies tools. Your MCP client chooses the language model. Use a +client that can connect both to an MCP server and to your local model server. +The MCP server does not load a model or allocate its GPU memory. + +A task-specific catalog gives a smaller local model fewer tool schemas to read. +An explicit allowlist selects the tools needed for the current task. + +## Select the tools for a task + +Set `MASTERCAM_MCP_TOOLS` to a comma-separated list of registered tool names in +the environment that starts the MCP server. Both stdio and HTTP use the setting. + +For example, this PowerShell command selects a small catalog for file review and +thread calculations: + +```powershell +$env:MASTERCAM_MCP_TOOLS = 'mastercam_help,discover_capabilities,analyze_nc_program,calculate_thread_tap' +node dist/cli.js serve +``` + +For a client that launches the server itself, add the variable to that server's +`env` configuration: + +```json +{ + "MASTERCAM_MCP_TOOLS": "mastercam_help,discover_capabilities,analyze_nc_program,calculate_thread_tap" +} +``` + +Only selected tools appear in `tools/list` and receive tool-call handlers. +The help and discovery tools describe that same selected catalog. Include them +in the list if you want the model to use them. Selection preserves the canonical +tool order, and duplicate names do not register duplicate tools. + +The setting is read at startup. Restart the server after changing it. With the +variable unset, the existing catalog is preserved. An explicitly empty value +selects zero tools. An unknown or unregistered name rejects server creation. +Wildcards are not supported. + +Selection narrows the current backend's tools. It cannot expose an unavailable +native mapping, enable Stage-B reads, override the profile, or grant write +approval. Existing resources remain available; this setting controls tools, not +resource access. Internal dependencies of a selected tool still run as before. +Treat it as a context-size control, not a replacement for authorization. + +## What to measure on an 8 GB GPU + +An 8 GB GPU is the hardware target motivating this contribution. This patch +does not establish a minimum GPU requirement or a model compatibility claim. +Model weights, quantization, context length, runtime buffers, and display use +all affect fit. + +Start with synthetic data and one supported deterministic task. Record: + +- The model, quantization, inference runtime, context setting, and GPU. +- The selected tools and serialized `tools/list` size. +- Whether the model selects the right tool and supplies valid arguments. +- Time to the first useful result, failures, and peak memory. + +Compare with the full catalog using the same prompt and inputs. A smaller +schema payload is a protocol-level result. Better latency, accuracy, and memory +use require measurements on the chosen model and hardware. + +After building, run `node work/local-model-catalog.mjs` to compare the full +synthetic catalog with the four-tool example above over stdio. It prints tool +counts and UTF-8 bytes of the serialized tool arrays. This is not an inference +benchmark. The measurement uses the repository's development MCP client. + +MCP transport tests use synthetic data. They do not prove local-model inference +or licensed live Mastercam behavior. Use the existing acceptance procedure for +the latter. For private work, also check your client's inference endpoint and +logging behavior; a local MCP server alone does not make the client offline. diff --git a/src/config.ts b/src/config.ts index 8d90637..9f5c073 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,7 @@ import { parseHardReadOnly } from "./safety/policy.js"; export const ConfigSchema = z.object({ profile: z.enum(["read", "write", "all"]).default("read"), hardReadOnly: z.boolean().default(true), + toolAllowlist: z.array(z.string().trim().min(1)).optional(), backend: z.enum(["auto", "mock", "live", "pipe"]).default("auto"), pipe: z.string().default("\\\\.\\pipe\\mastercam-mcp-default"), audit: z.object({ @@ -60,6 +61,9 @@ export function loadConfig(): Config { const raw = { profile: process.env.MASTERCAM_MCP_PROFILE ?? "read", hardReadOnly: parseHardReadOnly(process.env.MASTERCAM_MCP_HARD_READ_ONLY), + toolAllowlist: process.env.MASTERCAM_MCP_TOOLS === undefined + ? undefined + : parseStringArray(process.env.MASTERCAM_MCP_TOOLS), backend: process.env.MASTERCAM_MCP_BACKEND ?? "auto", pipe: process.env.MASTERCAM_MCP_PIPE ?? "\\\\.\\pipe\\mastercam-mcp-default", audit: { diff --git a/src/http.ts b/src/http.ts index 94a86a7..7ed3f41 100644 --- a/src/http.ts +++ b/src/http.ts @@ -63,7 +63,7 @@ let activeRequests = 0; let shuttingDown = false; const mcpHandler = createMcpHandler( - () => createMcpServer(backend, config.profile, config.hardReadOnly), + () => createMcpServer(backend, config.profile, config.hardReadOnly, config.toolAllowlist), { legacy: "stateless", responseMode: "auto", diff --git a/src/mcp/create-server.ts b/src/mcp/create-server.ts index 74bb203..8675e12 100644 --- a/src/mcp/create-server.ts +++ b/src/mcp/create-server.ts @@ -112,16 +112,28 @@ function approvalStateMatches( * advertises an unverified Mastercam operation mapping. */ export function advertisedToolDefinitions( - backendKind: "mock" | "live" + backendKind: "mock" | "live", + toolAllowlist?: readonly string[] ): ToolDefinition[] { - if (backendKind === "mock") return TOOL_DEFINITIONS; const stageB = stageBReadsEnabled(); - return TOOL_DEFINITIONS.filter( + const available = backendKind === "mock" ? TOOL_DEFINITIONS : TOOL_DEFINITIONS.filter( definition => LIVE_NATIVE_STAGE_A_TOOL_NAMES.has(definition.name) || (stageB && LIVE_NATIVE_STAGE_B_READ_TOOL_NAMES.has(definition.name)) || SERVER_LOCAL_TOOL_NAMES.has(definition.name) ); + if (toolAllowlist === undefined) return available; + + // Selection only narrows the existing execution surface. A configured name + // cannot enable a forbidden tool or a native mapping the backend hides. + const registered = new Set(TOOL_DEFINITIONS.map(definition => definition.name)); + for (const name of toolAllowlist) { + if (!registered.has(name)) { + throw new Error(`MASTERCAM_MCP_TOOLS contains an unknown or unregistered tool: ${name}`); + } + } + const selected = new Set(toolAllowlist); + return available.filter(definition => selected.has(definition.name)); } function envelope(result: ToolResult, schema: z.ZodType = ToolEnvelopeSchema) { @@ -159,10 +171,11 @@ function deadlineFor(category: ReturnType): number { export function createMcpServer( backend: Backend, profile: Profile = DEFAULT_PROFILE, - hardReadOnly = true + hardReadOnly = true, + toolAllowlist?: readonly string[] ) { const backendKind: "mock" | "live" = backend instanceof LiveBackend ? "live" : "mock"; - const definitions = advertisedToolDefinitions(backendKind); + const definitions = advertisedToolDefinitions(backendKind, toolAllowlist); const server = new McpServer( { name: "mastercam-mcp", version: VERSION }, { diff --git a/src/server.ts b/src/server.ts index ce9ff07..32aea1a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -37,7 +37,7 @@ const backend: Backend = }); const handle = serveStdio( - () => createMcpServer(backend, config.profile, config.hardReadOnly), + () => createMcpServer(backend, config.profile, config.hardReadOnly, config.toolAllowlist), { legacy: "serve", onerror: error => { diff --git a/test/integration/http-smoke.test.ts b/test/integration/http-smoke.test.ts index c8967e7..309299d 100644 --- a/test/integration/http-smoke.test.ts +++ b/test/integration/http-smoke.test.ts @@ -37,7 +37,7 @@ async function stopChild(child: ChildProcess): Promise { child.unref(); } -test("streamable http serves 2026-07-28 and keeps stateless legacy fallback", async () => { +async function checkHttp(toolAllowlist?: string): Promise { const child = spawn(process.execPath, [tsx, "src/http.ts"], { env: { ...process.env, @@ -45,7 +45,8 @@ test("streamable http serves 2026-07-28 and keeps stateless legacy fallback", as MASTERCAM_MCP_AUDIT: "0", MASTERCAM_MCP_PROFILE: "all", MASTERCAM_MCP_HARD_READ_ONLY: "0", - MASTERCAM_MCP_HTTP_PORT: String(PORT) + MASTERCAM_MCP_HTTP_PORT: String(PORT), + ...(toolAllowlist === undefined ? {} : { MASTERCAM_MCP_TOOLS: toolAllowlist }) }, stdio: ["ignore", "ignore", "pipe"] }); @@ -84,7 +85,15 @@ test("streamable http serves 2026-07-28 and keeps stateless legacy fallback", as assert.equal(client.getProtocolEra(), "modern"); const tools = await withTimeout(client.listTools(), 10_000, "HTTP tools/list"); - assert.ok(tools.tools.length >= 40, `expected full tool registry, got ${tools.tools.length}`); + if (toolAllowlist) { + assert.deepEqual(tools.tools.map(tool => tool.name), toolAllowlist.split(",")); + await assert.rejects( + () => client!.callTool({ name: "list_operations", arguments: {} }), + /not found|unknown|tool/i + ); + } else { + assert.ok(tools.tools.length >= 40, `expected full tool registry, got ${tools.tools.length}`); + } const status = await withTimeout( client.callTool({ name: "mastercam_status", arguments: {} }), 10_000, @@ -144,4 +153,8 @@ test("streamable http serves 2026-07-28 and keeps stateless legacy fallback", as } await stopChild(child); } -}); +} + +test("streamable http serves 2026-07-28 and keeps stateless legacy fallback", () => checkHttp()); +test("streamable http advertises and enforces a selected tool catalog", () => + checkHttp("mastercam_status,mastercam_help,discover_capabilities")); diff --git a/test/protocol.test.ts b/test/protocol.test.ts index 3653d0a..54076c9 100644 --- a/test/protocol.test.ts +++ b/test/protocol.test.ts @@ -151,6 +151,36 @@ test("unknown tool call fails with a typed MCP error (BUG-02 contract)", async ( }); }); +test("a selected stdio catalog limits discovery and rejects omitted tools", async () => { + const selected = ["mastercam_status", "mastercam_help", "discover_capabilities"]; + await withStdioClient(async client => { + const { tools } = await client.listTools(); + assert.deepEqual(tools.map(tool => tool.name), selected); + const result = await client.callTool({ name: "mastercam_status", arguments: {} }); + assert.equal(result.structuredContent?.ok, true); + + for (const name of ["mastercam_help", "discover_capabilities"]) { + const result = await client.callTool({ name, arguments: {} }); + const data = result.structuredContent?.data as { tools: { name: string }[] }; + assert.deepEqual(data.tools.map(tool => tool.name), selected); + } + await assert.rejects( + () => client.callTool({ name: "list_operations", arguments: {} }), + /not found|unknown|tool/i + ); + }, { MASTERCAM_MCP_TOOLS: selected.join(",") }); +}); + +test("an allowlisted mutation is still refused by hard read only", async () => { + await withStdioClient(async client => { + const result = await client.callTool({ + name: "apply_operation_parameter_preview", arguments: { approvalToken: "not-an-approval" } + }); + assert.equal(result.isError, true); + assert.equal((result.structuredContent?.error as { code: string }).code, "PROFILE_DENIED"); + }, { MASTERCAM_MCP_TOOLS: "apply_operation_parameter_preview", MASTERCAM_MCP_HARD_READ_ONLY: "1" }); +}); + test("tool call returns structuredContent plus text content (ARCH-02)", async () => { await withStdioClient(async client => { const result = await client.callTool({ name: "mastercam_status", arguments: {} }); diff --git a/test/tool-allowlist.test.ts b/test/tool-allowlist.test.ts new file mode 100644 index 0000000..452ff4c --- /dev/null +++ b/test/tool-allowlist.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ConfigSchema, loadConfig } from "../src/config.js"; +import { advertisedToolDefinitions } from "../src/mcp/create-server.js"; +import { TOOL_DEFINITIONS } from "../src/mcp/registry.js"; +import { allowed } from "../src/contracts.js"; + +test("an unset allowlist keeps the existing fixture catalog", () => { + assert.deepEqual(advertisedToolDefinitions("mock"), TOOL_DEFINITIONS); + assert.equal(ConfigSchema.parse({}).toolAllowlist, undefined); +}); + +test("an allowlist selects only requested tools in canonical order without duplicates", () => { + assert.deepEqual( + advertisedToolDefinitions("mock", ["calculate_thread_tap", "mastercam_status", "mastercam_status"]) + .map(tool => tool.name), + ["mastercam_status", "calculate_thread_tap"] + ); + assert.deepEqual(advertisedToolDefinitions("mock", []), []); +}); + +test("selection cannot advertise a native mapping hidden by the live backend", () => { + assert.deepEqual( + advertisedToolDefinitions("live", ["mastercam_status", "get_active_part", "apply_operation_parameter_preview"]) + .map(tool => tool.name), + ["mastercam_status"] + ); +}); + +test("unknown and deliberately unregistered tools fail instead of silently broadening selection", () => { + for (const name of ["calculate_thread_typo", "post_program", "regenerate_toolpath", "*"]) { + assert.throws(() => advertisedToolDefinitions("mock", [name]), /unknown or unregistered tool/); + } +}); + +test("selecting a write tool does not change the existing profile policy", () => { + const name = "apply_operation_parameter_preview"; + assert.equal(advertisedToolDefinitions("mock", [name])[0]?.name, name); + assert.equal(allowed(name, "read", false), false); + assert.equal(allowed(name, "write", true), false); + assert.equal(allowed(name, "write", false), true); +}); + +test("the environment trims names and keeps explicit empty selection distinct from unset", () => { + const previous = process.env.MASTERCAM_MCP_TOOLS; + try { + delete process.env.MASTERCAM_MCP_TOOLS; + assert.equal(loadConfig().toolAllowlist, undefined); + process.env.MASTERCAM_MCP_TOOLS = " mastercam_status, calculate_thread_tap,mastercam_status "; + assert.deepEqual(loadConfig().toolAllowlist, ["mastercam_status", "calculate_thread_tap", "mastercam_status"]); + process.env.MASTERCAM_MCP_TOOLS = " , "; + assert.deepEqual(loadConfig().toolAllowlist, []); + } finally { + if (previous === undefined) delete process.env.MASTERCAM_MCP_TOOLS; + else process.env.MASTERCAM_MCP_TOOLS = previous; + } +}); diff --git a/work/local-model-catalog.mjs b/work/local-model-catalog.mjs new file mode 100644 index 0000000..829a59a --- /dev/null +++ b/work/local-model-catalog.mjs @@ -0,0 +1,45 @@ +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +const selectedTools = ['mastercam_help', 'discover_capabilities', 'analyze_nc_program', 'calculate_thread_tap']; +const measurements = []; + +for (const selection of [undefined, selectedTools.join(',')]) { + const env = { + ...process.env, + MASTERCAM_MCP_BACKEND: 'mock', + MASTERCAM_MCP_AUDIT: '0', + MASTERCAM_MCP_PROFILE: 'read', + MASTERCAM_MCP_HARD_READ_ONLY: '1' + }; + delete env.MASTERCAM_MCP_TOOLS; + if (selection !== undefined) env.MASTERCAM_MCP_TOOLS = selection; + + const transport = new StdioClientTransport({ + command: process.execPath, args: ['dist/server.js'], env + }); + const client = new Client( + { name: 'local-catalog-measurement', version: '1.0' }, + { versionNegotiation: { mode: { pin: '2026-07-28' } } } + ); + try { + await client.connect(transport); + const { tools } = await client.listTools(); + if (selection !== undefined && JSON.stringify(tools.map(tool => tool.name)) !== JSON.stringify(selectedTools)) { + throw new Error('Selected catalog does not match the requested tool names'); + } + measurements.push({ + catalog: selection === undefined ? 'full' : 'selected', + toolCount: tools.length, + serializedToolBytes: Buffer.byteLength(JSON.stringify(tools), 'utf8') + }); + } finally { + await client.close(); + await transport.close(); + } +} + +console.log(JSON.stringify({ + backend: 'synthetic fixture', measurements, + note: 'Measures the tools/list payload only; no language model, GPU, or live Mastercam was tested.' +}, null, 2));