Skip to content

Commit affe800

Browse files
committed
tools: add MCP server for Node.js core development
Adds tools/mcp/node-core-mcp.mjs, a Model Context Protocol server that exposes core contributor workflows to AI assistants, and .mcp.json to wire it up for Claude Code users. Tools: configure, build, run_test, run_tests, search_code, list_docs, read_doc, search_docs, find_subsystem, list_relevant_tests, explain_test_failure, get_pr_metadata Signed-off-by: Daijiro Wachi <daijiro.wachi@gmail.com>
1 parent 2adaeee commit affe800

9 files changed

Lines changed: 955 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
!.yamllint.yaml
2222
!.configurations/
2323
!/.npmrc
24+
!.mcp.json
2425

2526
# === Rules for root dir ===
2627
/core

.mcp.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"mcpServers": {
3+
"node-core": {
4+
"type": "stdio",
5+
"command": "node",
6+
"args": ["tools/node-core-mcp/bin/node-core-mcp.mjs", "--repo", "."]
7+
}
8+
}
9+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env node
2+
import { fileURLToPath } from 'node:url';
3+
import { dirname, resolve } from 'node:path';
4+
import { server, start } from '../lib/server.mjs';
5+
import { registerTools } from '../lib/tools.mjs';
6+
7+
const argv = process.argv.slice(2);
8+
const repoIdx = argv.indexOf('--repo');
9+
const ROOT = resolve(repoIdx !== -1 ? argv[repoIdx + 1] : dirname(fileURLToPath(import.meta.url)) + '/../../..');
10+
11+
registerTools(server, ROOT);
12+
start();
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { spawn } from 'node:child_process';
4+
import { fileURLToPath } from 'node:url';
5+
import { dirname, resolve } from 'node:path';
6+
7+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
8+
const SERVER = resolve(dirname(fileURLToPath(import.meta.url)), 'node-core-mcp.mjs');
9+
10+
function createClient(args = []) {
11+
const proc = spawn(process.execPath, [SERVER, ...args], {
12+
stdio: ['pipe', 'pipe', 'pipe'],
13+
});
14+
15+
let buffer = '';
16+
let nextId = 1;
17+
const pending = new Map();
18+
19+
proc.stdout.setEncoding('utf8');
20+
proc.stdout.on('data', (chunk) => {
21+
buffer += chunk;
22+
const lines = buffer.split('\n');
23+
buffer = lines.pop();
24+
for (const line of lines) {
25+
if (!line.trim()) continue;
26+
try {
27+
const msg = JSON.parse(line);
28+
const cb = pending.get(msg.id);
29+
if (cb) { pending.delete(msg.id); cb(msg); }
30+
} catch { /* ignore malformed lines */ }
31+
}
32+
});
33+
34+
const call = (method, params = {}) => new Promise((resolve, reject) => {
35+
const id = nextId++;
36+
const timer = setTimeout(() => {
37+
pending.delete(id);
38+
reject(new Error(`Timeout: no response to "${method}"`));
39+
}, 10_000);
40+
pending.set(id, (msg) => { clearTimeout(timer); resolve(msg); });
41+
proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
42+
});
43+
44+
return { call, close: () => proc.kill() };
45+
}
46+
47+
test('--repo <path>: tools use the specified root', async () => {
48+
const client = createClient(['--repo', REPO_ROOT]);
49+
try {
50+
const res = await client.call('tools/call', { name: 'list_docs', arguments: {} });
51+
assert.ok(!res.error, res.error?.message);
52+
assert.ok(res.result.content[0].text.includes('.md'));
53+
} finally {
54+
client.close();
55+
}
56+
});
57+
58+
test('--repo <nonexistent>: tools return an error, server stays alive', async () => {
59+
const client = createClient(['--repo', '/nonexistent/path']);
60+
try {
61+
const res = await client.call('tools/call', { name: 'list_docs', arguments: {} });
62+
// Server must respond (not crash) — result is either an error content or isError
63+
assert.ok(res.result || res.error, 'server should respond');
64+
} finally {
65+
client.close();
66+
}
67+
});

tools/node-core-mcp/lib/server.mjs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
const registeredTools = [];
2+
3+
export const server = {
4+
tool(name, description, inputSchema, handler) {
5+
registeredTools.push({ name, description, inputSchema, handler });
6+
},
7+
};
8+
9+
function send(obj) {
10+
process.stdout.write(JSON.stringify(obj) + '\n');
11+
}
12+
13+
async function dispatch(msg) {
14+
switch (msg.method) {
15+
case 'initialize':
16+
return {
17+
protocolVersion: '2024-11-05',
18+
capabilities: { tools: {} },
19+
serverInfo: { name: 'node-core', version: '1.0.0' },
20+
};
21+
case 'notifications/initialized':
22+
return null;
23+
case 'tools/list':
24+
return {
25+
tools: registeredTools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
26+
};
27+
case 'tools/call': {
28+
const tool = registeredTools.find((t) => t.name === msg.params?.name);
29+
if (!tool) throw Object.assign(new Error(`Unknown tool: ${msg.params?.name}`), { code: -32601 });
30+
return tool.handler(msg.params?.arguments ?? {});
31+
}
32+
default:
33+
throw Object.assign(new Error(`Method not found: ${msg.method}`), { code: -32601 });
34+
}
35+
}
36+
37+
export function start() {
38+
let buffer = '';
39+
process.stdin.setEncoding('utf8');
40+
process.stdin.on('data', async (chunk) => {
41+
buffer += chunk;
42+
const lines = buffer.split('\n');
43+
buffer = lines.pop();
44+
for (const line of lines) {
45+
if (!line.trim()) continue;
46+
let msg;
47+
try { msg = JSON.parse(line); } catch { continue; }
48+
if (msg.id == null) continue; // notification — no response
49+
let result;
50+
try {
51+
result = await dispatch(msg);
52+
} catch (err) {
53+
send({ jsonrpc: '2.0', id: msg.id, error: { code: err.code ?? -32603, message: err.message } });
54+
continue;
55+
}
56+
if (result !== null) send({ jsonrpc: '2.0', id: msg.id, result });
57+
}
58+
});
59+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { spawn } from 'node:child_process';
4+
import { fileURLToPath } from 'node:url';
5+
import { dirname, resolve } from 'node:path';
6+
7+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
8+
const SERVER = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'node-core-mcp.mjs');
9+
10+
function createClient() {
11+
const proc = spawn(process.execPath, [SERVER, '--repo', REPO_ROOT], {
12+
stdio: ['pipe', 'pipe', 'pipe'],
13+
});
14+
15+
let buffer = '';
16+
let nextId = 1;
17+
const pending = new Map();
18+
19+
proc.stdout.setEncoding('utf8');
20+
proc.stdout.on('data', (chunk) => {
21+
buffer += chunk;
22+
const lines = buffer.split('\n');
23+
buffer = lines.pop();
24+
for (const line of lines) {
25+
if (!line.trim()) continue;
26+
try {
27+
const msg = JSON.parse(line);
28+
const cb = pending.get(msg.id);
29+
if (cb) { pending.delete(msg.id); cb(msg); }
30+
} catch { /* ignore malformed lines */ }
31+
}
32+
});
33+
34+
const call = (method, params = {}) => new Promise((resolve, reject) => {
35+
const id = nextId++;
36+
const timer = setTimeout(() => {
37+
pending.delete(id);
38+
reject(new Error(`Timeout: no response to "${method}"`));
39+
}, 10_000);
40+
pending.set(id, (msg) => { clearTimeout(timer); resolve(msg); });
41+
proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
42+
});
43+
44+
return { call, close: () => proc.kill() };
45+
}
46+
47+
test('initialize returns protocol version and server info', async () => {
48+
const client = createClient();
49+
try {
50+
const res = await client.call('initialize', {
51+
protocolVersion: '2024-11-05',
52+
capabilities: {},
53+
clientInfo: { name: 'test', version: '0.1' },
54+
});
55+
assert.equal(res.result.protocolVersion, '2024-11-05');
56+
assert.equal(res.result.serverInfo.name, 'node-core');
57+
assert.ok(res.result.capabilities.tools);
58+
} finally {
59+
client.close();
60+
}
61+
});
62+
63+
test('unknown method returns error -32601', async () => {
64+
const client = createClient();
65+
try {
66+
const res = await client.call('no/such/method');
67+
assert.ok(res.error);
68+
assert.equal(res.error.code, -32601);
69+
} finally {
70+
client.close();
71+
}
72+
});
73+
74+
test('tools/list returns all expected tools in order', async () => {
75+
const client = createClient();
76+
try {
77+
const res = await client.call('tools/list');
78+
const names = res.result.tools.map((t) => t.name);
79+
assert.deepEqual(names, [
80+
'configure', 'build', 'run_test', 'run_tests',
81+
'search_code', 'list_docs', 'read_doc',
82+
'find_subsystem', 'list_relevant_tests', 'explain_test_failure', 'search_docs',
83+
'get_pr_metadata',
84+
]);
85+
} finally {
86+
client.close();
87+
}
88+
});
89+
90+
test('each tool has name, description, and inputSchema', async () => {
91+
const client = createClient();
92+
try {
93+
const res = await client.call('tools/list');
94+
for (const tool of res.result.tools) {
95+
assert.ok(tool.name, 'missing name');
96+
assert.ok(tool.description, `${tool.name}: missing description`);
97+
assert.ok(tool.inputSchema, `${tool.name}: missing inputSchema`);
98+
}
99+
} finally {
100+
client.close();
101+
}
102+
});
103+
104+
test('tools/call unknown tool returns error -32601', async () => {
105+
const client = createClient();
106+
try {
107+
const res = await client.call('tools/call', { name: 'nonexistent', arguments: {} });
108+
assert.ok(res.error);
109+
assert.equal(res.error.code, -32601);
110+
} finally {
111+
client.close();
112+
}
113+
});

0 commit comments

Comments
 (0)