rebase generation
This commit is contained in:
185
tools/mcp/lib/mcp-server.mjs
Normal file
185
tools/mcp/lib/mcp-server.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Minimal Model Context Protocol (MCP) server over stdio.
|
||||
*
|
||||
* Implements just enough of the MCP JSON-RPC 2.0 surface to expose
|
||||
* a static list of read-only validation tools to a Claude Code client:
|
||||
* - initialize
|
||||
* - tools/list
|
||||
* - tools/call
|
||||
*
|
||||
* We intentionally avoid the @modelcontextprotocol/sdk dependency here —
|
||||
* the KIS-TOiR optimization policy forbids adding new npm packages for
|
||||
* these lightweight validators, so we speak raw JSON-RPC on stdin/stdout
|
||||
* with Node built-ins only.
|
||||
*
|
||||
* Tool contract:
|
||||
* {
|
||||
* name: string,
|
||||
* description: string,
|
||||
* inputSchema?: JSONSchema,
|
||||
* execute: (args: object) => Promise<{ success: boolean, result?, error? }>
|
||||
* }
|
||||
*
|
||||
* execute() must be read-only, must resolve quickly (<500ms target), and
|
||||
* must NEVER throw — wrap failures in { success: false, error }.
|
||||
*/
|
||||
|
||||
import readline from "node:readline";
|
||||
|
||||
const PROTOCOL_VERSION = "2024-11-05";
|
||||
|
||||
// JSON-RPC 2.0 constants
|
||||
const JSONRPC_VERSION = "2.0";
|
||||
const FIELD_JSONRPC = "jsonrpc";
|
||||
const FIELD_ID = "id";
|
||||
const FIELD_METHOD = "method";
|
||||
const FIELD_PARAMS = "params";
|
||||
const FIELD_RESULT = "result";
|
||||
const FIELD_ERROR = "error";
|
||||
const FIELD_CODE = "code";
|
||||
const FIELD_MESSAGE = "message";
|
||||
const FIELD_DATA = "data";
|
||||
const CONTENT_TYPE_TEXT = "text";
|
||||
const FIELD_CONTENT = "content";
|
||||
const FIELD_TYPE = "type";
|
||||
const FIELD_IS_ERROR = "isError";
|
||||
|
||||
function write(obj) {
|
||||
// MCP framing over stdio is line-delimited JSON (one JSON object per line).
|
||||
process.stdout.write(JSON.stringify(obj) + "\n");
|
||||
}
|
||||
|
||||
function ok(id, result) {
|
||||
write({ [FIELD_JSONRPC]: JSONRPC_VERSION, [FIELD_ID]: id, [FIELD_RESULT]: result });
|
||||
}
|
||||
|
||||
function fail(id, code, message, data) {
|
||||
write({
|
||||
[FIELD_JSONRPC]: JSONRPC_VERSION,
|
||||
[FIELD_ID]: id,
|
||||
[FIELD_ERROR]: { [FIELD_CODE]: code, [FIELD_MESSAGE]: message, [FIELD_DATA]: data },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an MCP server. Blocks on stdin until the parent process closes it.
|
||||
*/
|
||||
export function runMCPServer({ name, version = "0.1.0", tools }) {
|
||||
const byName = new Map(tools.map((t) => [t.name, t]));
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
// Log to stderr so we don't corrupt the stdout JSON-RPC channel.
|
||||
const log = (level, msg) =>
|
||||
process.stderr.write(`[${level}] ${name}: ${msg}\n`);
|
||||
|
||||
log("INFO", `starting (tools=${tools.length})`);
|
||||
|
||||
rl.on("line", async (line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
let req;
|
||||
try {
|
||||
req = JSON.parse(trimmed);
|
||||
} catch (e) {
|
||||
log("WARN", `invalid JSON on stdin: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, method, params } = req;
|
||||
|
||||
// Notifications have no id and expect no response.
|
||||
const isNotification = id === undefined || id === null;
|
||||
|
||||
try {
|
||||
if (method === "initialize") {
|
||||
ok(id, {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name, version },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "tools/list") {
|
||||
ok(id, {
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema ?? {
|
||||
[FIELD_TYPE]: "object",
|
||||
properties: {},
|
||||
additionalProperties: true,
|
||||
},
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "tools/call") {
|
||||
const toolName = params?.name;
|
||||
const tool = byName.get(toolName);
|
||||
if (!tool) {
|
||||
fail(id, -32601, `Unknown tool: ${toolName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
// Enforce per-tool timeout: tools MUST resolve within 2000ms.
|
||||
// Prevents hung tools from blocking the orchestrator.
|
||||
const toolTimeout = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("Tool execution timeout (>2000ms)")),
|
||||
2000
|
||||
)
|
||||
);
|
||||
result = await Promise.race([
|
||||
tool.execute(params?.arguments ?? {}),
|
||||
toolTimeout,
|
||||
]);
|
||||
} catch (e) {
|
||||
// Capture all errors (timeout, execution, etc.) uniformly.
|
||||
result = { success: false, error: e?.message ?? String(e) };
|
||||
}
|
||||
|
||||
ok(id, {
|
||||
[FIELD_CONTENT]: [
|
||||
{
|
||||
[FIELD_TYPE]: CONTENT_TYPE_TEXT,
|
||||
text:
|
||||
typeof result === "string"
|
||||
? result
|
||||
: JSON.stringify(result, null, 2),
|
||||
},
|
||||
],
|
||||
[FIELD_IS_ERROR]: result?.success === false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method?.startsWith("notifications/")) {
|
||||
// Client lifecycle notifications (initialized, cancelled, ...).
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isNotification) {
|
||||
fail(id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
} catch (e) {
|
||||
log("ERROR", `handler crashed: ${e?.stack ?? e}`);
|
||||
if (!isNotification) {
|
||||
fail(id, -32603, "Internal server error", { message: e?.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rl.on("close", () => {
|
||||
log("INFO", "stdin closed, exiting");
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user