managarten/apps/api/src/mcp/server.ts
Till JS e969324cc8 feat(mcp): Phase 2 — real DB operations for tool execution
Implement actual sync_changes reads and writes for MCP tool calls:

- sync-db.ts: Connection to mana_sync DB, RLS-scoped withUser(),
  readLatestRecords() for replaying sync state, writeRecord() for
  creating sync_changes entries
- executor.ts: 10 tool handlers implemented:
  - Reads: list_tasks, get_task_stats, list_notes, get_todays_events,
    get_contacts, get_habits
  - Writes: create_task, complete_task, create_note, create_contact
  - Remaining tools return helpful "not yet implemented" message
- server.ts: userId from auth context bound into MCP session via closure
- index.ts: typed Hono app with AuthVariables

Write pattern matches mana-ai: INSERT into sync_changes with
actor={kind:'system', source:'mcp-tool'}, client_id='mcp-server'.
Records appear on user devices on next sync cycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:46:06 +02:00

114 lines
3.8 KiB
TypeScript

/**
* Mana MCP Server — exposes AI_TOOL_CATALOG as MCP tools for external
* clients (Claude Desktop, Cursor, VS Code Copilot, etc.).
*
* Uses the Streamable HTTP transport (WebStandard variant) which works
* natively with Hono/Bun. Clients connect via:
*
* POST /api/v1/mcp — send JSON-RPC messages
* GET /api/v1/mcp — open SSE stream for responses
* DELETE /api/v1/mcp — close session
*
* Auth: inherits the existing authMiddleware() from the parent Hono app,
* so every MCP request carries a valid JWT or API key.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
import { AI_TOOL_CATALOG } from '@mana/shared-ai';
import { executeMcpTool } from './executor';
import { z } from 'zod';
/** Convert ToolSchema parameters → Zod shape for McpServer.tool(). */
function toZodShape(
params: (typeof AI_TOOL_CATALOG)[number]['parameters']
): Record<string, z.ZodTypeAny> {
const shape: Record<string, z.ZodTypeAny> = {};
for (const p of params) {
let field: z.ZodTypeAny;
if (p.type === 'number') field = z.number().describe(p.description);
else if (p.type === 'boolean') field = z.boolean().describe(p.description);
else if (p.enum) field = z.enum(p.enum as [string, ...string[]]).describe(p.description);
else field = z.string().describe(p.description);
if (!p.required) field = field.optional();
shape[p.name] = field;
}
return shape;
}
/**
* Create a new McpServer instance with all Mana tools registered.
*/
function createMcpServer(userId: string): McpServer {
const server = new McpServer({ name: 'mana', version: '1.0.0' }, { capabilities: { tools: {} } });
// Register all 29 tools from the AI Tool Catalog.
// userId is bound via closure — each MCP session belongs to one user.
for (const tool of AI_TOOL_CATALOG) {
const zodShape = toZodShape(tool.parameters);
const hasParams = Object.keys(zodShape).length > 0;
if (hasParams) {
server.tool(tool.name, tool.description, zodShape, async (args) => {
return executeMcpTool(tool.name, args, userId);
});
} else {
server.tool(tool.name, tool.description, async () => {
return executeMcpTool(tool.name, {}, userId);
});
}
}
return server;
}
/** Map of active sessions → their transport instances. */
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
/**
* Handle an incoming HTTP request on the MCP endpoint.
*
* Supports stateful sessions: the transport generates a session ID on
* initialization, and subsequent requests must carry it via the
* `Mcp-Session-Id` header.
*/
export async function handleMcpRequest(req: Request, userId: string): Promise<Response> {
const sessionId = req.headers.get('mcp-session-id');
// Existing session — route to its transport
if (sessionId && sessions.has(sessionId)) {
const transport = sessions.get(sessionId)!;
return transport.handleRequest(req);
}
// New session (POST without session ID = initialization)
if (req.method === 'POST' && !sessionId) {
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (id) => {
sessions.set(id, transport);
},
onsessionclosed: (id) => {
sessions.delete(id);
},
});
const server = createMcpServer(userId);
await server.connect(transport);
return transport.handleRequest(req);
}
// Invalid request
if (sessionId && !sessions.has(sessionId)) {
return new Response(JSON.stringify({ error: 'Session not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ error: 'Bad request' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}