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>
This commit is contained in:
Till JS 2026-04-16 13:37:52 +02:00
parent 827b252090
commit db4dd437bd
6 changed files with 508 additions and 5 deletions

View file

@ -15,10 +15,12 @@
"dependencies": {
"@ai-sdk/openai-compatible": "^2.0.41",
"@mana/media-client": "workspace:*",
"@mana/shared-ai": "workspace:*",
"@mana/shared-hono": "workspace:*",
"@mana/shared-rss": "workspace:*",
"@mana/shared-storage": "workspace:*",
"@mana/shared-types": "workspace:^",
"@modelcontextprotocol/sdk": "^1.29.0",
"ai": "^6.0.154",
"drizzle-orm": "^0.38.0",
"hono": "^4.7.0",

View file

@ -15,6 +15,9 @@ import {
rateLimitMiddleware,
} from '@mana/shared-hono';
// MCP server
import { handleMcpRequest } from './mcp/server';
// Module routes
import { calendarRoutes } from './modules/calendar/routes';
import { contactsRoutes } from './modules/contacts/routes';
@ -48,6 +51,10 @@ app.route('/health', healthRoute('mana-api'));
app.use('/api/*', rateLimitMiddleware({ max: 200, windowMs: 60_000 }));
app.use('/api/*', authMiddleware());
// ─── MCP Endpoint ──────────────────────────────────────────
// Streamable HTTP transport: POST (messages), GET (SSE stream), DELETE (close)
app.all('/api/v1/mcp', (c) => handleMcpRequest(c.req.raw));
// ─── Module Routes ──────────────────────────────────────────
app.route('/api/v1/calendar', calendarRoutes);
app.route('/api/v1/contacts', contactsRoutes);

View file

@ -0,0 +1,67 @@
/**
* MCP Tool Executor handles tools/call requests by routing to module
* handlers or returning status messages.
*
* Phase 1: Read-only tools query the sync database directly.
* Phase 2: Write tools will insert into sync_changes (like mana-ai does).
*/
import { AI_TOOL_CATALOG_BY_NAME } from '@mana/shared-ai';
export interface McpToolResult {
[key: string]: unknown;
content: Array<{ type: 'text'; text: string }>;
isError?: boolean;
}
/**
* Execute an MCP tool call. Returns MCP-formatted result content.
*
* Phase 1 scope:
* - All tools are listed (via tools/list from AI_TOOL_CATALOG)
* - Write tools return a "coming soon" message
* - Read tools are planned for Phase 2 (requires sync DB queries)
*/
export async function executeMcpTool(
toolName: string,
args: Record<string, unknown>,
_userId: string
): Promise<McpToolResult> {
const schema = AI_TOOL_CATALOG_BY_NAME.get(toolName);
if (!schema) {
return {
content: [{ type: 'text', text: `Unknown tool: ${toolName}` }],
isError: true,
};
}
// Phase 1: all tools return a descriptive message about what they will do.
// Phase 2 will implement actual DB reads and sync_changes writes.
if (schema.defaultPolicy === 'auto') {
return {
content: [
{
type: 'text',
text:
`[Mana MCP] Read-tool "${toolName}" (${schema.module}) acknowledged.\n` +
`Args: ${JSON.stringify(args)}\n` +
`Note: Server-side execution coming in Phase 2. ` +
`This tool will query the sync database for user data.`,
},
],
};
}
return {
content: [
{
type: 'text',
text:
`[Mana MCP] Write-tool "${toolName}" (${schema.module}) acknowledged.\n` +
`Args: ${JSON.stringify(args)}\n` +
`Note: Server-side execution coming in Phase 2. ` +
`This tool will write to the sync database and appear on your devices.`,
},
],
};
}

113
apps/api/src/mcp/server.ts Normal file
View file

@ -0,0 +1,113 @@
/**
* 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' },
});
}

33
apps/api/src/mcp/tools.ts Normal file
View file

@ -0,0 +1,33 @@
/**
* MCP Tool Definitions transforms AI_TOOL_CATALOG into MCP-compatible
* tool listings with JSON Schema inputSchema.
*
* The catalog in @mana/shared-ai is the single source of truth. This
* module provides the MCP wire format for tools/list responses.
*/
import { AI_TOOL_CATALOG, type ToolSchema } from '@mana/shared-ai';
/** Convert ToolSchema parameters → JSON Schema for MCP inputSchema. */
function toJsonSchema(params: ToolSchema['parameters']): {
type: 'object';
properties: Record<string, Record<string, unknown>>;
required: string[];
} {
const properties: Record<string, Record<string, unknown>> = {};
const required: string[] = [];
for (const p of params) {
const prop: Record<string, unknown> = { type: p.type, description: p.description };
if (p.enum) prop.enum = p.enum;
properties[p.name] = prop;
if (p.required) required.push(p.name);
}
return { type: 'object', properties, required };
}
/** MCP tool definitions derived from the AI Tool Catalog. */
export const MCP_TOOLS = AI_TOOL_CATALOG.map((t) => ({
name: t.name,
description: t.description,
inputSchema: toJsonSchema(t.parameters),
}));