import { NextRequest, NextResponse } from 'next/server'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; import { z } from 'zod'; export const runtime = 'nodejs'; export const maxDuration = 30; const SERVER_INFO = { name: 'ra-h-mcp', version: '1.0.0', }; const instructions = [ 'RA-H is a personal knowledge graph — local-first, vendor-neutral.', 'Core concepts: contexts (optional soft scopes), nodes (knowledge units), and edges (connections with explanations).', 'Always call rah_get_context first to orient yourself — it returns contexts, hub nodes, stats, and available guides.', 'Use contexts only when they are explicit and helpful. Do not expect automatic context assignment.', 'Search before creating: use rah_search_nodes to check if content already exists.', 'Every edge needs an explanation: why does this connection exist?', ].join(' '); function getBaseUrl(request: NextRequest): string { const envBase = process.env.RAH_MCP_TARGET_URL || process.env.NEXT_PUBLIC_BASE_URL; return (envBase || request.nextUrl.origin).replace(/\/+$/, ''); } async function callRaHApi(request: NextRequest, pathname: string, options: RequestInit = {}) { const response = await fetch(`${getBaseUrl(request)}${pathname}`, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}), }, }); const payload = await response.json().catch(() => null); if (!response.ok || !payload || payload.success === false) { throw new Error(payload?.error || `RA-H API request failed at ${pathname}`); } return payload; } function createServer(request: NextRequest): McpServer { const server = new McpServer(SERVER_INFO, { instructions, capabilities: { tools: {} }, }); server.registerTool( 'rah_add_node', { title: 'Add RA-H node', description: 'Create a new node in the local RA-H knowledge base. Set context explicitly when clear and useful; otherwise leave it empty.', inputSchema: { title: z.string().min(1).max(160), content: z.string().max(20000).optional(), source: z.string().max(50000).optional(), link: z.string().url().optional(), description: z.string().max(500).optional(), context_id: z.number().int().positive().nullable().optional(), context_name: z.string().optional(), metadata: z.record(z.any()).optional(), chunk: z.string().max(50000).optional(), }, }, async ({ title, content, source, link, description, context_id, context_name, metadata, chunk }) => { const payload = await callRaHApi(request, '/api/nodes', { method: 'POST', body: JSON.stringify({ title: title.trim(), source: source?.trim() || content?.trim() || chunk?.trim() || undefined, link: link?.trim() || undefined, description: description?.trim() || undefined, context_id, context_name: context_name?.trim() || undefined, metadata: metadata || {}, }), }); const node = payload.data; return { content: [{ type: 'text', text: `Created node #${node.id}: ${node.title}` }], structuredContent: { nodeId: node.id, title: node.title, message: payload.message || `Created node #${node.id}: ${node.title}`, }, }; } ); server.registerTool( 'rah_search_nodes', { title: 'Search RA-H nodes', description: 'Find existing RA-H entries that mention a topic before adding new ones.', inputSchema: { query: z.string().min(1).max(400), limit: z.number().min(1).max(25).optional(), contextId: z.number().int().positive().optional(), }, }, async ({ query, limit = 10, contextId }) => { const params = new URLSearchParams(); params.set('search', query.trim()); params.set('limit', String(Math.min(Math.max(limit, 1), 25))); if (contextId) params.set('contextId', String(contextId)); const payload = await callRaHApi(request, `/api/nodes?${params.toString()}`); const nodes = Array.isArray(payload.data) ? payload.data : []; return { content: [{ type: 'text', text: nodes.length === 0 ? 'No existing RA-H nodes mention that topic yet.' : `Found ${nodes.length} node(s) mentioning that topic.` }], structuredContent: { count: nodes.length, nodes: nodes.map((node: any) => ({ id: node.id, title: node.title, source: node.source ?? null, description: node.description ?? null, link: node.link ?? null, updated_at: node.updated_at, })), }, }; } ); server.registerTool( 'rah_query_contexts', { title: 'Query RA-H contexts', description: 'List or inspect contexts, the soft organizational scope layer for the graph.', inputSchema: { contextId: z.number().int().positive().optional(), name: z.string().optional(), search: z.string().optional(), limit: z.number().min(1).max(100).optional(), includeNodes: z.boolean().optional(), }, }, async ({ contextId, name, search, limit = 50, includeNodes = false }) => { const normalizedName = typeof name === 'string' ? name.trim().toLowerCase() : ''; const normalizedSearch = typeof search === 'string' ? search.trim().toLowerCase() : ''; let contexts: any[] = []; if (contextId) { const payload = await callRaHApi(request, `/api/contexts/${contextId}`); contexts = payload.data ? [payload.data] : []; } else { const payload = await callRaHApi(request, '/api/contexts'); contexts = Array.isArray(payload.data) ? payload.data : []; } if (normalizedName) { contexts = contexts.filter((context) => context.name.toLowerCase() === normalizedName); } if (normalizedSearch) { contexts = contexts.filter((context) => context.name.toLowerCase().includes(normalizedSearch) || (context.description || '').toLowerCase().includes(normalizedSearch) ); } contexts = contexts.slice(0, Math.min(Math.max(limit, 1), 100)); const structuredContexts = await Promise.all( contexts.map(async (context) => { if (!(includeNodes && contexts.length === 1)) { return { id: context.id, name: context.name, description: context.description ?? null, icon: context.icon ?? null, count: context.count ?? 0, }; } const nodesPayload = await callRaHApi(request, `/api/contexts/${context.id}/nodes`); const nodes = Array.isArray(nodesPayload.data) ? nodesPayload.data : []; return { id: context.id, name: context.name, description: context.description ?? null, icon: context.icon ?? null, count: context.count ?? nodes.length, nodes: nodes.map((node: any) => ({ id: node.id, title: node.title, description: node.description ?? null, link: node.link ?? null, context_id: node.context_id ?? null, updated_at: node.updated_at, })), }; }) ); return { content: [{ type: 'text', text: structuredContexts.length === 0 ? 'No contexts found.' : `Found ${structuredContexts.length} context(s).` }], structuredContent: { count: structuredContexts.length, contexts: structuredContexts, }, }; } ); server.registerTool( 'rah_update_node', { title: 'Update RA-H node', description: 'Update an existing node. Context remains optional and explicit.', inputSchema: { id: z.number().int().positive(), updates: z.object({ title: z.string().optional(), description: z.string().max(500).optional(), content: z.string().optional(), source: z.string().optional(), link: z.string().optional(), context_id: z.number().int().positive().nullable().optional(), metadata: z.record(z.any()).optional(), }), }, }, async ({ id, updates }) => { const mappedUpdates = { ...updates } as Record; if (mappedUpdates.content !== undefined && mappedUpdates.source === undefined) { mappedUpdates.source = mappedUpdates.content; } delete mappedUpdates.content; const payload = await callRaHApi(request, `/api/nodes/${id}`, { method: 'PUT', body: JSON.stringify(mappedUpdates), }); return { content: [{ type: 'text', text: `Updated node #${id}` }], structuredContent: { success: true, nodeId: payload.node?.id || id, message: payload.message || `Updated node #${id}`, }, }; } ); server.registerTool( 'rah_get_nodes', { title: 'Get RA-H nodes by ID', description: 'Load full node records by their IDs.', inputSchema: { nodeIds: z.array(z.number().int().positive()).min(1).max(10), }, }, async ({ nodeIds }) => { const uniqueIds = Array.from(new Set(nodeIds.filter((id) => Number.isFinite(id) && id > 0))); const nodes: any[] = []; for (const id of uniqueIds) { try { const payload = await callRaHApi(request, `/api/nodes/${id}`); if (payload.node) { nodes.push({ id: payload.node.id, title: payload.node.title, source: payload.node.source ?? null, link: payload.node.link ?? null, updated_at: payload.node.updated_at, }); } } catch { // Skip missing nodes. } } return { content: [{ type: 'text', text: `Loaded ${nodes.length} of ${uniqueIds.length} nodes.` }], structuredContent: { count: nodes.length, nodes, }, }; } ); server.registerTool( 'rah_create_edge', { title: 'Create RA-H edge', description: 'Create a connection between two nodes.', inputSchema: { sourceId: z.number().int().positive(), targetId: z.number().int().positive(), explanation: z.string().min(1), }, }, async ({ sourceId, targetId, explanation }) => { const payload = await callRaHApi(request, '/api/edges', { method: 'POST', body: JSON.stringify({ from_node_id: sourceId, to_node_id: targetId, explanation: explanation.trim(), source: 'helper_name', created_via: 'mcp', }), }); const edge = payload.edge || payload.data; return { content: [{ type: 'text', text: `Created edge from #${sourceId} to #${targetId}` }], structuredContent: { success: true, edgeId: edge?.id || 0, message: payload.message || `Created edge from #${sourceId} to #${targetId}`, }, }; } ); server.registerTool( 'rah_query_edges', { title: 'Query RA-H edges', description: 'Find connections between nodes.', inputSchema: { nodeId: z.number().int().positive().optional(), limit: z.number().min(1).max(50).optional(), }, }, async ({ nodeId, limit = 25 }) => { const params = new URLSearchParams(); if (nodeId) params.set('nodeId', String(nodeId)); params.set('limit', String(Math.min(Math.max(limit, 1), 50))); const payload = await callRaHApi(request, `/api/edges?${params.toString()}`); const edges = Array.isArray(payload.data) ? payload.data : []; return { content: [{ type: 'text', text: `Found ${edges.length} edge(s).` }], structuredContent: { count: edges.length, edges: edges.map((edge: any) => ({ id: edge.id, source_id: edge.from_node_id, target_id: edge.to_node_id, type: edge.context?.type ?? null, weight: typeof edge.context?.confidence === 'number' ? edge.context.confidence : null, })), }, }; } ); server.registerTool( 'rah_update_edge', { title: 'Update RA-H edge', description: 'Update an existing edge connection.', inputSchema: { id: z.number().int().positive(), explanation: z.string().min(1), }, }, async ({ id, explanation }) => { const payload = await callRaHApi(request, `/api/edges/${id}`, { method: 'PUT', body: JSON.stringify({ context: { explanation: explanation.trim(), created_via: 'mcp' }, }), }); return { content: [{ type: 'text', text: `Updated edge #${id}` }], structuredContent: { success: true, message: payload.message || `Updated edge #${id}`, }, }; } ); server.registerTool( 'rah_search_embeddings', { title: 'Semantic search RA-H', description: 'Search node content using semantic similarity.', inputSchema: { query: z.string().min(1), limit: z.number().min(1).max(20).optional(), }, }, async ({ query, limit = 10 }) => { const params = new URLSearchParams(); params.set('q', query); params.set('limit', String(Math.min(Math.max(limit, 1), 20))); const payload = await callRaHApi(request, `/api/nodes/search?${params.toString()}`); const results = Array.isArray(payload.data) ? payload.data : []; return { content: [{ type: 'text', text: `Found ${results.length} semantically similar result(s).` }], structuredContent: { count: results.length, results: results.map((result: any) => ({ nodeId: result.node_id || result.nodeId || result.id, title: result.title || 'Untitled', chunkPreview: (result.source || '').slice(0, 200), similarity: result.similarity || result.score || 0, })), }, }; } ); server.registerTool( 'rah_extract_url', { title: 'Extract URL content', description: 'Extract content from a webpage URL.', inputSchema: { url: z.string().url(), }, }, async ({ url }) => { const payload = await callRaHApi(request, '/api/extract/url', { method: 'POST', body: JSON.stringify({ url }), }); return { content: [{ type: 'text', text: `Extracted content from: ${payload.title || 'webpage'}` }], structuredContent: { success: true, title: payload.title || 'Untitled', source: payload.source || '', metadata: payload.metadata || {}, }, }; } ); server.registerTool( 'rah_extract_youtube', { title: 'Extract YouTube transcript', description: 'Extract transcript from a YouTube video.', inputSchema: { url: z.string(), }, }, async ({ url }) => { const payload = await callRaHApi(request, '/api/extract/youtube', { method: 'POST', body: JSON.stringify({ url }), }); return { content: [{ type: 'text', text: `Extracted transcript from: ${payload.title || 'YouTube video'}` }], structuredContent: { success: true, title: payload.title || 'Untitled', channel: payload.channel || 'Unknown', source: payload.source || '', metadata: payload.metadata || {}, }, }; } ); server.registerTool( 'rah_extract_pdf', { title: 'Extract PDF content', description: 'Extract content from a PDF file URL.', inputSchema: { url: z.string().url(), }, }, async ({ url }) => { const payload = await callRaHApi(request, '/api/extract/pdf', { method: 'POST', body: JSON.stringify({ url }), }); return { content: [{ type: 'text', text: `Extracted content from: ${payload.title || 'PDF document'}` }], structuredContent: { success: true, title: payload.title || 'Untitled PDF', source: payload.source || '', metadata: payload.metadata || {}, }, }; } ); server.registerTool( 'rah_get_context', { title: 'Get RA-H context', description: 'Get orientation context: contexts, hub nodes, stats, and available guides.', inputSchema: {}, }, async () => { const [hubPayload, contextsPayload, guidesPayload, countPayload] = await Promise.all([ callRaHApi(request, '/api/nodes?sortBy=edges&limit=5'), callRaHApi(request, '/api/contexts'), callRaHApi(request, '/api/guides').catch(() => ({ data: [] })), callRaHApi(request, '/api/nodes?limit=1').catch(() => ({ total: 0 })), ]); const hubNodes = Array.isArray(hubPayload.data) ? hubPayload.data.map((node: any) => ({ id: node.id, title: node.title, description: node.description ?? null, edgeCount: node.edge_count ?? 0, })) : []; const contexts = Array.isArray(contextsPayload.data) ? contextsPayload.data.map((context: any) => ({ id: context.id, name: context.name, description: context.description ?? null, icon: context.icon ?? null, count: context.count ?? 0, })) : []; const guides = Array.isArray(guidesPayload.data) ? guidesPayload.data.map((guide: any) => guide.name) : []; return { content: [{ type: 'text', text: `Knowledge graph: ${contexts.length} contexts, ${hubNodes.length} hub nodes, ${guides.length} guides available.` }], structuredContent: { stats: { nodeCount: countPayload.total ?? 0, edgeCount: 0, contextCount: contexts.length, }, hubNodes, contexts, guides, }, }; } ); return server; } export async function POST(request: NextRequest) { try { const server = createServer(request); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); await server.connect(transport); const response = await transport.handleRequest(request); await transport.close(); await server.close(); return response; } catch (error) { console.error('MCP request error:', error); return NextResponse.json( { jsonrpc: '2.0', error: { code: -32603, message: error instanceof Error ? error.message : 'Internal MCP error', }, }, { status: 500, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, } ); } } export async function OPTIONS() { return new NextResponse(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Accept, Mcp-Session-Id', }, }); } export async function GET(request: NextRequest) { const tools = [ 'rah_add_node', 'rah_search_nodes', 'rah_query_contexts', 'rah_update_node', 'rah_get_nodes', 'rah_create_edge', 'rah_query_edges', 'rah_update_edge', 'rah_search_embeddings', 'rah_extract_url', 'rah_extract_youtube', 'rah_extract_pdf', 'rah_get_context', ]; return NextResponse.json( { name: SERVER_INFO.name, version: SERVER_INFO.version, description: 'RA-H Knowledge Graph - Remote MCP Server', target: getBaseUrl(request), tools, }, { headers: { 'Access-Control-Allow-Origin': '*' }, } ); }