Skip to content
Draft
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
74 changes: 74 additions & 0 deletions docs/LOCAL-MODELS.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 18 additions & 5 deletions src/mcp/create-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -159,10 +171,11 @@ function deadlineFor(category: ReturnType<typeof categoryOf>): 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 },
{
Expand Down
2 changes: 1 addition & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
21 changes: 17 additions & 4 deletions test/integration/http-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,16 @@ async function stopChild(child: ChildProcess): Promise<void> {
child.unref();
}

test("streamable http serves 2026-07-28 and keeps stateless legacy fallback", async () => {
async function checkHttp(toolAllowlist?: string): Promise<void> {
const child = spawn(process.execPath, [tsx, "src/http.ts"], {
env: {
...process.env,
MASTERCAM_MCP_BACKEND: "mock",
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"]
});
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"));
30 changes: 30 additions & 0 deletions test/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} });
Expand Down
57 changes: 57 additions & 0 deletions test/tool-allowlist.test.ts
Original file line number Diff line number Diff line change
@@ -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;
}
});
45 changes: 45 additions & 0 deletions work/local-model-catalog.mjs
Original file line number Diff line number Diff line change
@@ -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));