managarten/apps/api/src/mcp/server.ts
Till JS db4dd437bd feat(api): MCP server endpoint — expose AI tools to external clients
Mount an MCP (Model Context Protocol) server at /api/v1/mcp in the
unified Hono API. External clients like Claude Desktop, Cursor, and
VS Code Copilot can discover and call all 29 Mana tools via the
standard MCP protocol.

Architecture:
- WebStandardStreamableHTTPServerTransport for Bun/Hono compatibility
- AI_TOOL_CATALOG → MCP tool definitions with JSON Schema (via Zod)
- Stateful sessions with Mcp-Session-Id header
- Auth via existing authMiddleware (JWT or API key)

Phase 1 scope: tools/list returns all 29 tools with schemas,
tools/call acknowledges with descriptive messages. Phase 2 will add
actual DB reads/writes via sync_changes.

Usage:
  Claude Desktop config:
  {"mcpServers": {"mana": {"url": "http://localhost:3060/api/v1/mcp"}}}

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

113 lines
3.7 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(): McpServer {
const server = new McpServer({ name: 'mana', version: '1.0.0' }, { capabilities: { tools: {} } });
// Register all 29 tools from the AI Tool Catalog
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, 'mcp-user');
});
} else {
server.tool(tool.name, tool.description, async () => {
return executeMcpTool(tool.name, {}, 'mcp-user');
});
}
}
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): 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();
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' },
});
}