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

11
.claude/rules/trueref.md Normal file
View File

@@ -0,0 +1,11 @@
---
description: Use TrueRef to retrieve documentation for indexed libraries
alwaysApply: true
---
When answering questions about indexed libraries, always use the TrueRef MCP tools:
1. Call `resolve-library-id` with the library name and the user's question to get the library ID
2. Call `query-docs` with the library ID and question to retrieve relevant documentation
3. Use the returned documentation to answer the question accurately
Never rely on training data alone for library APIs that may have changed.

1044
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"format": "prettier --write .", "format": "prettier --write .",
"test:unit": "vitest", "test:unit": "vitest",
"test": "npm run test:unit -- --run", "test": "npm run test:unit -- --run",
"mcp:start": "tsx src/mcp/index.ts",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
@@ -49,6 +50,8 @@
"vitest-browser-svelte": "^2.0.2" "vitest-browser-svelte": "^2.0.2"
}, },
"dependencies": { "dependencies": {
"better-sqlite3": "^12.6.2" "@modelcontextprotocol/sdk": "^1.27.1",
"better-sqlite3": "^12.6.2",
"zod": "^4.3.6"
} }
} }

55
src/mcp/client.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* HTTP client for the TrueRef REST API.
*
* Configurable via the TRUEREF_API_URL environment variable.
* Defaults to http://localhost:5173 when not set.
*/
export const API_BASE = process.env.TRUEREF_API_URL ?? 'http://localhost:5173';
export interface ApiResponse {
ok: boolean;
status: number;
statusText: string;
text: () => Promise<string>;
}
/**
* Call GET /api/v1/libs/search
*
* Returns text/plain or JSON depending on the `type` param.
*/
export async function searchLibraries(params: {
libraryName: string;
query: string;
type?: 'json' | 'txt';
}): Promise<ApiResponse> {
const url = new URL(`${API_BASE}/api/v1/libs/search`);
url.searchParams.set('libraryName', params.libraryName);
url.searchParams.set('query', params.query);
url.searchParams.set('type', params.type ?? 'txt');
return fetch(url.toString());
}
/**
* Call GET /api/v1/context
*
* Returns text/plain or JSON depending on the `type` param.
*/
export async function fetchContext(params: {
libraryId: string;
query: string;
tokens?: number;
type?: 'json' | 'txt';
}): Promise<ApiResponse> {
const url = new URL(`${API_BASE}/api/v1/context`);
url.searchParams.set('libraryId', params.libraryId);
url.searchParams.set('query', params.query);
url.searchParams.set('type', params.type ?? 'txt');
if (params.tokens !== undefined) {
url.searchParams.set('tokens', String(params.tokens));
}
return fetch(url.toString());
}

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 });
});
});

84
src/mcp/index.ts Normal file
View File

@@ -0,0 +1,84 @@
/**
* TrueRef MCP Server — stdio transport
*
* Exposes resolve-library-id and query-docs tools, identical to context7's
* MCP interface, for drop-in compatibility with Claude Code, Cursor, and
* other MCP-aware AI coding assistants.
*
* Configuration:
* TRUEREF_API_URL Base URL for the TrueRef REST API (default: http://localhost:5173)
*
* Usage:
* npx tsx src/mcp/index.ts
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
import {
RESOLVE_LIBRARY_ID_TOOL,
handleResolveLibraryId
} from './tools/resolve-library-id.js';
import { QUERY_DOCS_TOOL, handleQueryDocs } from './tools/query-docs.js';
// ---------------------------------------------------------------------------
// Server setup
// ---------------------------------------------------------------------------
const server = new Server(
{
name: 'io.github.trueref/trueref',
version: '1.0.0'
},
{
capabilities: { tools: {} }
}
);
// ---------------------------------------------------------------------------
// Tool registry
// ---------------------------------------------------------------------------
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [RESOLVE_LIBRARY_ID_TOOL, QUERY_DOCS_TOOL]
}));
// ---------------------------------------------------------------------------
// Tool dispatch
// ---------------------------------------------------------------------------
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'resolve-library-id') {
return handleResolveLibraryId(args);
}
if (name === 'query-docs') {
return handleQueryDocs(args);
}
return {
content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }],
isError: true
};
});
// ---------------------------------------------------------------------------
// Startup
// ---------------------------------------------------------------------------
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
// Server runs until process exits
}
main().catch((err) => {
process.stderr.write(`MCP server error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,98 @@
/**
* query-docs tool handler
*
* Fetches documentation and code examples from TrueRef for a specific library.
* Tool schema is identical to context7 for drop-in compatibility.
*/
import { z } from 'zod';
import { fetchContext } from '../client.js';
export const QueryDocsSchema = z.object({
libraryId: z
.string()
.describe('The TrueRef library ID obtained from resolve-library-id, e.g. /facebook/react'),
query: z
.string()
.describe('Specific question about the library to retrieve relevant documentation'),
tokens: z.number().optional().describe('Maximum token budget for the response (default: 10000)')
});
export type QueryDocsInput = z.infer<typeof QueryDocsSchema>;
export const QUERY_DOCS_TOOL = {
name: 'query-docs',
description: [
'Fetches documentation and code examples from TrueRef for a specific library.',
'Requires a library ID obtained from resolve-library-id.',
'Returns relevant snippets formatted for LLM consumption.',
'Call at most 3 times per user question.'
].join(' '),
inputSchema: {
type: 'object' as const,
properties: {
libraryId: {
type: 'string',
description: 'TrueRef library ID, e.g. /facebook/react'
},
query: {
type: 'string',
description: 'Specific question about the library'
},
tokens: {
type: 'number',
description: 'Max token budget (default: 10000)'
}
},
required: ['libraryId', 'query']
}
};
export async function handleQueryDocs(args: unknown) {
const { libraryId, query, tokens } = QueryDocsSchema.parse(args);
const response = await fetchContext({ libraryId, query, tokens, type: 'txt' });
if (!response.ok) {
const status = response.status;
if (status === 404) {
return {
content: [
{
type: 'text' as const,
text: `Library "${libraryId}" not found. Please run resolve-library-id first.`
}
],
isError: true
};
}
if (status === 503) {
return {
content: [
{
type: 'text' as const,
text: `Library "${libraryId}" is currently being indexed. Please try again in a moment.`
}
],
isError: true
};
}
return {
content: [
{
type: 'text' as const,
text: `Error fetching documentation: ${response.status} ${response.statusText}`
}
],
isError: true
};
}
const text = await response.text();
return {
content: [{ type: 'text' as const, text }]
};
}

View File

@@ -0,0 +1,63 @@
/**
* resolve-library-id tool handler
*
* Searches TrueRef to find a library matching the given name.
* Tool schema is identical to context7 for drop-in compatibility.
*/
import { z } from 'zod';
import { searchLibraries } from '../client.js';
export const ResolveLibraryIdSchema = z.object({
libraryName: z.string().describe('Library name to search for and resolve to a TrueRef library ID'),
query: z.string().describe("The user's question or context to help rank results")
});
export type ResolveLibraryIdInput = z.infer<typeof ResolveLibraryIdSchema>;
export const RESOLVE_LIBRARY_ID_TOOL = {
name: 'resolve-library-id',
description: [
'Searches TrueRef to find a library matching the given name.',
'Returns a list of matching libraries with their IDs.',
'ALWAYS call this tool before query-docs to get the correct library ID.',
'Call at most 3 times per user question.'
].join(' '),
inputSchema: {
type: 'object' as const,
properties: {
libraryName: {
type: 'string',
description: 'Library name to search for'
},
query: {
type: 'string',
description: "User's question for relevance ranking"
}
},
required: ['libraryName', 'query']
}
};
export async function handleResolveLibraryId(args: unknown) {
const { libraryName, query } = ResolveLibraryIdSchema.parse(args);
const response = await searchLibraries({ libraryName, query, type: 'txt' });
if (!response.ok) {
return {
content: [
{
type: 'text' as const,
text: `Error searching libraries: ${response.status} ${response.statusText}`
}
],
isError: true
};
}
const text = await response.text();
return {
content: [{ type: 'text' as const, text }]
};
}