From efa59492758ed7df4177c10714a483009bcbb0c2 Mon Sep 17 00:00:00 2001 From: Daijiro Wachi Date: Sun, 7 Jun 2026 18:02:42 +0900 Subject: [PATCH] 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 --- .gitignore | 1 + .mcp.json | 9 + tools/node-core-mcp/bin/node-core-mcp.mjs | 12 + .../node-core-mcp/bin/node-core-mcp.test.mjs | 67 +++ tools/node-core-mcp/lib/server.mjs | 59 ++ tools/node-core-mcp/lib/server.test.mjs | 113 ++++ tools/node-core-mcp/lib/tools.mjs | 566 ++++++++++++++++++ tools/node-core-mcp/lib/tools.test.mjs | 126 ++++ tools/node-core-mcp/package.json | 16 + 9 files changed, 969 insertions(+) create mode 100644 .mcp.json create mode 100755 tools/node-core-mcp/bin/node-core-mcp.mjs create mode 100644 tools/node-core-mcp/bin/node-core-mcp.test.mjs create mode 100644 tools/node-core-mcp/lib/server.mjs create mode 100644 tools/node-core-mcp/lib/server.test.mjs create mode 100644 tools/node-core-mcp/lib/tools.mjs create mode 100644 tools/node-core-mcp/lib/tools.test.mjs create mode 100644 tools/node-core-mcp/package.json diff --git a/.gitignore b/.gitignore index 69c1dd205316fa..f80c00c7e8e3e4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ !.yamllint.yaml !.configurations/ !/.npmrc +!.mcp.json # === Rules for root dir === /core diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000000000..e25eec35fde21d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "node-core": { + "type": "stdio", + "command": "node", + "args": ["tools/node-core-mcp/bin/node-core-mcp.mjs", "--repo", "."] + } + } +} diff --git a/tools/node-core-mcp/bin/node-core-mcp.mjs b/tools/node-core-mcp/bin/node-core-mcp.mjs new file mode 100755 index 00000000000000..e69c86a0830cfe --- /dev/null +++ b/tools/node-core-mcp/bin/node-core-mcp.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { server, start } from '../lib/server.mjs'; +import { registerTools } from '../lib/tools.mjs'; + +const argv = process.argv.slice(2); +const repoIdx = argv.indexOf('--repo'); +const ROOT = resolve(repoIdx !== -1 ? argv[repoIdx + 1] : dirname(fileURLToPath(import.meta.url)) + '/../../..'); + +registerTools(server, ROOT); +start(); diff --git a/tools/node-core-mcp/bin/node-core-mcp.test.mjs b/tools/node-core-mcp/bin/node-core-mcp.test.mjs new file mode 100644 index 00000000000000..88bafc9a6fc953 --- /dev/null +++ b/tools/node-core-mcp/bin/node-core-mcp.test.mjs @@ -0,0 +1,67 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const SERVER = resolve(dirname(fileURLToPath(import.meta.url)), 'node-core-mcp.mjs'); + +function createClient(args = []) { + const proc = spawn(process.execPath, [SERVER, ...args], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let buffer = ''; + let nextId = 1; + const pending = new Map(); + + proc.stdout.setEncoding('utf8'); + proc.stdout.on('data', (chunk) => { + buffer += chunk; + const lines = buffer.split('\n'); + buffer = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + const cb = pending.get(msg.id); + if (cb) { pending.delete(msg.id); cb(msg); } + } catch { /* ignore malformed lines */ } + } + }); + + const call = (method, params = {}) => new Promise((resolve, reject) => { + const id = nextId++; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Timeout: no response to "${method}"`)); + }, 10_000); + pending.set(id, (msg) => { clearTimeout(timer); resolve(msg); }); + proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + }); + + return { call, close: () => proc.kill() }; +} + +test('--repo : tools use the specified root', async () => { + const client = createClient(['--repo', REPO_ROOT]); + try { + const res = await client.call('tools/call', { name: 'list_docs', arguments: {} }); + assert.ok(!res.error, res.error?.message); + assert.ok(res.result.content[0].text.includes('.md')); + } finally { + client.close(); + } +}); + +test('--repo : tools return an error, server stays alive', async () => { + const client = createClient(['--repo', '/nonexistent/path']); + try { + const res = await client.call('tools/call', { name: 'list_docs', arguments: {} }); + // Server must respond (not crash) — result is either an error content or isError + assert.ok(res.result || res.error, 'server should respond'); + } finally { + client.close(); + } +}); diff --git a/tools/node-core-mcp/lib/server.mjs b/tools/node-core-mcp/lib/server.mjs new file mode 100644 index 00000000000000..b4ff1c070315ee --- /dev/null +++ b/tools/node-core-mcp/lib/server.mjs @@ -0,0 +1,59 @@ +const registeredTools = []; + +export const server = { + tool(name, description, inputSchema, handler) { + registeredTools.push({ name, description, inputSchema, handler }); + }, +}; + +function send(obj) { + process.stdout.write(JSON.stringify(obj) + '\n'); +} + +async function dispatch(msg) { + switch (msg.method) { + case 'initialize': + return { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'node-core', version: '1.0.0' }, + }; + case 'notifications/initialized': + return null; + case 'tools/list': + return { + tools: registeredTools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })), + }; + case 'tools/call': { + const tool = registeredTools.find((t) => t.name === msg.params?.name); + if (!tool) throw Object.assign(new Error(`Unknown tool: ${msg.params?.name}`), { code: -32601 }); + return tool.handler(msg.params?.arguments ?? {}); + } + default: + throw Object.assign(new Error(`Method not found: ${msg.method}`), { code: -32601 }); + } +} + +export function start() { + let buffer = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', async (chunk) => { + buffer += chunk; + const lines = buffer.split('\n'); + buffer = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.id == null) continue; // Notification — no response + let result; + try { + result = await dispatch(msg); + } catch (err) { + send({ jsonrpc: '2.0', id: msg.id, error: { code: err.code ?? -32603, message: err.message } }); + continue; + } + if (result !== null) send({ jsonrpc: '2.0', id: msg.id, result }); + } + }); +} diff --git a/tools/node-core-mcp/lib/server.test.mjs b/tools/node-core-mcp/lib/server.test.mjs new file mode 100644 index 00000000000000..1cae79bb4fadc0 --- /dev/null +++ b/tools/node-core-mcp/lib/server.test.mjs @@ -0,0 +1,113 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); +const SERVER = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'node-core-mcp.mjs'); + +function createClient() { + const proc = spawn(process.execPath, [SERVER, '--repo', REPO_ROOT], { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let buffer = ''; + let nextId = 1; + const pending = new Map(); + + proc.stdout.setEncoding('utf8'); + proc.stdout.on('data', (chunk) => { + buffer += chunk; + const lines = buffer.split('\n'); + buffer = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + const cb = pending.get(msg.id); + if (cb) { pending.delete(msg.id); cb(msg); } + } catch { /* ignore malformed lines */ } + } + }); + + const call = (method, params = {}) => new Promise((resolve, reject) => { + const id = nextId++; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Timeout: no response to "${method}"`)); + }, 10_000); + pending.set(id, (msg) => { clearTimeout(timer); resolve(msg); }); + proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + }); + + return { call, close: () => proc.kill() }; +} + +test('initialize returns protocol version and server info', async () => { + const client = createClient(); + try { + const res = await client.call('initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test', version: '0.1' }, + }); + assert.strictEqual(res.result.protocolVersion, '2024-11-05'); + assert.strictEqual(res.result.serverInfo.name, 'node-core'); + assert.ok(res.result.capabilities.tools); + } finally { + client.close(); + } +}); + +test('unknown method returns error -32601', async () => { + const client = createClient(); + try { + const res = await client.call('no/such/method'); + assert.ok(res.error); + assert.strictEqual(res.error.code, -32601); + } finally { + client.close(); + } +}); + +test('tools/list returns all expected tools in order', async () => { + const client = createClient(); + try { + const res = await client.call('tools/list'); + const names = res.result.tools.map((t) => t.name); + assert.deepStrictEqual(names, [ + 'configure', 'build', 'run_test', 'run_tests', + 'search_code', 'list_docs', 'read_doc', + 'find_subsystem', 'list_relevant_tests', 'explain_test_failure', 'search_docs', + 'get_pr_metadata', + ]); + } finally { + client.close(); + } +}); + +test('each tool has name, description, and inputSchema', async () => { + const client = createClient(); + try { + const res = await client.call('tools/list'); + for (const tool of res.result.tools) { + assert.ok(tool.name, 'missing name'); + assert.ok(tool.description, `${tool.name}: missing description`); + assert.ok(tool.inputSchema, `${tool.name}: missing inputSchema`); + } + } finally { + client.close(); + } +}); + +test('tools/call unknown tool returns error -32601', async () => { + const client = createClient(); + try { + const res = await client.call('tools/call', { name: 'nonexistent', arguments: {} }); + assert.ok(res.error); + assert.strictEqual(res.error.code, -32601); + } finally { + client.close(); + } +}); diff --git a/tools/node-core-mcp/lib/tools.mjs b/tools/node-core-mcp/lib/tools.mjs new file mode 100644 index 00000000000000..fc71a7fb44164d --- /dev/null +++ b/tools/node-core-mcp/lib/tools.mjs @@ -0,0 +1,566 @@ +import { execFile } from 'node:child_process'; +import { readFile, readdir, access } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +const MAX_OUTPUT = 32_000; + +export function truncate(str, limit = MAX_OUTPUT) { + if (str.length <= limit) return str; + return str.slice(0, limit) + `\n...[truncated ${str.length - limit} chars]`; +} + +export function inferSubsystemFromPath(file) { + const parts = file.replace(/\\/g, '/').split('/'); + if (parts[0] === 'lib') { + const idx = parts[1] === 'internal' ? 2 : 1; + return parts[idx]?.replace(/\.m?js$/, '') || null; + } + if (parts[0] === 'src') { + return parts[1]?.replace(/^node_/, '').replace(/\.(cc|h)$/, '') || null; + } + if (parts[0] === 'test') { + const name = parts[parts.length - 1]?.replace(/^test-/, '').replace(/\.m?js$/, ''); + return name?.split('-')[0] || null; + } + if (parts[0] === 'doc' && parts[1] === 'api') return parts[2]?.replace(/\.md$/, '') || null; + if (parts[0] === 'tools') return 'tools'; + if (parts[0] === 'benchmark') return 'benchmark'; + if (parts[0] === 'deps') return 'deps'; + return null; +} + +export function registerTools(server, root) { + async function exec(cmd, args, { cwd = root, timeout = 30_000, env } = {}) { + try { + const { stdout, stderr } = await execFileAsync(cmd, args, { + cwd, + timeout, + maxBuffer: 10 * 1024 * 1024, + env: { ...process.env, FORCE_COLOR: '0', ...env }, + }); + const out = (stdout + (stderr ? `\nSTDERR:\n${stderr}` : '')).trim(); + return { ok: true, output: truncate(out) }; + } catch (err) { + const out = [ + err.stdout, + err.stderr ? `STDERR:\n${err.stderr}` : '', + err.killed ? `[killed: timeout or signal]` : `[exit code: ${err.code}]`, + ].filter(Boolean).join('\n').trim(); + return { ok: false, output: truncate(out) }; + } + } + + async function getNodeBin() { + const devBin = join(root, 'node'); + try { + await access(devBin); + return devBin; + } catch { + return process.execPath; + } + } + + // ── Build ─────────────────────────────────────────────────────────────── + + server.tool('configure', + 'Run ./configure to set build flags. Required before the first build or when changing flags.', + { + type: 'object', + properties: { + debug: { + type: 'boolean', + description: 'Build in debug mode (passes --debug)', + default: false, + }, + extra_flags: { + type: 'array', + items: { type: 'string' }, + description: 'Additional flags, e.g. ["--with-intl=full-icu", "--ninja"]', + default: [], + }, + }, + }, + async ({ debug = false, extra_flags: extraFlags = [] }) => { + const args = []; + if (debug) args.push('--debug'); + args.push(...extraFlags); + const { ok, output } = await exec('./configure', args, { timeout: 120_000 }); + return { + content: [{ type: 'text', text: ok ? `Configure succeeded.\n\n${output}` : `Configure failed.\n\n${output}` }], + isError: !ok, + }; + }, + ); + + server.tool('build', + 'Build Node.js. Pass target="" for the default release build (equivalent to `make -j4`).', + { + type: 'object', + properties: { + target: { + type: 'string', + description: 'Make target (e.g. "", "test-only", "lint", "doc-only")', + default: '', + }, + jobs: { + type: 'integer', + description: 'Parallel jobs (-j). Default: 4.', + default: 4, + }, + }, + }, + async ({ target = '', jobs = 4 }) => { + const args = [`-j${jobs}`]; + if (target) args.push(target); + const { ok, output } = await exec('make', args, { timeout: 300_000 }); + return { + content: [{ type: 'text', text: ok ? `Build succeeded.\n\n${output}` : `Build failed.\n\n${output}` }], + isError: !ok, + }; + }, + ); + + // ── Test ──────────────────────────────────────────────────────────────── + + server.tool('run_test', + 'Run a single test file with the dev Node.js binary (fast, no test runner overhead).', + { + type: 'object', + properties: { + file: { + type: 'string', + description: 'Test file path relative to repo root, e.g. "test/parallel/test-stream2-transform.js"', + }, + flags: { + type: 'array', + items: { type: 'string' }, + description: 'Extra Node.js flags, e.g. ["--expose-internals"]', + default: [], + }, + }, + required: ['file'], + }, + async ({ file, flags = [] }) => { + const nodeBin = await getNodeBin(); + const abs = join(root, file); + const { ok, output } = await exec(nodeBin, [...flags, abs], { timeout: 60_000 }); + return { + content: [{ type: 'text', text: ok ? `Test passed.\n\n${output}` : `Test failed.\n\n${output}` }], + isError: !ok, + }; + }, + ); + + server.tool('run_tests', + 'Run tests matching a pattern or subsystem using tools/test.py.', + { + type: 'object', + properties: { + pattern: { + type: 'string', + description: 'Glob pattern or subsystem name, e.g. "parallel/test-stream-*" or "child-process"', + }, + timeout: { + type: 'integer', + description: 'Per-test timeout in seconds. Default: 60.', + default: 60, + }, + }, + required: ['pattern'], + }, + async ({ pattern, timeout: perTestTimeout = 60 }) => { + const nodeBin = await getNodeBin(); + const args = [join(root, 'tools/test.py'), `--timeout=${perTestTimeout}`, '--no-progress', pattern]; + const { ok, output } = await exec(nodeBin, args, { timeout: 300_000 }); + return { + content: [{ type: 'text', text: ok ? `Tests passed.\n\n${output}` : `Tests failed.\n\n${output}` }], + isError: !ok, + }; + }, + ); + + // ── Code Search ───────────────────────────────────────────────────────── + + server.tool('search_code', + 'Search for a pattern in the Node.js source (grep -rn). Searches lib/, src/, test/ by default.', + { + type: 'object', + properties: { + pattern: { + type: 'string', + description: 'Search pattern (extended regex)', + }, + dir: { + type: 'string', + description: 'Directory to search in, relative to repo root. Default: searches lib/, src/, test/.', + default: '', + }, + include: { + type: 'string', + description: 'File glob filter, e.g. "*.js" or "*.cc"', + default: '', + }, + ignore_case: { + type: 'boolean', + description: 'Case-insensitive search', + default: false, + }, + }, + required: ['pattern'], + }, + async ({ pattern, dir = '', include = '', ignore_case: ignoreCase = false }) => { + const args = ['-rn', '--include=*.js', '--include=*.cc', '--include=*.h']; + if (ignoreCase) args.push('-i'); + if (include) { + const idx = args.indexOf('--include=*.js'); + args.splice(idx, 3, `--include=${include}`); + } + args.push('--', pattern); + const targets = dir ? + [join(root, dir)] : + [join(root, 'lib'), join(root, 'src'), join(root, 'test')]; + args.push(...targets); + const { ok, output } = await exec('grep', args, { timeout: 15_000 }); + if (!ok && !output) return { content: [{ type: 'text', text: 'No matches found.' }] }; + return { content: [{ type: 'text', text: truncate(output, 8_000) }] }; + }, + ); + + // ── Documentation ─────────────────────────────────────────────────────── + + server.tool('list_docs', + 'List available API documentation files in doc/api/.', + { type: 'object', properties: {} }, + async () => { + try { + const files = await readdir(join(root, 'doc/api')); + const sorted = files.filter((f) => f.endsWith('.md')).sort().join('\n'); + return { content: [{ type: 'text', text: sorted }] }; + } catch (err) { + return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true }; + } + }, + ); + + server.tool('read_doc', + 'Read an API documentation file from doc/api/.', + { + type: 'object', + properties: { + name: { + type: 'string', + description: 'Doc filename, e.g. "mcp.md" or "stream.md"', + }, + }, + required: ['name'], + }, + async ({ name }) => { + const filename = name.endsWith('.md') ? name : `${name}.md`; + try { + const content = await readFile(join(root, 'doc/api', filename), 'utf8'); + return { content: [{ type: 'text', text: truncate(content, 16_000) }] }; + } catch { + try { + const files = await readdir(join(root, 'doc/api')); + const match = files.find((f) => f.toLowerCase() === filename.toLowerCase()); + if (match) { + const content = await readFile(join(root, 'doc/api', match), 'utf8'); + return { content: [{ type: 'text', text: truncate(content, 16_000) }] }; + } + } catch { + // Fall through to not-found return below + } + return { content: [{ type: 'text', text: `Doc not found: ${filename}` }], isError: true }; + } + }, + ); + + // ── Subsystem / Review ────────────────────────────────────────────────── + + server.tool('find_subsystem', + 'Given changed files, identify the primary Node.js subsystem, reviewers, and PR labels.', + { + type: 'object', + properties: { + files: { + type: 'array', + items: { type: 'string' }, + description: 'File paths relative to repo root', + }, + }, + required: ['files'], + }, + async ({ files }) => { + const counts = {}; + const labels = new Set(); + + for (const file of files) { + const s = inferSubsystemFromPath(file); + if (s) counts[s] = (counts[s] || 0) + 1; + if (file.startsWith('test/')) labels.add('test'); + if (file.startsWith('doc/')) labels.add('doc'); + if (file.startsWith('benchmark/')) labels.add('benchmark'); + } + + for (const file of files.slice(0, 5)) { + const { ok, output } = await exec('git', ['log', '--oneline', '-10', '--', file], { timeout: 8_000 }); + if (!ok || !output) continue; + for (const line of output.split('\n')) { + const m = line.match(/^[a-f0-9]+ ([a-z][a-z0-9_/-]*(?:,[a-z][a-z0-9_/-]*)*):/); + if (m) { + for (const s of m[1].split(',').map((x) => x.trim())) { + counts[s] = (counts[s] || 0) + 2; + } + } + } + } + + const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([s]) => s); + const subsystem = sorted[0] || 'unknown'; + labels.add(subsystem); + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + subsystem, + likelyReviewers: sorted.slice(0, 3), + labels: [...labels].sort(), + }, null, 2), + }], + }; + }, + ); + + server.tool('list_relevant_tests', + 'Given changed files, suggest which test commands to run.', + { + type: 'object', + properties: { + changedFiles: { + type: 'array', + items: { type: 'string' }, + description: 'Changed file paths relative to repo root', + }, + }, + required: ['changedFiles'], + }, + async ({ changedFiles }) => { + const modules = new Set(); + for (const file of changedFiles) { + const s = inferSubsystemFromPath(file); + if (s && !['tools', 'benchmark', 'deps', 'doc'].includes(s)) modules.add(s); + } + + const commands = []; + const reasons = []; + + for (const file of changedFiles) { + if (file.startsWith('test/')) commands.push(`./node ${file}`); + } + + for (const mod of modules) { + const checks = [ + { pattern: `test-${mod}*.js`, cmd: `tools/test.py -J parallel/test-${mod}*` }, + { pattern: `test-whatwg-${mod}*.js`, cmd: `tools/test.py -J parallel/test-whatwg-${mod}*` }, + ]; + for (const { pattern, cmd } of checks) { + const { output } = await exec( + 'find', [join(root, 'test/parallel'), '-name', pattern], { timeout: 10_000 }, + ); + if (output?.trim()) { commands.push(cmd); reasons.push(mod); } + } + const { output: wpt } = await exec('find', [join(root, 'test/wpt'), '-name', '*.js', '-path', `*${mod}*`], { timeout: 10_000 }); + if (wpt?.trim()) commands.push(`tools/test.py wpt/${mod}`); + } + + return { + content: [{ + type: 'text', + text: JSON.stringify({ + commands: [...new Set(commands)], + reason: reasons.length ? `${[...new Set(reasons)].join(', ')} implementation affected` : 'Related tests found for changed files.', + }, null, 2), + }], + }; + }, + ); + + server.tool('explain_test_failure', + 'Parse a test failure log (TAP or tools/test.py) and return failures and re-run commands.', + { + type: 'object', + properties: { + log: { + type: 'string', + description: 'Raw test output log', + }, + platform: { + type: 'string', + enum: ['linux', 'darwin', 'win32'], + description: 'Platform the test ran on (optional context)', + default: 'linux', + }, + }, + required: ['log'], + }, + async ({ log }) => { + const failures = []; + const lines = log.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const tap = line.match(/^not ok \d+ - (.+)/); + if (tap) { + const diagnostics = []; + for (let j = i + 1; j < Math.min(i + 30, lines.length); j++) { + if (lines[j].startsWith('#') || lines[j].startsWith(' ')) { + diagnostics.push(lines[j].replace(/^#\s*/, '').trim()); + } else if (/^(ok|not ok)\s/.test(lines[j])) break; + } + failures.push({ test: tap[1].trim(), details: diagnostics.join('\n').trim() }); + continue; + } + const py = line.match(/^(?:FAIL|FAILED)\s+(test\/\S+)/); + if (py) failures.push({ test: py[1], details: '' }); + } + + const errorMessages = lines + .filter((l) => /^\s*([A-Z][a-zA-Z]*Error|assert\.)/.test(l)) + .slice(0, 5) + .map((l) => l.trim()); + + const rerunCommands = [...new Set( + failures.map((f) => (f.test.startsWith('test/') ? `./node ${f.test}` : `tools/test.py ${f.test}`)), + )].slice(0, 10); + + return { + content: [{ + type: 'text', + text: failures.length === 0 ? + 'No test failures detected in the provided log.' : + JSON.stringify({ + failureCount: failures.length, + failures: failures.slice(0, 20), + errorMessages, + rerunCommands, + }, null, 2), + }], + }; + }, + ); + + server.tool('search_docs', + 'Search Node.js documentation (doc/api, doc/contributing, test/README.md) for a query.', + { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search pattern (extended regex)', + }, + section: { + type: 'string', + enum: ['api', 'contributing', 'all'], + description: 'Which docs to search. Default: "all"', + default: 'all', + }, + }, + required: ['query'], + }, + async ({ query, section = 'all' }) => { + const targets = []; + if (section !== 'contributing') targets.push(join(root, 'doc/api')); + if (section !== 'api') targets.push(join(root, 'doc/contributing')); + if (section === 'all') targets.push(join(root, 'test/README.md')); + + const args = ['-rn', '-i', '--include=*.md', '--', query, ...targets]; + const { ok, output } = await exec('grep', args, { timeout: 10_000 }); + if (!ok && !output) return { content: [{ type: 'text', text: 'No documentation matches found.' }] }; + return { content: [{ type: 'text', text: truncate(output, 8_000) }] }; + }, + ); + + // ── PR Metadata ───────────────────────────────────────────────────────── + + server.tool('get_pr_metadata', + 'Fetch PR metadata: labels, CI status, reviews, and commits for a nodejs/node PR.', + { + type: 'object', + properties: { + pr: { + type: 'string', + description: 'PR number or full GitHub PR URL', + }, + repo: { + type: 'string', + description: 'GitHub repo in owner/repo format. Default: "nodejs/node"', + default: 'nodejs/node', + }, + include_landing_metadata: { + type: 'boolean', + description: 'Also run `git node metadata` to get the Reviewed-By / PR-URL block', + default: false, + }, + }, + required: ['pr'], + }, + async ({ pr, repo = 'nodejs/node', include_landing_metadata: includeLandingMetadata = false }) => { + const prNum = String(pr).match(/(\d+)\/?$/)?.[1] ?? String(pr); + const result = { pr: prNum, repo }; + + const { ok, output } = await exec('gh', [ + 'pr', 'view', prNum, '--repo', repo, + '--json', 'number,title,state,labels,reviews,statusCheckRollup,commits,author,url', + ], { timeout: 30_000 }); + + if (ok) { + try { + const d = JSON.parse(output); + result.title = d.title; + result.state = d.state; + result.url = d.url; + result.author = d.author?.login; + result.labels = (d.labels ?? []).map((l) => l.name); + + const approved = (d.reviews ?? []) + .filter((r) => r.state === 'APPROVED') + .map((r) => r.author?.login); + const changes = (d.reviews ?? []) + .filter((r) => r.state === 'CHANGES_REQUESTED') + .map((r) => r.author?.login); + result.reviews = { approved, changesRequested: changes }; + + const checks = d.statusCheckRollup ?? []; + result.ci = { + total: checks.length, + passed: checks.filter((c) => c.conclusion === 'SUCCESS').length, + failed: checks.filter((c) => c.conclusion === 'FAILURE').length, + pending: checks.filter((c) => !c.conclusion || c.conclusion === 'PENDING').length, + }; + + const commits = d.commits ?? []; + result.commitCount = commits.length; + if (commits.length > 0) { + const last = commits[commits.length - 1]; + result.latestCommit = { sha: last.oid?.slice(0, 10), message: last.messageHeadline }; + } + } catch (e) { + result.error = `gh parse error: ${e.message}`; + } + } else { + result.error = truncate(output, 300); + } + + if (includeLandingMetadata) { + const { ok: mOk, output: mOut } = await exec('git', ['node', 'metadata', prNum], { timeout: 20_000 }); + result.landingMetadata = mOk ? mOut : `git node metadata failed: ${truncate(mOut, 200)}`; + } + + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + }, + ); +} diff --git a/tools/node-core-mcp/lib/tools.test.mjs b/tools/node-core-mcp/lib/tools.test.mjs new file mode 100644 index 00000000000000..355e067d5b7f8b --- /dev/null +++ b/tools/node-core-mcp/lib/tools.test.mjs @@ -0,0 +1,126 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { registerTools, inferSubsystemFromPath, truncate } from './tools.mjs'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +function createMock() { + const handlers = new Map(); + const mock = { + tool(name, _desc, _schema, handler) { handlers.set(name, handler); }, + async call(name, args = {}) { + const h = handlers.get(name); + if (!h) throw new Error(`No tool registered: ${name}`); + return h(args); + }, + }; + registerTools(mock, REPO_ROOT); + return mock; +} + +// ── inferSubsystemFromPath ──────────────────────────────────────────────── + +test('inferSubsystemFromPath: lib/internal/url.js → url', () => { + assert.strictEqual(inferSubsystemFromPath('lib/internal/url.js'), 'url'); +}); + +test('inferSubsystemFromPath: lib/fs.js → fs', () => { + assert.strictEqual(inferSubsystemFromPath('lib/fs.js'), 'fs'); +}); + +test('inferSubsystemFromPath: src/node_url.cc → url', () => { + assert.strictEqual(inferSubsystemFromPath('src/node_url.cc'), 'url'); +}); + +test('inferSubsystemFromPath: test/parallel/test-whatwg-url-foo.js → whatwg', () => { + assert.strictEqual(inferSubsystemFromPath('test/parallel/test-whatwg-url-foo.js'), 'whatwg'); +}); + +test('inferSubsystemFromPath: doc/api/stream.md → stream', () => { + assert.strictEqual(inferSubsystemFromPath('doc/api/stream.md'), 'stream'); +}); + +test('inferSubsystemFromPath: tools/foo.js → tools', () => { + assert.strictEqual(inferSubsystemFromPath('tools/foo.js'), 'tools'); +}); + +// ── truncate ───────────────────────────────────────────────────────────── + +test('truncate: short string passes through unchanged', () => { + assert.strictEqual(truncate('hello'), 'hello'); +}); + +test('truncate: long string is cut at limit with annotation', () => { + const s = 'x'.repeat(100); + const result = truncate(s, 10); + assert.strictEqual(result.length > 10, true); + assert.ok(result.includes('[truncated')); + assert.ok(result.startsWith('x'.repeat(10))); +}); + +// ── explain_test_failure ───────────────────────────────────────────────── + +test('explain_test_failure: parses TAP not-ok line', async () => { + const mock = createMock(); + const log = [ + 'TAP version 13', + '1..2', + 'not ok 1 - test/parallel/test-foo.js', + '# AssertionError: expected 1 to equal 2', + 'ok 2 - test/parallel/test-bar.js', + ].join('\n'); + const res = await mock.call('explain_test_failure', { log }); + const data = JSON.parse(res.content[0].text); + assert.strictEqual(data.failureCount, 1); + assert.strictEqual(data.failures[0].test, 'test/parallel/test-foo.js'); + assert.ok(data.failures[0].details.includes('AssertionError')); + assert.ok(data.rerunCommands[0].includes('test/parallel/test-foo.js')); +}); + +test('explain_test_failure: parses tools/test.py FAIL line', async () => { + const mock = createMock(); + const log = 'FAIL test/parallel/test-baz.js\n'; + const res = await mock.call('explain_test_failure', { log }); + const data = JSON.parse(res.content[0].text); + assert.strictEqual(data.failureCount, 1); + assert.strictEqual(data.failures[0].test, 'test/parallel/test-baz.js'); +}); + +test('explain_test_failure: reports no failures for passing log', async () => { + const mock = createMock(); + const res = await mock.call('explain_test_failure', { log: 'ok 1 - test-foo\nok 2 - test-bar\n' }); + assert.strictEqual(res.content[0].text, 'No test failures detected in the provided log.'); +}); + +// ── find_subsystem ──────────────────────────────────────────────────────── + +test('find_subsystem: url files return url subsystem', async () => { + const mock = createMock(); + const res = await mock.call('find_subsystem', { + files: ['lib/internal/url.js', 'test/parallel/test-whatwg-url-custom-searchparams.js'], + }); + const data = JSON.parse(res.content[0].text); + assert.ok(data.subsystem, 'missing subsystem'); + assert.ok(data.labels.includes('test')); + assert.ok(Array.isArray(data.likelyReviewers)); +}); + +// ── list_relevant_tests ─────────────────────────────────────────────────── + +test('list_relevant_tests: returns commands array and reason string', async () => { + const mock = createMock(); + const res = await mock.call('list_relevant_tests', { changedFiles: ['lib/internal/url.js'] }); + const data = JSON.parse(res.content[0].text); + assert.ok(Array.isArray(data.commands)); + assert.ok(typeof data.reason === 'string'); +}); + +// ── list_docs ───────────────────────────────────────────────────────────── + +test('list_docs: returns .md file names', async () => { + const mock = createMock(); + const res = await mock.call('list_docs', {}); + assert.ok(res.content[0].text.includes('.md')); +}); diff --git a/tools/node-core-mcp/package.json b/tools/node-core-mcp/package.json new file mode 100644 index 00000000000000..a9eb99385a15db --- /dev/null +++ b/tools/node-core-mcp/package.json @@ -0,0 +1,16 @@ +{ + "name": "node-core-mcp", + "version": "1.0.0", + "description": "MCP server for Node.js core contributors", + "type": "module", + "bin": { + "node-core-mcp": "./bin/node-core-mcp.mjs" + }, + "scripts": { + "test": "node --test bin/node-core-mcp.test.mjs lib/server.test.mjs lib/tools.test.mjs" + }, + "author": "Daijiro Wachi", + "engines": { + "node": ">=18" + } +}