dist-mcp / mcp / mcp.test.js
dist-mcp / mcp / mcp.test.js
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 = ["analyse_image", "detect_object", "annotate_image"];
/** 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, path) {
if (Array.isArray(node)) {
node.forEach((item, index) => assertNoArrayTypeNodes(item, `${path}[${index}]`));
return;
}
if (node === null || typeof node !== "object")
return;
const record = node;
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 analyse_image/detect_object/annotate_image over stdio", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "analyse-image-mcp-"));
const client = new Client({ name: "analyse-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 (see start-mcp.mjs's own comment).
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_BIONIC defaults to "true" — scratchpadFolder is required for all three tools.
// analyse_image's own `targets` field is itself required (unlike detect_object/annotate_image's
// optional targets, which auto-select when there is exactly one image).
for (const name of TOOL_NAMES) {
const tool = tools.find((t) => t.name === name);
const expectedRequired = name === "analyse_image" ? ["scratchpadFolder", "targets"] : ["scratchpadFolder"];
assert.deepEqual(tool?.inputSchema.required, expectedRequired, `${name} should require scratchpadFolder`);
assert.equal(tool?.inputSchema.additionalProperties, false, `${name} schema should be strict`);
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: "detect_object", 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: "analyse_image", 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: "detect_object", arguments: { scratchpadFolder: "8x", targets: ["a1"] } });
assert.equal(attachmentNotation.isError, true);
// resources/list and prompts/list must return an empty list, not "Method not found" (-32601).
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_BIONIC=false, scratchpadFolder is optional in the exposed schema", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "analyse-image-mcp-"));
const client = new Client({ name: "analyse-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();
for (const name of TOOL_NAMES) {
const tool = tools.find((t) => t.name === name);
const expectedRequired = name === "analyse_image" ? ["targets"] : [];
assert.deepEqual(tool?.inputSchema.required, expectedRequired, `${name} should not require scratchpadFolder`);
}
}
finally {
await client.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 = ["analyse_image", "detect_object", "annotate_image"];
/** 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, path) {
if (Array.isArray(node)) {
node.forEach((item, index) => assertNoArrayTypeNodes(item, `${path}[${index}]`));
return;
}
if (node === null || typeof node !== "object")
return;
const record = node;
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 analyse_image/detect_object/annotate_image over stdio", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "analyse-image-mcp-"));
const client = new Client({ name: "analyse-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 (see start-mcp.mjs's own comment).
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_BIONIC defaults to "true" — scratchpadFolder is required for all three tools.
// analyse_image's own `targets` field is itself required (unlike detect_object/annotate_image's
// optional targets, which auto-select when there is exactly one image).
for (const name of TOOL_NAMES) {
const tool = tools.find((t) => t.name === name);
const expectedRequired = name === "analyse_image" ? ["scratchpadFolder", "targets"] : ["scratchpadFolder"];
assert.deepEqual(tool?.inputSchema.required, expectedRequired, `${name} should require scratchpadFolder`);
assert.equal(tool?.inputSchema.additionalProperties, false, `${name} schema should be strict`);
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: "detect_object", 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: "analyse_image", 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: "detect_object", arguments: { scratchpadFolder: "8x", targets: ["a1"] } });
assert.equal(attachmentNotation.isError, true);
// resources/list and prompts/list must return an empty list, not "Method not found" (-32601).
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_BIONIC=false, scratchpadFolder is optional in the exposed schema", async () => {
const baseDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "analyse-image-mcp-"));
const client = new Client({ name: "analyse-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();
for (const name of TOOL_NAMES) {
const tool = tools.find((t) => t.name === name);
const expectedRequired = name === "analyse_image" ? ["targets"] : [];
assert.deepEqual(tool?.inputSchema.required, expectedRequired, `${name} should not require scratchpadFolder`);
}
}
finally {
await client.close();
await fs.rm(baseDirectory, { recursive: true, force: true });
}
});