managarten/services/mana-mcp/src/index.ts
Till JS 16c8818338 feat(mcp): M1+M1.5 MCP gateway + tool-registry + shared-crypto
Foundation for autonomous Claude-driven testing. Plan:
docs/plans/mana-mcp-and-personas.md.

New packages
- @mana/tool-registry — schema-first ToolSpec<InputSchema, OutputSchema>
  with zod generics, scope ('user-space' | 'admin') and policyHint
  ('read' | 'write' | 'destructive'). sync-client helpers speak the
  mana-sync push/pull protocol directly so RLS and field-level LWW are
  preserved. MasterKeyClient fetches per-user MKs via the existing
  mana-auth GET /api/v1/me/encryption-vault/key endpoint (JWT-gated,
  ZK-aware, already audited) — no new service-key endpoint built.
  ZeroKnowledgeUserError surfaced as a typed throw.
- @mana/shared-crypto — AES-GCM-256 primitives extracted from the web
  app's $lib/data/crypto/aes.ts so the server-side tool handlers and the
  browser produce byte-for-byte identical wire format
  (enc:1:{b64(iv)}.{b64(ct)}). Web app aes.ts now re-exports from
  shared-crypto — 5 existing importers unchanged, svelte-check stays
  green.

New service
- services/mana-mcp (:3069, Bun/Hono) — MCP Streamable HTTP gateway.
  JWKS auth against mana-auth, per-user session isolation (session-id
  belongs to the user who opened it — cross-user access returns 403),
  admin-scoped tools filtered out before registration. MasterKeyClient
  cached per process with a 5-minute TTL.

11 tools registered
- habits.{create,list,update,archive}, spaces.list (plaintext, M1)
- todo.{create,list,complete}, notes.{create,search}, journal.add
  (encrypted — field lists match
  apps/mana/apps/web/src/lib/data/crypto/registry.ts verbatim)

Infra
- Port 3069 added to docs/PORT_SCHEMA.md
- services/mana-mcp/CLAUDE.md with architecture, auth model,
  tool-authoring recipe, local smoke-test steps
- Root CLAUDE.md services list updated

Type-check green across shared-crypto, mana-tool-registry, mana-mcp.
svelte-check on apps/mana/apps/web stays at 0 errors / 0 warnings.
Boot smoke verified: /health returns registry.loaded=true, unauthed
/mcp → 401, invalid-JWT /mcp → 401 with descriptive message.

Decisions locked in for later milestones (per plan D1–D10):
- Personas will be real mana-auth users (users.kind='persona'), no
  service-key bypass (D1, D2)
- Tool-registry is the SSOT; mana-ai and the legacy
  apps/api/src/mcp/server.ts get merged into it in M4 (three current
  parallel tool catalogs collapse to one)
- Persona-runner (:3070) will be a separate service using the Claude
  Agent SDK + MCP client (D5)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:18:35 +02:00

69 lines
2.2 KiB
TypeScript

/**
* mana-mcp — MCP gateway service.
*
* Exposes `@mana/tool-registry` over Streamable HTTP, JWT-authed via
* mana-auth's JWKS. Per-user sessions; admin-scoped tools never reach
* the wire.
*
* Port: 3069. See services/mana-mcp/CLAUDE.md.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { registerAllModules } from '@mana/tool-registry';
import { loadConfig } from './config.ts';
import { authenticateRequest, UnauthorizedError } from './auth.ts';
import { handleMcpRequest } from './transport.ts';
// ─── Bootstrap ────────────────────────────────────────────────────
const config = loadConfig();
registerAllModules();
const app = new Hono();
app.use(
'*',
cors({
origin: config.corsOrigins,
allowHeaders: ['authorization', 'content-type', 'x-mana-space', 'mcp-session-id'],
exposeHeaders: ['mcp-session-id'],
credentials: true,
})
);
// ─── Health / metrics ─────────────────────────────────────────────
app.get('/health', (c) =>
c.json({
status: 'ok',
service: 'mana-mcp',
registry: { loaded: true },
})
);
app.get('/metrics', (c) =>
c.text('# mana-mcp metrics stub — populated alongside Persona-Runner observability\n')
);
// ─── MCP endpoint ─────────────────────────────────────────────────
app.all('/mcp', async (c) => {
let user;
try {
user = await authenticateRequest(c.req.raw, config.authUrl, config.jwtAudience);
} catch (err) {
const msg = err instanceof UnauthorizedError ? err.message : 'Unauthorized';
return c.json({ error: msg }, 401);
}
return handleMcpRequest(c.req.raw, user);
});
// ─── Server ───────────────────────────────────────────────────────
console.info(`[mana-mcp] listening on :${config.port} (auth=${config.authUrl})`);
export default {
port: config.port,
fetch: app.fetch,
};