metadata
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user