managarten/services/mana-mcp/CLAUDE.md
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

6 KiB

mana-mcp

MCP (Model Context Protocol) gateway for Mana. External agents — Claude Desktop, Claude Code, the persona-runner — connect here to drive Mana modules over a single, JWT-authed protocol.

Plan: docs/plans/mana-mcp-and-personas.md

Tech Stack

Layer Technology
Runtime Bun
Framework Hono
Transport MCP Streamable HTTP (@modelcontextprotocol/sdk)
Auth JWT verify via JWKS from mana-auth (no service-key path)
Tools @mana/tool-registry (shared SSOT — also consumed by mana-ai)

Port: 3069

Quick Start

# Requires: mana-auth (3001) and mana-sync (3050) running
pnpm --filter @mana/mcp-service dev

Health check: curl localhost:3069/health

Architecture

External Agent (Claude Desktop, persona-runner, …)
         │
         │  POST /mcp                Authorization: Bearer <jwt>
         │  GET  /mcp                X-Mana-Space: <spaceId>
         │  DELETE /mcp              Mcp-Session-Id: <session>
         ▼
   ┌─────────────────────────────────┐
   │  src/index.ts (Hono :3069)      │
   │  ├── CORS                       │
   │  ├── /health, /metrics          │
   │  └── /mcp                       │
   │       │                         │
   │       ▼ authenticateRequest     │
   │       │  src/auth.ts            │
   │       │   (verify JWT via JWKS, │
   │       │    pull X-Mana-Space)   │
   │       ▼                         │
   │       handleMcpRequest          │
   │       src/transport.ts          │
   │       (per-user MCP session,    │
   │        scoped, no cross-user)   │
   │       │                         │
   │       ▼ createMcpServerForUser  │
   │       src/mcp-adapter.ts        │
   │       (registry → MCP tools)    │
   └────────────────┬────────────────┘
                    │
                    ▼
       @mana/tool-registry handlers
                    │
        ┌───────────┼────────────┐
        ▼           ▼            ▼
   mana-sync     mana-auth    (other services
   (push/pull)   (org list)    via tool handlers)

Auth model

Every MCP request must carry:

Header Required Purpose
Authorization: Bearer <jwt> yes EdDSA JWT issued by mana-auth, verified via JWKS
X-Mana-Space: <spaceId> yes Active Space — every tool write lands here
Mcp-Session-Id: <id> after init Session tracking; absent on first POST /mcp

No service-key path. Personas, the persona-runner, and any future agent client all hold real user JWTs. There is no admin bypass — admin-scoped tools (scope: 'admin') are silently filtered out before being registered with the MCP server.

Per-user session isolation. A session is created against a specific user. If a request later arrives with a session ID that belongs to a different user, the gateway returns 403. This is defense-in-depth against session-id collisions or a leaked session header.

Adding a tool

Tools are defined in packages/mana-tool-registry/src/modules/<module>.ts, never in this service. Steps:

  1. Open the relevant module file (or create a new one).
  2. Define the ToolSpec: name (module.verb), zod input/output schemas, scope, policyHint, handler.
  3. Add it to the module's register<Module>Tools() function.
  4. If new module: extend ModuleId in packages/mana-tool-registry/src/types.ts and call the new register function from modules/index.ts.

The MCP server picks up the tool on next restart — no service code change needed.

Policy gating reminder: scope: 'admin' tools never reach MCP clients. policyHint: 'destructive' tools are exposed but should be rare; prefer policyHint: 'write' with a soft-delete semantic.

Local smoke test (M1 exit gate)

Manual end-to-end check that proves: external client → MCP → mana-sync → Postgres.

# 1. Start the stack
pnpm docker:up                    # Postgres, Redis, MinIO
pnpm dev:auth                     # mana-auth on 3001
pnpm dev:sync                     # mana-sync on 3050
pnpm --filter @mana/mcp-service dev   # mana-mcp on 3069

# 2. Get a dev-user JWT
pnpm setup:dev-user                # creates dev@mana.test
# Then login to get a JWT — easiest path is via the web app dev-tools
# panel, or use a curl against /api/v1/auth/sign-in/email.

# 3. Fetch the user's active Space ID
curl -H "Authorization: Bearer $JWT" \
  http://localhost:3001/api/auth/organization/list

# 4. Configure Claude Code (.mcp.json in repo root or ~/.claude.json)
{
  "mcpServers": {
    "mana": {
      "type": "http",
      "url": "http://localhost:3069/mcp",
      "headers": {
        "Authorization": "Bearer <JWT>",
        "X-Mana-Space": "<SPACE_ID>"
      }
    }
  }
}

# 5. In Claude Code, ask: "List my mana habits, then create one called 'Spazieren'"
#    Verify a row appears in mana_sync.sync_changes for table='habits'.

Environment Variables

PORT=3069
MANA_AUTH_URL=http://localhost:3001
MANA_SYNC_URL=http://localhost:3050
JWT_AUDIENCE=mana
CORS_ORIGINS=http://localhost:5173

No provider keys, no DB connection — this service is stateless and forwards everything through tool handlers to other services.

Why a separate service (not folded into apps/api)

apps/api/src/mcp/server.ts already exists and exposes AI_TOOL_CATALOG over MCP. Per the plan (M4), that file and AI_TOOL_CATALOG get deleted once the new @mana/tool-registry covers all 67+ tools currently in mana-ai. Until then this service is the new path; the old endpoint stays for compatibility but is not extended.

Keeping mana-mcp standalone lets it be deployed independently, scaled separately (sessions are stateful in memory), and reasoned about as the single agent-facing entrypoint.