managarten/services/mana-mcp/CLAUDE.md
Till JS 25c3bb6cdf docs(mana-mcp,mana-ai): CLAUDE.md coverage for M1 agent-loop primitives
mana-mcp:
  - Policy-gate section: POLICY_MODE semantics, the four decision
    rules, where to find soak metrics during log-only burn-in.
  - /metrics section pointing at the Prometheus job.

mana-ai:
  - New v0.8 status block: reminderChannel wiring, the two live
    producers (tokenBudgetReminder active, retryLoopReminder dormant
    pending LoopState extension), why POLICY_MODE here is limited to
    freetext inspection, why parallel-reads have no effect until the
    tool-registry absorbs the full AI_TOOL_CATALOG (M4 of personas).

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

7.7 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.

Policy gate

Every tool call is evaluated by evaluatePolicy() from @mana/tool-registry before reaching the handler (see docs/plans/agent-loop-improvements-m1.md for the design, and docs/reports/claude-code-architecture.md for the Claude-Code UH1 precedent).

POLICY_MODE Behaviour
off Gate disabled. Legacy path.
log-only Default. Evaluates, increments metrics, never blocks. Used during soak.
enforce Deny decisions abort the call with the reminder payload attached to the MCP error.

Decisions are emitted as mana_mcp_policy_decisions_total{decision, reason, mode} on /metrics. During log-only soak, watch decision="deny" — those are the calls that WOULD have been blocked. If false-positive rate stays below ~1 % over a week, flip to enforce.

What the gate decides today

  1. scope: 'admin' → deny outright (defense-in-depth; isExposable already filters these, but mana-ai consuming the registry doesn't).
  2. policyHint: 'destructive' not in user's allowDestructive list → deny. Today the list is hard-coded to [] in settingsFor(); next PR sources it from the user profile.
  3. Rolling 30 calls / 60 s per tool per user → deny. Ring buffer in invocation-log.ts.
  4. Prompt-injection markers in freetext args → allow with decision=flagged. Non-blocking; signal only.

Metrics

GET /metrics exposes the mana_mcp_ registry (prom-client default metrics + policy + tool counters). Scraped by Prometheus at 30 s via the mana-mcp job in docker/prometheus/prometheus.yml.

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.