src / mcp / mcp.test.ts
src / mcp / mcp.test.ts
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const TOOL_NAMES = ["crop", "mask", "zoom-in", "inpaint", "outpaint"];
/** Several MCP clients reject/silently drop a JSON-Schema node whose `type` is an array — every such node must have been rewritten into `anyOf` branches (see index.ts's splitTypeArrayUnions()). */
function assertNoArrayTypeNodes(node: unknown, path: string): void {
if (Array.isArray(node)) {
node.forEach((item, index) => assertNoArrayTypeNodes(item, `${path}[${index}]`));
return;
}
if (node === null || typeof node !== "object") return;
const record = node as Record<string, unknown>;
assert.ok(!Array.isArray(record.type), `${path}.type must not be an array, found ${JSON.stringify(record.type)}`);
for (const [key, value] of Object.entries(record)) {
assertNoArrayTypeNodes(value, `${path}.${key}`);
}
}
test("the built MCP server exposes crop/mask/zoom-in/inpaint/outpaint over stdio", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "process-image-mcp-"));
const client = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
try {
// Spawn via start-mcp.mjs, not dist-mcp/mcp/index.js directly: this keeps
// process.argv[1] at the project root, matching how Bionic invokes it
// (see start-mcp.mjs's own comment for why this matters).
const entryPoint = new URL("../../start-mcp.mjs", import.meta.url).pathname;
await client.connect(new StdioClientTransport({
command: process.execPath,
args: [entryPoint],
env: { CHAT_WORKING_DIRECTORIES: baseDirectory },
stderr: "pipe",
}));
const { tools } = await client.listTools();
assert.deepEqual(tools.map((tool) => tool.name).sort(), [...TOOL_NAMES].sort());
// MCP_MADE_FOR defaults to "bionic" — scratchpadFolder is required there.
for (const name of TOOL_NAMES) {
const tool = tools.find((t) => t.name === name);
assert.deepEqual(tool?.inputSchema.required, ["scratchpadFolder"], `${name} should require scratchpadFolder`);
assert.equal(tool?.inputSchema.additionalProperties, false, `${name} schema should be strict`);
assert.equal("quality" in ((tool?.inputSchema.properties as Record<string, unknown>) ?? {}), false, `${name} should hide quality`);
assertNoArrayTypeNodes(tool?.inputSchema, `${name}.inputSchema`);
}
// A missing scratchpad folder must fail fast, before any handler call, with a clear cause.
const missingScratchpad = await client.callTool({ name: "crop", arguments: { scratchpadFolder: "missing-session" } });
assert.equal(missingScratchpad.isError, true);
// An omitted scratchpadFolder must also fail fast when the bionic preset is active (the default).
const omittedScratchpad = await client.callTool({ name: "crop", arguments: {} });
assert.equal(omittedScratchpad.isError, true);
// aN must be rejected before the handler is ever invoked.
await fs.mkdir(path.join(baseDirectory, "8x"));
const attachmentNotation = await client.callTool({ name: "crop", arguments: { scratchpadFolder: "8x", canvas: "a1" } });
assert.equal(attachmentNotation.isError, true);
// resources/list and prompts/list must return an empty list, not "Method not found" (-32601)
// — no bridge registers any resource/prompt, but MCP Inspector and other clients query both
// right after initialize (see bridgeServer.ts's capabilities.{resources,prompts} fix).
assert.deepEqual((await client.listResources()).resources, []);
assert.deepEqual((await client.listPrompts()).prompts, []);
} finally {
await client.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});
test("with MCP_MADE_FOR=generic, scratchpadFolder is optional in the exposed schema", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "process-image-mcp-"));
const client = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
try {
const entryPoint = new URL("../../start-mcp.mjs", import.meta.url).pathname;
await client.connect(new StdioClientTransport({
command: process.execPath,
args: [entryPoint],
env: { CHAT_WORKING_DIRECTORIES: baseDirectory, MCP_MADE_FOR: "generic" },
stderr: "pipe",
}));
const { tools } = await client.listTools();
for (const name of TOOL_NAMES) {
const tool = tools.find((t) => t.name === name);
assert.deepEqual(tool?.inputSchema.required, [], `${name} should not require scratchpadFolder`);
}
} finally {
await client.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});
test("the deprecated MCP_MADE_FOR_BIONIC=false boolean still resolves to the generic preset", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "process-image-mcp-"));
const client = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
try {
const entryPoint = new URL("../../start-mcp.mjs", import.meta.url).pathname;
await client.connect(new StdioClientTransport({
command: process.execPath,
args: [entryPoint],
env: { CHAT_WORKING_DIRECTORIES: baseDirectory, MCP_MADE_FOR_BIONIC: "false" },
stderr: "pipe",
}));
const { tools } = await client.listTools();
const crop = tools.find((t) => t.name === "crop");
assert.deepEqual(crop?.inputSchema.required, []);
} finally {
await client.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});
test("bionic/generic: no field description (canvas or detectLabel) ever mentions an aN example", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "process-image-mcp-"));
const client = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
try {
const entryPoint = new URL("../../start-mcp.mjs", import.meta.url).pathname;
await client.connect(new StdioClientTransport({
command: process.execPath,
args: [entryPoint],
env: { CHAT_WORKING_DIRECTORIES: baseDirectory },
stderr: "pipe",
}));
const { tools } = await client.listTools();
for (const name of TOOL_NAMES) {
const tool = tools.find((t) => t.name === name);
const properties = (tool?.inputSchema.properties ?? {}) as Record<string, { description?: string }>;
const detectLabelDescription = properties.detectLabel?.description ?? "";
assert.doesNotMatch(detectLabelDescription, /'a1'/, `${name}'s detectLabel description must not mention 'a1' for bionic`);
assert.doesNotMatch(detectLabelDescription, /Pass the literal "aN"/, `${name}'s detectLabel description must never get the attachment-inventory hint`);
const canvasDescription = properties.canvas?.description ?? "";
assert.doesNotMatch(canvasDescription, /'a1'/, `${name}'s canvas description must not mention 'a1' for bionic`);
assert.doesNotMatch(tool?.description ?? "", /\be\.g\. a1\b|\ba1, v2\b/, `${name}'s top-level description must not mention an aN example for bionic`);
}
} finally {
await client.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});
test("with MCP_MADE_FOR=unsloth, canvas's description gets the attachment-inventory hint, detectLabel's keeps its 'a1' example but never gets the hint", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "process-image-mcp-"));
const client = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
try {
const entryPoint = new URL("../../start-mcp.mjs", import.meta.url).pathname;
await client.connect(new StdioClientTransport({
command: process.execPath,
args: [entryPoint],
env: { CHAT_WORKING_DIRECTORIES: baseDirectory, MCP_MADE_FOR: "unsloth" },
stderr: "pipe",
}));
const { tools } = await client.listTools();
for (const name of TOOL_NAMES) {
const tool = tools.find((t) => t.name === name);
const properties = (tool?.inputSchema.properties ?? {}) as Record<string, { description?: string }>;
const canvasDescription = properties.canvas?.description ?? "";
assert.match(canvasDescription, /Pass the literal "aN"/, `${name}'s canvas description should get the attachment-inventory hint for unsloth`);
const detectLabelDescription = properties.detectLabel?.description ?? "";
assert.doesNotMatch(detectLabelDescription, /Pass the literal "aN"/, `${name}'s detectLabel description must not get the attachment-inventory hint`);
assert.match(detectLabelDescription, /\(e\.g\. 'a1'\)/, `${name}'s detectLabel description should keep its 'a1' example untouched for unsloth`);
assert.match(tool?.description ?? "", /e\.g\. a1|a1, v2/, `${name}'s top-level description should keep its aN example for unsloth`);
}
} finally {
await client.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});
test("bionic/unsloth mention a concrete tool + example for scratchpadFolder; generic never names a specific tool", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "process-image-mcp-"));
const bionicClient = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
const unslothClient = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
const genericClient = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
try {
const entryPoint = new URL("../../start-mcp.mjs", import.meta.url).pathname;
await bionicClient.connect(new StdioClientTransport({ command: process.execPath, args: [entryPoint], env: { CHAT_WORKING_DIRECTORIES: baseDirectory }, stderr: "pipe" }));
await unslothClient.connect(new StdioClientTransport({ command: process.execPath, args: [entryPoint], env: { CHAT_WORKING_DIRECTORIES: baseDirectory, MCP_MADE_FOR: "unsloth" }, stderr: "pipe" }));
await genericClient.connect(new StdioClientTransport({ command: process.execPath, args: [entryPoint], env: { CHAT_WORKING_DIRECTORIES: baseDirectory, MCP_MADE_FOR: "generic" }, stderr: "pipe" }));
const bionicCrop = (await bionicClient.listTools()).tools.find((t) => t.name === "crop");
assert.match(bionicCrop?.description ?? "", /get_scratchpad_folder/);
assert.match(bionicCrop?.description ?? "", /e\.g\. /);
assert.match((bionicCrop?.inputSchema.properties as any)?.scratchpadFolder?.description ?? "", /get_scratchpad_folder/);
const unslothCrop = (await unslothClient.listTools()).tools.find((t) => t.name === "crop");
assert.match(unslothCrop?.description ?? "", /cat \.unsloth_sandbox/);
assert.match(unslothCrop?.description ?? "", /__LOCALID_htL7iV3/);
assert.match((unslothCrop?.inputSchema.properties as any)?.scratchpadFolder?.description ?? "", /cat \.unsloth_sandbox/);
assert.doesNotMatch((unslothCrop?.inputSchema.properties as any)?.scratchpadFolder?.description ?? "", /get_scratchpad_folder/);
const genericCrop = (await genericClient.listTools()).tools.find((t) => t.name === "crop");
assert.doesNotMatch(genericCrop?.description ?? "", /get_scratchpad_folder|cat \.unsloth_sandbox/, "generic must never name a specific tool");
} finally {
await bionicClient.close();
await unslothClient.close();
await genericClient.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
const TOOL_NAMES = ["crop", "mask", "zoom-in", "inpaint", "outpaint"];
/** Several MCP clients reject/silently drop a JSON-Schema node whose `type` is an array — every such node must have been rewritten into `anyOf` branches (see index.ts's splitTypeArrayUnions()). */
function assertNoArrayTypeNodes(node: unknown, path: string): void {
if (Array.isArray(node)) {
node.forEach((item, index) => assertNoArrayTypeNodes(item, `${path}[${index}]`));
return;
}
if (node === null || typeof node !== "object") return;
const record = node as Record<string, unknown>;
assert.ok(!Array.isArray(record.type), `${path}.type must not be an array, found ${JSON.stringify(record.type)}`);
for (const [key, value] of Object.entries(record)) {
assertNoArrayTypeNodes(value, `${path}.${key}`);
}
}
test("the built MCP server exposes crop/mask/zoom-in/inpaint/outpaint over stdio", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "process-image-mcp-"));
const client = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
try {
// Spawn via start-mcp.mjs, not dist-mcp/mcp/index.js directly: this keeps
// process.argv[1] at the project root, matching how Bionic invokes it
// (see start-mcp.mjs's own comment for why this matters).
const entryPoint = new URL("../../start-mcp.mjs", import.meta.url).pathname;
await client.connect(new StdioClientTransport({
command: process.execPath,
args: [entryPoint],
env: { CHAT_WORKING_DIRECTORIES: baseDirectory },
stderr: "pipe",
}));
const { tools } = await client.listTools();
assert.deepEqual(tools.map((tool) => tool.name).sort(), [...TOOL_NAMES].sort());
// MCP_MADE_FOR defaults to "bionic" — scratchpadFolder is required there.
for (const name of TOOL_NAMES) {
const tool = tools.find((t) => t.name === name);
assert.deepEqual(tool?.inputSchema.required, ["scratchpadFolder"], `${name} should require scratchpadFolder`);
assert.equal(tool?.inputSchema.additionalProperties, false, `${name} schema should be strict`);
assert.equal("quality" in ((tool?.inputSchema.properties as Record<string, unknown>) ?? {}), false, `${name} should hide quality`);
assertNoArrayTypeNodes(tool?.inputSchema, `${name}.inputSchema`);
}
// A missing scratchpad folder must fail fast, before any handler call, with a clear cause.
const missingScratchpad = await client.callTool({ name: "crop", arguments: { scratchpadFolder: "missing-session" } });
assert.equal(missingScratchpad.isError, true);
// An omitted scratchpadFolder must also fail fast when the bionic preset is active (the default).
const omittedScratchpad = await client.callTool({ name: "crop", arguments: {} });
assert.equal(omittedScratchpad.isError, true);
// aN must be rejected before the handler is ever invoked.
await fs.mkdir(path.join(baseDirectory, "8x"));
const attachmentNotation = await client.callTool({ name: "crop", arguments: { scratchpadFolder: "8x", canvas: "a1" } });
assert.equal(attachmentNotation.isError, true);
// resources/list and prompts/list must return an empty list, not "Method not found" (-32601)
// — no bridge registers any resource/prompt, but MCP Inspector and other clients query both
// right after initialize (see bridgeServer.ts's capabilities.{resources,prompts} fix).
assert.deepEqual((await client.listResources()).resources, []);
assert.deepEqual((await client.listPrompts()).prompts, []);
} finally {
await client.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});
test("with MCP_MADE_FOR=generic, scratchpadFolder is optional in the exposed schema", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "process-image-mcp-"));
const client = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
try {
const entryPoint = new URL("../../start-mcp.mjs", import.meta.url).pathname;
await client.connect(new StdioClientTransport({
command: process.execPath,
args: [entryPoint],
env: { CHAT_WORKING_DIRECTORIES: baseDirectory, MCP_MADE_FOR: "generic" },
stderr: "pipe",
}));
const { tools } = await client.listTools();
for (const name of TOOL_NAMES) {
const tool = tools.find((t) => t.name === name);
assert.deepEqual(tool?.inputSchema.required, [], `${name} should not require scratchpadFolder`);
}
} finally {
await client.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});
test("the deprecated MCP_MADE_FOR_BIONIC=false boolean still resolves to the generic preset", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "process-image-mcp-"));
const client = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
try {
const entryPoint = new URL("../../start-mcp.mjs", import.meta.url).pathname;
await client.connect(new StdioClientTransport({
command: process.execPath,
args: [entryPoint],
env: { CHAT_WORKING_DIRECTORIES: baseDirectory, MCP_MADE_FOR_BIONIC: "false" },
stderr: "pipe",
}));
const { tools } = await client.listTools();
const crop = tools.find((t) => t.name === "crop");
assert.deepEqual(crop?.inputSchema.required, []);
} finally {
await client.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});
test("bionic/generic: no field description (canvas or detectLabel) ever mentions an aN example", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "process-image-mcp-"));
const client = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
try {
const entryPoint = new URL("../../start-mcp.mjs", import.meta.url).pathname;
await client.connect(new StdioClientTransport({
command: process.execPath,
args: [entryPoint],
env: { CHAT_WORKING_DIRECTORIES: baseDirectory },
stderr: "pipe",
}));
const { tools } = await client.listTools();
for (const name of TOOL_NAMES) {
const tool = tools.find((t) => t.name === name);
const properties = (tool?.inputSchema.properties ?? {}) as Record<string, { description?: string }>;
const detectLabelDescription = properties.detectLabel?.description ?? "";
assert.doesNotMatch(detectLabelDescription, /'a1'/, `${name}'s detectLabel description must not mention 'a1' for bionic`);
assert.doesNotMatch(detectLabelDescription, /Pass the literal "aN"/, `${name}'s detectLabel description must never get the attachment-inventory hint`);
const canvasDescription = properties.canvas?.description ?? "";
assert.doesNotMatch(canvasDescription, /'a1'/, `${name}'s canvas description must not mention 'a1' for bionic`);
assert.doesNotMatch(tool?.description ?? "", /\be\.g\. a1\b|\ba1, v2\b/, `${name}'s top-level description must not mention an aN example for bionic`);
}
} finally {
await client.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});
test("with MCP_MADE_FOR=unsloth, canvas's description gets the attachment-inventory hint, detectLabel's keeps its 'a1' example but never gets the hint", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "process-image-mcp-"));
const client = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
try {
const entryPoint = new URL("../../start-mcp.mjs", import.meta.url).pathname;
await client.connect(new StdioClientTransport({
command: process.execPath,
args: [entryPoint],
env: { CHAT_WORKING_DIRECTORIES: baseDirectory, MCP_MADE_FOR: "unsloth" },
stderr: "pipe",
}));
const { tools } = await client.listTools();
for (const name of TOOL_NAMES) {
const tool = tools.find((t) => t.name === name);
const properties = (tool?.inputSchema.properties ?? {}) as Record<string, { description?: string }>;
const canvasDescription = properties.canvas?.description ?? "";
assert.match(canvasDescription, /Pass the literal "aN"/, `${name}'s canvas description should get the attachment-inventory hint for unsloth`);
const detectLabelDescription = properties.detectLabel?.description ?? "";
assert.doesNotMatch(detectLabelDescription, /Pass the literal "aN"/, `${name}'s detectLabel description must not get the attachment-inventory hint`);
assert.match(detectLabelDescription, /\(e\.g\. 'a1'\)/, `${name}'s detectLabel description should keep its 'a1' example untouched for unsloth`);
assert.match(tool?.description ?? "", /e\.g\. a1|a1, v2/, `${name}'s top-level description should keep its aN example for unsloth`);
}
} finally {
await client.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});
test("bionic/unsloth mention a concrete tool + example for scratchpadFolder; generic never names a specific tool", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "process-image-mcp-"));
const bionicClient = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
const unslothClient = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
const genericClient = new Client({ name: "process-image-mcp-test", version: "0.1.0" });
try {
const entryPoint = new URL("../../start-mcp.mjs", import.meta.url).pathname;
await bionicClient.connect(new StdioClientTransport({ command: process.execPath, args: [entryPoint], env: { CHAT_WORKING_DIRECTORIES: baseDirectory }, stderr: "pipe" }));
await unslothClient.connect(new StdioClientTransport({ command: process.execPath, args: [entryPoint], env: { CHAT_WORKING_DIRECTORIES: baseDirectory, MCP_MADE_FOR: "unsloth" }, stderr: "pipe" }));
await genericClient.connect(new StdioClientTransport({ command: process.execPath, args: [entryPoint], env: { CHAT_WORKING_DIRECTORIES: baseDirectory, MCP_MADE_FOR: "generic" }, stderr: "pipe" }));
const bionicCrop = (await bionicClient.listTools()).tools.find((t) => t.name === "crop");
assert.match(bionicCrop?.description ?? "", /get_scratchpad_folder/);
assert.match(bionicCrop?.description ?? "", /e\.g\. /);
assert.match((bionicCrop?.inputSchema.properties as any)?.scratchpadFolder?.description ?? "", /get_scratchpad_folder/);
const unslothCrop = (await unslothClient.listTools()).tools.find((t) => t.name === "crop");
assert.match(unslothCrop?.description ?? "", /cat \.unsloth_sandbox/);
assert.match(unslothCrop?.description ?? "", /__LOCALID_htL7iV3/);
assert.match((unslothCrop?.inputSchema.properties as any)?.scratchpadFolder?.description ?? "", /cat \.unsloth_sandbox/);
assert.doesNotMatch((unslothCrop?.inputSchema.properties as any)?.scratchpadFolder?.description ?? "", /get_scratchpad_folder/);
const genericCrop = (await genericClient.listTools()).tools.find((t) => t.name === "crop");
assert.doesNotMatch(genericCrop?.description ?? "", /get_scratchpad_folder|cat \.unsloth_sandbox/, "generic must never name a specific tool");
} finally {
await bionicClient.close();
await unslothClient.close();
await genericClient.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});