Files
ra-h-os/app/api/mcp/route.ts
T

632 lines
20 KiB
TypeScript

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<string, unknown>;
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': '*' },
}
);
}