mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
827b252090
commit
db4dd437bd
6 changed files with 508 additions and 5 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
67
apps/api/src/mcp/executor.ts
Normal file
67
apps/api/src/mcp/executor.ts
Normal 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
113
apps/api/src/mcp/server.ts
Normal 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
33
apps/api/src/mcp/tools.ts
Normal 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),
|
||||
}));
|
||||
Loading…
Add table
Add a link
Reference in a new issue