feat: add remote MCP endpoint for external agent access
- Add app/api/mcp/route.ts with read-only ls_* tools - Tools: ls_search_nodes, ls_get_nodes, ls_query_edges, ls_list_dimensions - Uses WebStandardStreamableHTTPServerTransport for Vercel serverless - Stateless mode (sessionIdGenerator: undefined) for proper serverless execution - CORS headers for cross-origin agent access - GET endpoint returns server info for debugging Usage: claude mcp add --transport http latent-space https://latentspace.ra-h.app/api/mcp
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Latent Space Hub - Remote MCP Endpoint
|
||||
*
|
||||
* A stateless, read-only MCP server for Vercel serverless.
|
||||
* Exposes ls_* tools for external agents to query the knowledge graph.
|
||||
*
|
||||
* Usage:
|
||||
* claude mcp add --transport http latent-space https://latentspace.ra-h.app/api/mcp
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
import { nodeService, edgeService } from '@/services/database';
|
||||
import { getSQLiteClient } from '@/services/database/sqlite-client';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 30; // 30 second timeout for tool calls
|
||||
|
||||
// Server info
|
||||
const SERVER_INFO = {
|
||||
name: 'latent-space-hub',
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const INSTRUCTIONS = [
|
||||
'This is the Latent Space Knowledge Hub - a searchable graph of Latent Space content.',
|
||||
'Use ls_search_nodes to find content by keyword (newsletters, podcasts, talks, etc.).',
|
||||
'Use ls_get_nodes to load full node content by ID.',
|
||||
'Use ls_query_edges to explore connections between content.',
|
||||
'Use ls_list_dimensions to see content categories (AI News, Podcast, People, etc.).',
|
||||
'This is a read-only API - you cannot modify the knowledge graph.',
|
||||
].join(' ');
|
||||
|
||||
/**
|
||||
* Create a fresh MCP server instance with read-only ls_* tools
|
||||
*/
|
||||
function createLSServer(): McpServer {
|
||||
const server = new McpServer(SERVER_INFO, {
|
||||
instructions: INSTRUCTIONS,
|
||||
capabilities: { tools: {} },
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ls_search_nodes - Full-text search across all content
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'ls_search_nodes',
|
||||
{
|
||||
title: 'Search Latent Space content',
|
||||
description: 'Search the Latent Space knowledge graph by keyword. Returns matching nodes (newsletters, podcasts, talks, people, topics, etc.).',
|
||||
inputSchema: {
|
||||
query: z.string().min(1).max(400).describe('Search query (keywords)'),
|
||||
limit: z.number().min(1).max(50).optional().describe('Max results (default 20)'),
|
||||
dimensions: z.array(z.string()).max(5).optional().describe('Filter by dimensions (e.g., ["Podcast", "AI News"])'),
|
||||
},
|
||||
},
|
||||
async ({ query, limit = 20, dimensions }) => {
|
||||
const filters: any = {
|
||||
search: query.trim(),
|
||||
limit: Math.min(Math.max(limit, 1), 50),
|
||||
};
|
||||
|
||||
if (dimensions && dimensions.length > 0) {
|
||||
filters.dimensions = dimensions;
|
||||
}
|
||||
|
||||
const nodes = await nodeService.getNodes(filters);
|
||||
|
||||
const summary = nodes.length === 0
|
||||
? `No results found for "${query}".`
|
||||
: `Found ${nodes.length} result(s) for "${query}".`;
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: summary }],
|
||||
structuredContent: {
|
||||
count: nodes.length,
|
||||
nodes: nodes.map((node: any) => ({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
description: node.description ?? null,
|
||||
link: node.link ?? null,
|
||||
dimensions: node.dimensions || [],
|
||||
updated_at: node.updated_at,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ls_get_nodes - Load full node content by ID
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'ls_get_nodes',
|
||||
{
|
||||
title: 'Get Latent Space nodes by ID',
|
||||
description: 'Load full content of specific nodes by their IDs. Use this after searching to get complete details.',
|
||||
inputSchema: {
|
||||
nodeIds: z.array(z.number().int().positive()).min(1).max(10).describe('Node IDs to load (max 10)'),
|
||||
},
|
||||
},
|
||||
async ({ nodeIds }) => {
|
||||
const uniqueIds = Array.from(new Set(nodeIds.filter(id => Number.isFinite(id) && id > 0)));
|
||||
|
||||
if (uniqueIds.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'No valid node IDs provided.' }],
|
||||
structuredContent: { count: 0, nodes: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const nodes: any[] = [];
|
||||
for (const id of uniqueIds) {
|
||||
try {
|
||||
const node = await nodeService.getNodeById(id);
|
||||
if (node) {
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
description: node.description ?? null,
|
||||
content: node.content ?? null,
|
||||
link: node.link ?? null,
|
||||
dimensions: node.dimensions || [],
|
||||
metadata: node.metadata || {},
|
||||
created_at: node.created_at,
|
||||
updated_at: node.updated_at,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip missing nodes
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Loaded ${nodes.length} of ${uniqueIds.length} nodes.` }],
|
||||
structuredContent: { count: nodes.length, nodes },
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ls_query_edges - Find connections between content
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'ls_query_edges',
|
||||
{
|
||||
title: 'Query Latent Space connections',
|
||||
description: 'Find connections (edges) between nodes. Use nodeId to get all connections for a specific node.',
|
||||
inputSchema: {
|
||||
nodeId: z.number().int().positive().optional().describe('Find edges connected to this node'),
|
||||
limit: z.number().min(1).max(100).optional().describe('Max edges to return (default 50)'),
|
||||
},
|
||||
},
|
||||
async ({ nodeId, limit = 50 }) => {
|
||||
let edges: any[];
|
||||
|
||||
if (nodeId) {
|
||||
// Get edges for a specific node using getNodeConnections
|
||||
const connections = await edgeService.getNodeConnections(nodeId);
|
||||
edges = connections.slice(0, limit).map(c => c.edge);
|
||||
} else {
|
||||
// Get all edges (limited)
|
||||
edges = await edgeService.getEdges();
|
||||
edges = edges.slice(0, limit);
|
||||
}
|
||||
|
||||
// Parse context if it's a string (SQLite returns JSON as string)
|
||||
const parseContext = (ctx: any) => {
|
||||
if (typeof ctx === 'string') {
|
||||
try { return JSON.parse(ctx); } catch { return {}; }
|
||||
}
|
||||
return ctx || {};
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Found ${edges.length} connection(s).` }],
|
||||
structuredContent: {
|
||||
count: edges.length,
|
||||
edges: edges.map((e: any) => {
|
||||
const ctx = parseContext(e.context);
|
||||
return {
|
||||
id: e.id,
|
||||
from_node_id: e.from_node_id,
|
||||
to_node_id: e.to_node_id,
|
||||
explanation: ctx.explanation ?? null,
|
||||
type: ctx.type ?? null,
|
||||
created_at: e.created_at,
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ls_list_dimensions - List all content categories
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'ls_list_dimensions',
|
||||
{
|
||||
title: 'List Latent Space dimensions',
|
||||
description: 'List all content categories (dimensions) in the knowledge graph, with node counts. Dimensions include: AI News, Podcast, People, Companies, Topics, etc.',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
const sqlite = getSQLiteClient();
|
||||
|
||||
const result = sqlite.query(`
|
||||
WITH dimension_counts AS (
|
||||
SELECT nd.dimension, COUNT(*) AS count
|
||||
FROM node_dimensions nd
|
||||
GROUP BY nd.dimension
|
||||
)
|
||||
SELECT
|
||||
d.name AS dimension,
|
||||
d.description,
|
||||
COALESCE(dc.count, 0) AS count
|
||||
FROM dimensions d
|
||||
LEFT JOIN dimension_counts dc ON dc.dimension = d.name
|
||||
ORDER BY dc.count DESC, d.name ASC
|
||||
`);
|
||||
|
||||
const dimensions = result.rows.map((row: any) => ({
|
||||
name: row.dimension,
|
||||
description: row.description ?? null,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
|
||||
const totalNodes = dimensions.reduce((sum, d) => sum + d.count, 0);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `${dimensions.length} dimensions, ${totalNodes} total nodes.` }],
|
||||
structuredContent: {
|
||||
count: dimensions.length,
|
||||
totalNodes,
|
||||
dimensions,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MCP POST requests (tool calls)
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Create fresh instances for stateless serverless execution
|
||||
const server = createLSServer();
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined, // Stateless mode - critical for serverless
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
// Handle the request - WebStandardStreamableHTTPServerTransport accepts web Request
|
||||
const response = await transport.handleRequest(req);
|
||||
|
||||
// Cleanup
|
||||
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': '*',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle preflight CORS requests
|
||||
*/
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET returns server info (not required for MCP, but useful for debugging)
|
||||
*/
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
name: SERVER_INFO.name,
|
||||
version: SERVER_INFO.version,
|
||||
description: 'Latent Space Knowledge Hub - Read-only MCP server',
|
||||
tools: ['ls_search_nodes', 'ls_get_nodes', 'ls_query_edges', 'ls_list_dimensions'],
|
||||
usage: 'claude mcp add --transport http latent-space https://latentspace.ra-h.app/api/mcp',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
Generated
+829
-14
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
||||
"@ai-sdk/openai": "^3.0.7",
|
||||
"@langchain/core": "^1.0.1",
|
||||
"@langchain/textsplitters": "^1.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.3",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.27",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
|
||||
Reference in New Issue
Block a user