This commit is contained in:
kst
2026-08-26 03:33:29 +09:00
parent 400f1d3e91
commit 4fc70e9697
10 changed files with 610 additions and 156 deletions
+41 -1
View File
@@ -38,6 +38,29 @@ const ALL_TOOLS = [
type ToolName = (typeof ALL_TOOLS)[number];
type ToolResult = Awaited<ReturnType<Client["callTool"]>>;
const EXPECTED_ANNOTATIONS = {
apply_patch: [false, true, false, false],
chmod_path: [false, true, true, false],
copy_path: [false, true, true, false],
download_file: [true, false, true, false],
exec_command: [false, true, false, true],
hash_file: [true, false, true, false],
list_directory: [true, false, true, false],
list_processes: [true, false, true, false],
make_directory: [false, false, true, false],
move_path: [false, true, true, false],
read_file: [true, false, true, false],
read_process: [true, false, true, false],
remove_path: [false, true, true, false],
replace_in_file: [false, true, false, false],
run_script: [false, true, false, true],
stat_path: [true, false, true, false],
terminate_process: [false, true, false, false],
upload_file: [false, true, true, false],
write_file: [false, true, false, false],
write_stdin: [false, true, false, true],
} as const satisfies Record<ToolName, readonly [boolean, boolean, boolean, boolean]>;
function structured(result: ToolResult): Record<string, unknown> {
return (result.structuredContent ?? {}) as Record<string, unknown>;
}
@@ -159,7 +182,14 @@ describe.sequential("all registered MCP tools", () => {
expect(listed.tools.map((tool) => tool.name).sort()).toEqual([...ALL_TOOLS]);
for (const tool of listed.tools) {
expect(tool.inputSchema.type).toBe("object");
expect(tool.annotations).toBeDefined();
const [readOnlyHint, destructiveHint, idempotentHint, openWorldHint] =
EXPECTED_ANNOTATIONS[tool.name as ToolName];
expect(tool.annotations, `${tool.name} annotations`).toEqual({
readOnlyHint,
destructiveHint,
idempotentHint,
openWorldHint,
});
}
});
@@ -758,6 +788,16 @@ describe.sequential("all registered MCP tools", () => {
path: "transfer/move-destination.txt",
cwd: testRoot,
})).toMatchObject({ content: "move-source" });
expect(await callError("move_path", {
sourcePath: "transfer/move-source.txt",
destinationPath: "transfer/move-destination.txt",
cwd: testRoot,
overwrite: true,
})).toMatch(/ENOENT|no such file/i);
expect(await callOk("read_file", {
path: "transfer/move-destination.txt",
cwd: testRoot,
})).toMatchObject({ content: "move-source" });
expect(await callOk("move_path", {
sourcePath: "transfer/move-destination.txt",
destinationPath: "transfer/move-destination.txt",
+24
View File
@@ -132,4 +132,28 @@ describe("ProcessManager", () => {
expect(first.output + second.output).toBe(expected);
expect(first.output + second.output).not.toContain("");
});
it("lists processes without pruning and expires completed sessions independently", async () => {
manager = new ProcessManager({
maxRetainedOutputBytes: 1024 * 1024,
processRetentionMs: 500,
maxProcesses: 16,
defaultMaxOutputBytes: 1024 * 1024,
});
const sessionId = manager.start({
executable: "/bin/bash",
args: ["-c", "true"],
commandForDisplay: "true",
cwd: process.cwd(),
});
await manager.waitForExit(sessionId, 2000);
expect(manager.list()).toEqual(
expect.arrayContaining([expect.objectContaining({ sessionId, running: false })]),
);
await expect(manager.read(sessionId)).resolves.toMatchObject({ running: false });
await new Promise((resolve) => setTimeout(resolve, 600));
await expect(manager.read(sessionId)).rejects.toThrow("Unknown process session");
});
});
+124
View File
@@ -0,0 +1,124 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it } from "vitest";
import { loadConfig } from "../src/config.js";
import { createMcpServer, createServices } from "../src/mcp-server.js";
async function withClient<T>(
env: NodeJS.ProcessEnv,
operation: (client: Client) => Promise<T> | T,
): Promise<T> {
const config = loadConfig(env, "/tmp");
const server = createMcpServer(config, createServices(config));
const client = new Client({ name: "tool-metadata-test", version: "1.0.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
return await operation(client);
} finally {
await client.close();
await server.close();
}
}
async function listTools(env: NodeJS.ProcessEnv) {
return withClient(env, async (client) => (await client.listTools()).tools);
}
describe("tool authentication metadata", () => {
it("advertises the OAuth scope on every tool when OAuth is enabled", async () => {
const tools = await listTools({
MCP_OAUTH_ENABLED: "true",
MCP_OAUTH_APPROVAL_KEY: "approval-key",
MCP_PUBLIC_URL: "https://mcp.example.com",
MCP_OAUTH_ISSUER: "https://mcp.example.com",
MCP_OAUTH_RESOURCE: "https://mcp.example.com/mcp",
});
expect(tools).toHaveLength(20);
for (const tool of tools) {
expect(tool._meta, tool.name).toEqual({
securitySchemes: [{ type: "oauth2", scopes: ["mcp:tools"] }],
});
}
});
it("does not infer noauth from the internal authentication bypass", async () => {
const tools = await listTools({ MCP_ALLOW_NO_AUTH: "true" });
expect(tools).toHaveLength(20);
for (const tool of tools) {
expect(tool._meta, tool.name).toBeUndefined();
}
});
it("does not mislabel static bearer authentication as noauth or OAuth", async () => {
const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" });
expect(tools).toHaveLength(20);
for (const tool of tools) {
expect(tool._meta, tool.name).toBeUndefined();
}
});
});
describe("client-facing metadata accuracy", () => {
it("does not mislabel the MCP service origin as an implementation website", async () => {
const serverInfo = await withClient(
{
MCP_AUTH_TOKEN: "static-secret",
MCP_PUBLIC_URL: "https://mcp.example.com",
},
(client) => client.getServerVersion(),
);
expect(serverInfo).toMatchObject({ name: "cokacremote", version: "0.1.0" });
expect(serverInfo?.websiteUrl).not.toBe("https://mcp.example.com");
});
it("describes every tool and every input field", async () => {
const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" });
expect(tools).toHaveLength(20);
for (const tool of tools) {
expect(tool.title?.trim().length, tool.name).toBeGreaterThan(0);
expect(tool.description?.trim().length, tool.name).toBeGreaterThan(0);
for (const [fieldName, schema] of Object.entries(
tool.inputSchema.properties ?? {},
)) {
const description = (schema as { description?: unknown }).description;
expect(
typeof description === "string" ? description.trim().length : 0,
`${tool.name}.${fieldName}`,
).toBeGreaterThan(0);
}
}
});
it("describes process timing, session, polling, and escalation semantics exactly", async () => {
const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" });
const byName = new Map(tools.map((tool) => [tool.name, tool]));
const execCommand = byName.get("exec_command")!;
const runScript = byName.get("run_script")!;
const writeStdin = byName.get("write_stdin")!;
const terminateProcess = byName.get("terminate_process")!;
expect(execCommand.description).toContain("always returns a process session ID");
expect(writeStdin.description).toContain("greater than afterSeq");
expect(terminateProcess.description).toContain("SIGINT and SIGTERM");
for (const tool of [execCommand, runScript]) {
const properties = tool.inputSchema.properties as Record<
string,
{ description?: string }
>;
expect(properties.timeoutMs?.description).toContain("sending SIGTERM");
expect(properties.timeoutMs?.description).toContain("sent SIGKILL");
expect(properties.timeoutMs?.description).not.toContain("Maximum runtime");
expect(properties.yieldTimeMs?.description).toContain("wait for");
expect(properties.yieldTimeMs?.description).toContain("to exit");
}
});
});