feat(TRUEREF-0011): implement MCP server with stdio transport

Adds a Model Context Protocol server that exposes resolve-library-id
and query-docs tools via stdio, with tool schemas identical to context7
for drop-in compatibility with Claude Code, Cursor, and Zed.

- src/mcp/index.ts — server entry point (io.github.trueref/trueref)
- src/mcp/client.ts — HTTP client for TrueRef REST API (TRUEREF_API_URL)
- src/mcp/tools/resolve-library-id.ts — library search tool handler
- src/mcp/tools/query-docs.ts — documentation retrieval tool handler
- src/mcp/index.test.ts — integration tests spawning real server subprocess
- .claude/rules/trueref.md — Claude Code rule file for MCP usage
- package.json: mcp:start script using tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-03-22 18:32:20 +01:00
parent 956b2a3a62
commit cb253ffe98
8 changed files with 1626 additions and 31 deletions

297
src/mcp/index.test.ts Normal file
View File

@@ -0,0 +1,297 @@
/**
* Integration tests for the TrueRef MCP server (stdio transport).
*
* Spawns the MCP server as a subprocess, sends JSON-RPC messages over stdin,
* and reads responses from stdout. The server is pointed at a mock HTTP
* server so no real TrueRef instance is required.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawn, type ChildProcess } from 'node:child_process';
import { createServer, type Server as HttpServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const MCP_ENTRY = resolve(__dirname, 'index.ts');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildRequest(id: number, method: string, params?: Record<string, unknown>) {
return JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
}
function waitForResponse(proc: ChildProcess, id: number, timeoutMs = 5000): Promise<unknown> {
return new Promise((resolve, reject) => {
let buffer = '';
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Timeout waiting for response id=${id}`));
}, timeoutMs);
function onData(chunk: Buffer) {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.id === id) {
cleanup();
resolve(msg);
}
} catch {
// not JSON — skip
}
}
}
function cleanup() {
clearTimeout(timer);
proc.stdout?.off('data', onData);
proc.stderr?.off('data', onStderr);
}
function onStderr(chunk: Buffer) {
// Surface stderr for debugging but don't fail the test here
process.stderr.write(`[mcp-server] ${chunk.toString()}`);
}
proc.stdout?.on('data', onData);
proc.stderr?.on('data', onStderr);
});
}
// ---------------------------------------------------------------------------
// Mock HTTP server
// ---------------------------------------------------------------------------
function startMockApiServer(port: number): Promise<HttpServer> {
return new Promise((resolve, reject) => {
const srv = createServer((req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${port}`);
if (url.pathname === '/api/v1/libs/search') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('- /mock/mylib — Mock Library (42 snippets)');
return;
}
if (url.pathname === '/api/v1/context') {
const libraryId = url.searchParams.get('libraryId') ?? '';
if (libraryId === '/not-found/lib') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found', code: 'LIBRARY_NOT_FOUND' }));
return;
}
if (libraryId === '/indexing/lib') {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'indexing', code: 'INDEXING_IN_PROGRESS' }));
return;
}
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('# Mock Library\n\nSome documentation content.');
return;
}
res.writeHead(404);
res.end();
});
srv.listen(port, '127.0.0.1', () => resolve(srv));
srv.on('error', reject);
});
}
// ---------------------------------------------------------------------------
// Test suite
// ---------------------------------------------------------------------------
describe('MCP server (stdio transport)', () => {
let mockServer: HttpServer;
let proc: ChildProcess;
const MOCK_PORT = 19573;
beforeAll(async () => {
mockServer = await startMockApiServer(MOCK_PORT);
proc = spawn('npx', ['tsx', MCP_ENTRY], {
env: {
...process.env,
TRUEREF_API_URL: `http://127.0.0.1:${MOCK_PORT}`
},
stdio: ['pipe', 'pipe', 'pipe']
});
// Send the MCP initialize handshake
proc.stdin!.write(
buildRequest(0, 'initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'test', version: '1.0.0' }
})
);
await waitForResponse(proc, 0);
// Send initialized notification (no response expected)
proc.stdin!.write(
JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'
);
});
afterAll(() => {
proc?.kill();
mockServer?.close();
});
it('lists both tools via tools/list', async () => {
proc.stdin!.write(buildRequest(1, 'tools/list'));
const response = await waitForResponse(proc, 1);
expect(response).toMatchObject({
jsonrpc: '2.0',
id: 1,
result: {
tools: expect.arrayContaining([
expect.objectContaining({ name: 'resolve-library-id' }),
expect.objectContaining({ name: 'query-docs' })
])
}
});
});
it('resolve-library-id returns library search results', async () => {
proc.stdin!.write(
buildRequest(2, 'tools/call', {
name: 'resolve-library-id',
arguments: { libraryName: 'mylib', query: 'how to use mylib' }
})
);
const response = await waitForResponse(proc, 2);
expect(response).toMatchObject({
jsonrpc: '2.0',
id: 2,
result: {
content: [
{
type: 'text',
text: expect.stringContaining('/mock/mylib')
}
]
}
});
});
it('query-docs returns documentation text', async () => {
proc.stdin!.write(
buildRequest(3, 'tools/call', {
name: 'query-docs',
arguments: { libraryId: '/mock/mylib', query: 'how to install' }
})
);
const response = await waitForResponse(proc, 3);
expect(response).toMatchObject({
jsonrpc: '2.0',
id: 3,
result: {
content: [
{
type: 'text',
text: expect.stringContaining('Mock Library')
}
]
}
});
});
it('query-docs returns error message for 404', async () => {
proc.stdin!.write(
buildRequest(4, 'tools/call', {
name: 'query-docs',
arguments: { libraryId: '/not-found/lib', query: 'anything' }
})
);
const response = await waitForResponse(proc, 4);
expect(response).toMatchObject({
jsonrpc: '2.0',
id: 4,
result: {
isError: true,
content: [
{
type: 'text',
text: expect.stringContaining('not found')
}
]
}
});
});
it('query-docs returns indexing message for 503', async () => {
proc.stdin!.write(
buildRequest(5, 'tools/call', {
name: 'query-docs',
arguments: { libraryId: '/indexing/lib', query: 'anything' }
})
);
const response = await waitForResponse(proc, 5);
expect(response).toMatchObject({
jsonrpc: '2.0',
id: 5,
result: {
isError: true,
content: [
{
type: 'text',
text: expect.stringContaining('being indexed')
}
]
}
});
});
it('query-docs accepts optional tokens parameter', async () => {
proc.stdin!.write(
buildRequest(6, 'tools/call', {
name: 'query-docs',
arguments: { libraryId: '/mock/mylib', query: 'advanced usage', tokens: 5000 }
})
);
const response = await waitForResponse(proc, 6);
expect(response).toMatchObject({
jsonrpc: '2.0',
id: 6,
result: {
content: [{ type: 'text' }]
}
});
});
it('returns error for unknown tool', async () => {
proc.stdin!.write(
buildRequest(7, 'tools/call', {
name: 'no-such-tool',
arguments: {}
})
);
const response = await waitForResponse(proc, 7);
// MCP SDK may return an error result or our fallback
expect(response).toMatchObject({ jsonrpc: '2.0', id: 7 });
});
});