mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 00:59:40 +02:00
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>
114 lines
3.8 KiB
TypeScript
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' },
|
|
});
|
|
}
|