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>
This commit is contained in:
Till JS 2026-04-23 13:18:35 +02:00
parent f719d1768f
commit 16c8818338
31 changed files with 2958 additions and 360 deletions

View file

@ -34,7 +34,7 @@ docs/ # Long-form docs (deployment, hardware, postmortems, etc.)
### Active services (`services/`) ### Active services (`services/`)
`mana-auth` (3001), `mana-sync` (3050), `mana-credits`, `mana-user`, `mana-subscriptions`, `mana-analytics`, `mana-search` (3021), `mana-crawler`, `mana-api-gateway`, `mana-notify`, `mana-media`, `mana-llm`, `mana-image-gen`, `mana-video-gen`, `mana-stt`, `mana-tts`, `mana-voice-bot`, `mana-events`, `mana-geocoding` (3018), `mana-landing-builder`, `mana-ai` (3067, background AI Mission Runner — see [`services/mana-ai/CLAUDE.md`](services/mana-ai/CLAUDE.md)), `mana-research` (3068, web research provider orchestration across 16+ providers — see [`services/mana-research/CLAUDE.md`](services/mana-research/CLAUDE.md) and [`docs/plans/mana-research-service.md`](docs/plans/mana-research-service.md)). Each non-trivial service has its own `CLAUDE.md`. `mana-auth` (3001), `mana-sync` (3050), `mana-credits`, `mana-user`, `mana-subscriptions`, `mana-analytics`, `mana-search` (3021), `mana-crawler`, `mana-api-gateway`, `mana-notify`, `mana-media`, `mana-llm`, `mana-image-gen`, `mana-video-gen`, `mana-stt`, `mana-tts`, `mana-voice-bot`, `mana-events`, `mana-geocoding` (3018), `mana-landing-builder`, `mana-ai` (3067, background AI Mission Runner — see [`services/mana-ai/CLAUDE.md`](services/mana-ai/CLAUDE.md)), `mana-research` (3068, web research provider orchestration across 16+ providers — see [`services/mana-research/CLAUDE.md`](services/mana-research/CLAUDE.md) and [`docs/plans/mana-research-service.md`](docs/plans/mana-research-service.md)), `mana-mcp` (3069, MCP gateway exposing the shared tool-registry to Claude Desktop / Claude Code / persona-runner — see [`services/mana-mcp/CLAUDE.md`](services/mana-mcp/CLAUDE.md) and [`docs/plans/mana-mcp-and-personas.md`](docs/plans/mana-mcp-and-personas.md)). Each non-trivial service has its own `CLAUDE.md`.
## Coding Guidelines ## Coding Guidelines

View file

@ -58,6 +58,7 @@
"@mana/qr-export": "workspace:*", "@mana/qr-export": "workspace:*",
"@mana/shared-ai": "workspace:*", "@mana/shared-ai": "workspace:*",
"@mana/shared-auth": "workspace:*", "@mana/shared-auth": "workspace:*",
"@mana/shared-crypto": "workspace:*",
"@mana/shared-auth-ui": "workspace:*", "@mana/shared-auth-ui": "workspace:*",
"@mana/shared-branding": "workspace:*", "@mana/shared-branding": "workspace:*",
"@mana/shared-error-tracking": "workspace:*", "@mana/shared-error-tracking": "workspace:*",

View file

@ -1,186 +1,22 @@
/** /**
* AES-GCM-256 wrap/unwrap primitives. * AES-GCM-256 wrap/unwrap primitives thin re-export from `@mana/shared-crypto`.
* *
* Pure crypto layer with no state and no Dexie dependency. The higher-level * The implementation moved to the shared package on 2026-04-22 as part of
* registry/key-provider modules use these to encrypt configured fields on * M1.5 of the MCP/Personas plan mana-mcp tool handlers need byte-for-byte
* the way into IndexedDB and decrypt them on the way out. * identical wire format, so both the web app and server-side consumers
* import from the same source.
* *
* Wire format * All prior importers in this app keep working against `$lib/data/crypto/aes`
* `enc:${VERSION}:${base64(iv)}.${base64(ct)}` * the module surface is unchanged.
*
* The string-prefix format (rather than a JSON envelope) is deliberate:
* - One scan to detect "is this encrypted?" `value.startsWith('enc:1:')`
* - Survives JSON.stringify when records flow through the sync wire
* - Compact: ~1.4× the original byte length, vs ~2× for a JSON envelope
* - Trivial to bump VERSION for future format migrations
*
* Authenticated encryption: AES-GCM provides both confidentiality and
* tamper-detection. A modified ciphertext fails decryption with an
* OperationError instead of returning silent garbage `unwrapValue`
* surfaces that as a thrown error so callers can react.
*
* Value types: anything JSON-serialisable. The plaintext is JSON.stringified
* before encryption, JSON.parsed after decryption. `null` and `undefined`
* pass through unchanged so callers can blindly wrap optional fields
* without checking each one first.
*/ */
/** Bumped if the wire format ever changes. Old blobs stay readable as long export {
* as `unwrapValue` knows how to handle their version prefix. */ ENC_PREFIX,
export const ENCRYPTION_VERSION = 1; ENCRYPTION_VERSION,
exportMasterKey,
/** All encrypted blobs start with this exact prefix — used by `isEncrypted`. */ generateMasterKey,
export const ENC_PREFIX = `enc:${ENCRYPTION_VERSION}:`; importMasterKey,
isEncrypted,
/** AES-GCM standard IV length is 96 bits (12 bytes). Larger IVs are not unwrapValue,
* recommended by NIST and would only burn entropy. */ wrapValue,
const IV_LENGTH = 12; } from '@mana/shared-crypto';
// ─── Base64 helpers ───────────────────────────────────────────
//
// We avoid `btoa(String.fromCharCode(...bytes))` because the spread operator
// hits the JS argument limit (~65k) for large records. The manual loop is
// O(n) and works for any size.
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
/**
* TypeScript 5.7+ parameterised Uint8Array with the underlying buffer
* type, which now includes SharedArrayBuffer. Web Crypto's `BufferSource`
* type still expects a plain ArrayBuffer-backed view, so we need to copy
* the bytes through a fresh ArrayBuffer to satisfy the strict type check.
*
* This is a TypeScript-only annoyance at runtime the call would have
* worked fine with the original Uint8Array. The copy is O(n) and
* negligible for the field sizes we encrypt (< 100 KB typical).
*/
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
const buf = new ArrayBuffer(bytes.length);
new Uint8Array(buf).set(bytes);
return buf;
}
// ─── Public API ───────────────────────────────────────────────
/**
* Returns true iff `value` is a string carrying the encryption prefix.
*
* Cheap synchronous detection no decryption attempted. Use this to
* decide whether a field needs to be unwrapped on read, or whether a
* value coming back from a backend pull is already encrypted.
*/
export function isEncrypted(value: unknown): boolean {
return typeof value === 'string' && value.startsWith(ENC_PREFIX);
}
/**
* Encrypts `value` with `key` and returns the wire-format string. Pass-
* through for `null` / `undefined` so optional-field call sites stay
* concise:
*
* record.title = await wrapValue(record.title, key);
* record.notes = await wrapValue(record.notes, key); // safe even if null
*
* Throws if `key` is unusable (wrong algorithm, wrong usages). Each call
* generates a fresh random IV never reuse one for the same key.
*/
export async function wrapValue(value: unknown, key: CryptoKey): Promise<unknown> {
if (value === null || value === undefined) return value;
const json = JSON.stringify(value);
const plaintext = new TextEncoder().encode(json);
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: toBufferSource(iv) },
key,
toBufferSource(plaintext)
);
return ENC_PREFIX + bytesToBase64(iv) + '.' + bytesToBase64(new Uint8Array(ct));
}
/**
* Decrypts a wire-format string back to its original JS value. Pass-
* through for non-strings, `null`/`undefined`, and any string that
* doesn't carry the encryption prefix that way `unwrapValue` is safe
* to apply unconditionally to mixed records.
*
* Throws on tampered ciphertext (AES-GCM auth tag mismatch), malformed
* blobs, or wrong key. Callers should treat the throw as data corruption
* there's no soft-recovery path.
*/
export async function unwrapValue(blob: unknown, key: CryptoKey): Promise<unknown> {
if (!isEncrypted(blob)) return blob;
const body = (blob as string).slice(ENC_PREFIX.length);
const dotIndex = body.indexOf('.');
if (dotIndex === -1) {
throw new Error('mana-crypto: malformed encrypted blob (missing iv/ct separator)');
}
const iv = base64ToBytes(body.slice(0, dotIndex));
const ct = base64ToBytes(body.slice(dotIndex + 1));
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: toBufferSource(iv) },
key,
toBufferSource(ct)
);
const json = new TextDecoder().decode(plaintext);
return JSON.parse(json);
}
/**
* Generates a fresh AES-GCM-256 key. Used at vault initialisation time
* (Phase 2: server-side; tests: in-memory) to mint the per-user master
* key. The key is `extractable: true` so the server can wrap it with
* the KEK before storing set to `false` for client-side derived keys
* that should never leave the browser.
*/
export async function generateMasterKey(extractable = true): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, extractable, [
'encrypt',
'decrypt',
]);
}
/**
* Imports a raw 32-byte buffer as an AES-GCM-256 key. Used by the
* Phase 3 client to take the bytes the vault endpoint returns and turn
* them into a non-extractable CryptoKey instance for runtime use.
*/
export async function importMasterKey(rawBytes: Uint8Array): Promise<CryptoKey> {
if (rawBytes.length !== 32) {
throw new Error(`mana-crypto: expected 32-byte master key, got ${rawBytes.length}`);
}
return crypto.subtle.importKey(
'raw',
toBufferSource(rawBytes),
{ name: 'AES-GCM', length: 256 },
false, // non-extractable: once it's in the browser, it stays there
['encrypt', 'decrypt']
);
}
/**
* Exports a key back to its raw 32 bytes. Only works on extractable
* keys; non-extractable keys throw. Used by tests and the Phase 2
* server-side wrap path.
*/
export async function exportMasterKey(key: CryptoKey): Promise<Uint8Array> {
const raw = await crypto.subtle.exportKey('raw', key);
return new Uint8Array(raw);
}

View file

@ -27,7 +27,9 @@
> - mana-sync `3050` > - mana-sync `3050`
> - mana-credits `3061`, mana-user `3062`, mana-subscriptions `3063`, > - mana-credits `3061`, mana-user `3062`, mana-subscriptions `3063`,
> mana-analytics `3064`, mana-events `3065`, mana-research `3068` > mana-analytics `3064`, mana-events `3065`, mana-research `3068`
> (new 2026-04-17, Bun/Hono, public: `research.mana.how`) > (new 2026-04-17, Bun/Hono, public: `research.mana.how`),
> mana-mcp `3069` (new 2026-04-22, Bun/Hono, MCP gateway over
> Streamable HTTP — see `services/mana-mcp/CLAUDE.md`)
> >
> **Not deployed:** `mana-voice-bot` (default port `3024`, no scheduled > **Not deployed:** `mana-voice-bot` (default port `3024`, no scheduled
> task, no cloudflared route, no launchd plist). > task, no cloudflared route, no launchd plist).

View file

@ -0,0 +1,474 @@
# Mana MCP + Personas + Visual Suite
_Started 2026-04-22._
Autonome Nutzung und Test von Mana durch Claude (und andere Agents) über ein einziges, sauberes Protokoll — plus eine Persona-Simulation, die Mana dauerhaft mit realistischem Content bespielt, und eine visuelle Regression-Suite, die das Ergebnis tatsächlich anschaut.
Voraussetzung: **nicht live, unbegrenzte Ressourcen, keine Migrations-Kompromisse.** Wir bauen die Endzustands-Architektur direkt, ohne Legacy-Reste.
## Ziel in einem Satz
Jeder Agent (Claude Desktop, Claude Code, ein interner Tick-Loop, ein MCP-fähiger Voice-Client) spricht **ein** Protokoll mit Mana — und dasselbe Protokoll wird von simulierten Personas genutzt, deren akkumulierender Content nachts durch eine Playwright-Suite visuell geprüft wird.
## Nicht-Ziele
- **Kein** Ersatz der Svelte-UI durch Agent-Flows. Die UI bleibt primäres Frontend für Menschen.
- **Keine** MCP-Exposition von Admin- oder Auth-Endpoints. Personas registrieren sich nicht selbst.
- **Keine** parallele Tool-Registry. mana-ai und mana-mcp konsumieren dasselbe Paket (siehe Entscheidung D3).
- **Keine** Persona-Daten in Produktion-Spaces. Personas sind eigene User mit klarer Kennzeichnung, ihre Spaces sind isoliert.
## Architektur
```
┌────────────────────────────────────────────────┐
│ Claude Desktop / Claude Code / persona-runner │
└───────────────┬────────────────────────────────┘
│ MCP (Streamable HTTP + JWT)
┌─────────────────────┐
│ services/mana-mcp │ :3069
│ (Hono/Bun) │
└──────────┬──────────┘
┌───────────────────┼───────────────────────┐
│ │ │
▼ ▼ ▼
mana-auth mana-api mana-sync
(JWT/JWKS) (Module-REST) (SSE, RLS)
PostgreSQL
(mana_platform, mana_sync)
┌─────────────────────────────┐
│ services/mana-persona-runner│ :3070
│ Claude Agent SDK + MCP │
│ Tick-Loop (daily per user) │
└──────────┬──────────────────┘
│ writes: persona_actions, persona_feedback
Postgres
┌─────────────────────────────┐
│ tests/personas/*.spec.ts │
│ Playwright, nightly │
│ Login als Persona → Tour │
│ toHaveScreenshot Baselines │
└──────────┬──────────────────┘
Visual Diff Report
```
## Entscheidungen
Explizit und mit Begründung, damit wir bei späteren Zweifeln den Pfad kennen.
### D1 — Personas sind echte Mana-User
Kein Bypass, kein Service-Key-Trick. Jede Persona macht ein ganz normales `POST /api/v1/auth/register`, bekommt ein reguläres JWT, landet mit RLS-geschütztem Zugriff auf ihre `personal` Space.
**Warum:** Jeder andere Weg erzeugt zwei Code-Pfade — den normalen und den Persona-Pfad. Zwei Pfade bedeuten: Tests decken nur den Persona-Pfad ab, Bugs im echten Pfad bleiben unsichtbar. Wir wollen, dass Personas exakt dasselbe durchlaufen wie echte Nutzer.
**Abgrenzung zu echten Usern:** Neue Spalte `users.kind` mit Enum `'human' | 'persona' | 'system'`. Defaults auf `'human'`. Admin-UIs und Metriken filtern standardmäßig auf `kind = 'human'`. Email-Namespace `persona.<name>@mana.test` (nicht-existierende TLD, visuell sofort erkennbar).
### D2 — Auth via JWT-per-Agent, kein Service-Key
Der MCP-Server akzeptiert ausschließlich gültige mana-auth JWTs. Claude hält pro Persona ein Token (oder Refresh-Token) und authentifiziert sich wie ein Mensch.
**Warum:** Service-Key + `X-User-Id` (wie heute mana-ai → mana-research) bypassed RLS-Logik und erlaubt jedem Request, sich als jeden User auszugeben. Für interne service-to-service ok. Für einen offenen MCP-Endpoint inakzeptabel.
**Konsequenz:** MCP-Server ist JWKS-validiert über mana-auth. Standard `authMiddleware()` aus `@mana/shared-hono`. Jeder Tool-Call erbt User-Context aus Token, forwarded das an mana-api, mana-sync.
### D3 — Tool-Registry ist ein neues Shared Package
`packages/mana-tool-registry/` wird der einzige Ort, an dem Tools definiert werden. mana-ai (Mission-Runner) und mana-mcp (MCP-Server) konsumieren denselben Registry. Jedes Modul (`todo`, `journal`, `calendar`, …) liefert einen Tool-Spec (Zod-Schema + Description + Implementation-Stub) und registriert ihn.
**Warum:** mana-ai hat heute 67 Tools über 21 Module, großteils hartcodiert. Duplizieren im MCP-Server wäre der sofortige Rutsch in Legacy. Einmal zentral, beide konsumieren.
**Was das enthält:**
- Zod-Schema pro Tool-Input/Output (generiert Json-Schema für MCP + TS-Types für mana-ai)
- `scope`: `'per-user-space'` vs. `'global-admin'` — MCP exponiert nur ersteres
- `policy-hint`: `'read' | 'write' | 'destructive'` — für mana-ai Policy-Layer UND MCP Consent-Flows
- `implementation`: Funktion `(input, ctx) => result`, wobei `ctx` User+Space+JWT enthält
**Reihenfolge:** Wir ziehen mana-ai während M4 auf die neue Registry um. Nicht vorher. Sonst blockiert der Mission-Runner den MCP-MVP.
### D4 — MCP-Transport: Streamable HTTP
Nicht stdio. Der MCP-Server läuft als echter HTTP-Service, Multi-Client-fähig, durch dasselbe Cloudflare-Tunnel/nginx-Setup wie andere Services erreichbar.
**Warum:** stdio erzwingt 1:1 Child-Process-Modell. Ungeeignet für einen gemeinsamen Server, den Claude Desktop, der Persona-Runner und ad-hoc Agents gleichzeitig nutzen.
### D5 — Persona-Runner als eigener, minimaler Service
`services/mana-persona-runner/` (Bun/Hono, :3070). Uses `@anthropic-ai/claude-agent-sdk` direkt, konfiguriert mit dem MCP-Endpoint, iteriert pro Persona einmal pro Tag (configurable).
**Warum nicht in mana-ai mit reinpacken:** mana-ai ist für _unsere Nutzer_ und deren Missionen. Persona-Runner ist Test-Infrastruktur. Unterschiedliche Lifecycle, unterschiedliche Observability, unterschiedliche Risiko-Profile. Vermischt = später schwer zu trennen.
**Warum nicht Claude Code als Daemon:** Claude Code ist eine interaktive CLI. `claude -p` geht, aber Setup ist fragiler als 200 Zeilen Agent-SDK-Code mit klarem Tick-Loop.
### D6 — Persona-Metadata als eigene Tabelle
`platform.personas` in mana_platform:
- `userId` (FK, PK zu `users.id`, 1:1)
- `archetype` (z.B. `'adhd-student'`, `'ceo-busy'`, `'creative-parent'`)
- `systemPrompt` (Text, das was die Persona charakterisiert)
- `moduleMix` (jsonb, Gewichtungen — welche Module wie oft)
- `tickCadence` (`'daily'` | `'weekdays'` | `'hourly'`)
- `createdAt`, `lastActiveAt`
Nicht auf `users` geklatscht, damit die User-Tabelle pur bleibt — echte User und Personas teilen nur Auth/Identity, nicht ihre Charakterisierung.
### D7 — Spaces: Personas nutzen auto-erstellte `personal` Space plus Cross-Space-Testing
Bei Persona-Registrierung wird durch den normalen Signup-Flow bereits eine `personal` Space angelegt (Spaces-Foundation). Zusätzlich definieren wir im Persona-Katalog **Space-Rollen**: z.B. `Anna + Ben` sind Member einer `family` Space, `Marcus + Lena` einer `team` Space. Das testet Shared-Space-Mechanik ohne Extra-Framework.
**Warum:** Wenn alle Personas nur Personal-Spaces haben, testen wir Shared-Spaces nie. Die Kopplung im Persona-Katalog ist billig und deckt das ab.
### D8 — Visual Regression: Playwright built-in
`toHaveScreenshot()` mit Baselines in `tests/personas/__snapshots__/`. Konfiguration: 3 Viewports (Desktop 1440×900, iPad 768×1024, iPhone 390×844), threshold konservativ (0.2% pixel diff).
**Kein Chromatic, kein Percy.** Die Baselines werden ins Repo committed (nicht zu groß bei 3 Viewports × ~20 Screens × ~10 Personas = ~600 PNGs, overlap hoch durch WebP-Komprimierung).
**Warum:** Dritt-Services bedeuten Kosten, Accounts, Integration. Wir sind nicht live, brauchen das nicht. Wenn Baseline-Drift zum Problem wird, können wir später zu Chromatic wechseln.
### D9 — Ratings als strukturiertes Feedback
Jeder Persona-Tick endet mit einem Rating-Schritt: Claude generiert (als Persona) für jedes genutzte Modul eine 15 Bewertung plus Freitext-Notiz. Landet in `platform.persona_feedback`.
**Warum:** Der ganze Sinn von "Persona spielt Produkt" ist die qualitative Beobachtung. Ohne strukturierte Ausgabe erzeugen wir Logs niemand liest. Mit Ratings kannst du dir Montagfrüh ein Dashboard anschauen.
### D10 — Keine Exposition von destruktiven Admin-Tools via MCP
Tool-Registry markiert Operationen mit `policy-hint`. MCP exponiert nur `read` und `write`. `destructive` (User-Löschung, Space-Löschung, Tier-Änderung) bleibt interner Admin-API, erreichbar nur über Admin-UI mit echter 2FA-Sitzung.
**Warum:** Wenn wir jemals den MCP-Server extern zugänglich machen (Claude Desktop mit OAuth), soll kompromittiertes Token nicht bedeuten "User ist weg".
## Komponenten
### Komponente 1 — `packages/mana-tool-registry`
Neues Workspace-Paket. Zuerst gebaut, weil alles andere darauf aufbaut.
**Public API (Skizze):**
```ts
export interface ToolSpec<Input, Output> {
name: string; // z.B. 'todo.create'
description: string; // Für LLM/Doku
module: ModuleId; // 'todo', 'journal', …
scope: 'user-space' | 'admin';
policyHint: 'read' | 'write' | 'destructive';
input: ZodSchema<Input>;
output: ZodSchema<Output>;
handler: (input: Input, ctx: ToolContext) => Promise<Output>;
}
export interface ToolContext {
userId: string;
spaceId: string;
jwt: string;
logger: Logger;
}
export function registerTool(spec: ToolSpec<any, any>): void;
export function getRegistry(): ToolSpec<any, any>[];
export function getToolsByModule(module: ModuleId): ToolSpec<any, any>[];
```
Implementation der Handler ruft `mana-api` und `mana-sync` HTTP-Endpoints (mit `ctx.jwt`). Das hält die Registry dünn und zwingt korrekte RLS-Durchgänge. Direkte DB-Zugriffe wären schneller, aber würden RLS umgehen — genau das wollen wir nicht.
**Module-Coverage M1M4:**
- **M1**: todo, journal, notes, calendar, contacts (5 Module, ~20 Tools)
- **M4 expand**: articles, picture, cards, missions, tags, spaces, goals, mood, dreams, library (10 weitere, ~45 Tools)
- **Nicht in scope initial**: voice-bot, video-gen, image-gen (die haben eigene Async-Flows, eigene Phase)
### Komponente 2 — `services/mana-mcp`
Hono/Bun, port 3069. Nutzt MCP TypeScript SDK (`@modelcontextprotocol/sdk`). Streamable HTTP Transport.
**Server-Struktur:**
```
services/mana-mcp/
├── src/
│ ├── index.ts # Server bootstrap
│ ├── mcp-adapter.ts # tool-registry → MCP tool definitions
│ ├── auth-middleware.ts # JWKS verify
│ └── transport.ts # Streamable HTTP
├── package.json
└── CLAUDE.md
```
Adapter-Logik: Für jeden `ToolSpec` aus `mana-tool-registry`, generiere eine MCP-Tool-Definition. Input-Schema aus `zod-to-json-schema`. Bei Invoke: User-Context aus JWT ziehen, `ctx` aufbauen, `handler` aufrufen, Output streamen.
**Auth-Flow:**
1. Client öffnet MCP-Session mit `Authorization: Bearer <jwt>`.
2. Middleware verifiziert gegen JWKS von mana-auth.
3. User-ID und aktive Space-ID aus Token-Claims.
4. Active Space wird aus `X-Mana-Space` Header überschrieben werden können (wichtig für Cross-Space-Tests).
**Logging:** Pro Tool-Call ein structured log mit `{persona, toolname, inputHash, latencyMs, result: 'ok'|'error'}`. Landet in `platform.persona_actions` falls `users.kind = 'persona'`, sonst nur stdout (echte User).
### Komponente 3 — `services/mana-persona-runner`
Hono/Bun, port 3070. Tick-Loop.
**Loop pro Tick (pro Persona):**
```
1. Lade persona record + systemPrompt + tickCadence + recent actions
2. Prüfe ob persona fällig ist (basierend auf tickCadence + lastActiveAt)
3. Falls ja:
a. Hole JWT für persona (via stored refresh token oder re-login)
b. Starte Claude Agent SDK session mit:
- system: persona.systemPrompt + "Heute ist {date}, du hast Zugriff
auf deine persönliche Mana-App. Was würdest du heute hier tun?"
- mcp: localhost:3069 mit persona JWT
- max_turns: 15
c. Claude ruft MCP-Tools auf, erzeugt Content in Mana
d. Abschließend Rating-Prompt: "Bewerte jedes Modul, das du heute
genutzt hast, 1-5 mit einer Begründung."
e. Parse Rating, schreibe zu persona_feedback
4. Update persona.lastActiveAt
```
**Concurrency:** Default 2 parallele Personas (Claude API rate limits schonen). Konfigurierbar über `PERSONA_CONCURRENCY` env.
**Scheduling:** Interner `setInterval` + `tickCadence`-Check. Kein externes Cron — Service muss eh durchlaufen, Metriken zu haben. Health-Endpoint `/health`, Prometheus-Metriken `/metrics`.
**Fehlertoleranz:** Timeout pro Persona-Tick 10 Minuten. Bei Fehler: Eintrag in `persona_actions` mit `error` Field, nächster Tick fährt weiter.
### Komponente 4 — Persona-Katalog
10 Personas, handgeschrieben, committed als JSON in `scripts/personas/catalog.json`.
**Archetype-Verteilung (Entwurf):**
| Name | Archetype | Modul-Mix | Space-Setup |
|------|-----------|-----------|-------------|
| Anna | adhd-student | todo, journal, notes, mood | personal only |
| Ben | adhd-student | todo, calendar, todos, journal | personal + family(mit Anna) |
| Marcus | ceo-busy | contacts, calendar, tasks, articles | personal + team(mit Lena) |
| Lena | ceo-busy | contacts, meetings, journal | personal + team(mit Marcus) |
| Sofia | creative-parent | journal, picture, notes, dreams | personal + family(mit Tom) |
| Tom | creative-parent | cards, calendar, todos | personal + family(mit Sofia) |
| Kai | solo-dev | articles, notes, library, goals | personal + practice |
| Julia | researcher | articles, news-research, notes | personal only |
| Paul | freelancer | invoices, calendar, contacts | personal + brand |
| Maya | overwhelmed-newbie | nur todo+journal, macht viele Fehler | personal only |
Die "overwhelmed-newbie" Persona ist bewusst dabei, um Onboarding-Bugs und Confusing-UX zu finden. Ihr System-Prompt erlaubt Claude, "verwirrt" zu sein und suboptimale Dinge zu tun.
### Komponente 5 — `tests/personas/`
Playwright-Suite. Läuft nachts gegen Staging (oder lokal gegen dev).
**Struktur:**
```
tests/personas/
├── fixtures/
│ └── persona-auth.ts # Login-Helper, produziert Auth-State
├── flows/
│ ├── home-tour.spec.ts # Alle Personas: Home + Navigation
│ ├── todo.spec.ts # Todo-Personas: Liste + Detail
│ ├── journal.spec.ts # Journal-Personas: Schreiben + Archive
│ └── ...
└── __snapshots__/
└── <test>-<persona>-<viewport>.png
```
**Jeder Test-Lauf:**
1. Hole Persona-Liste aus `persona_catalog` (DB, gefüllt durch seed).
2. Für jede Persona × Flow × Viewport:
- Login via API (bypass Browser-Login, wir testen nicht Login hier)
- Storage-State setzen
- Flow ausführen
- Screenshot nehmen, vergleichen
3. Bei Diff: report generieren, bei `--update-snapshots` neue Baseline.
**CI-Integration:** GitHub Action nightly, oder Mac-Mini-Cron. Diff-HTML-Report in MinIO uploaded, Link in Slack gepostet.
## Datenmodell-Erweiterungen
Drei neue Tabellen in `platform.*`:
```sql
-- 1. Persona-Metadaten (1:1 mit users.kind='persona')
CREATE TABLE platform.personas (
user_id UUID PRIMARY KEY REFERENCES platform.users(id) ON DELETE CASCADE,
archetype TEXT NOT NULL,
system_prompt TEXT NOT NULL,
module_mix JSONB NOT NULL,
tick_cadence TEXT NOT NULL DEFAULT 'daily',
last_active_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 2. Was Personas tun (audit trail)
CREATE TABLE platform.persona_actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
persona_id UUID NOT NULL REFERENCES platform.personas(user_id),
tick_id UUID NOT NULL, -- zusammenhängender Tick
tool_name TEXT NOT NULL,
input_hash TEXT, -- für dedup analytics
result TEXT NOT NULL, -- 'ok' | 'error'
error_message TEXT,
latency_ms INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON platform.persona_actions (persona_id, created_at DESC);
-- 3. Persona-Feedback pro Tick
CREATE TABLE platform.persona_feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
persona_id UUID NOT NULL REFERENCES platform.personas(user_id),
tick_id UUID NOT NULL,
module TEXT NOT NULL,
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX ON platform.persona_feedback (module, created_at DESC);
```
Plus **ein** Feld auf existierender `platform.users`:
```sql
ALTER TABLE platform.users
ADD COLUMN kind TEXT NOT NULL DEFAULT 'human'
CHECK (kind IN ('human', 'persona', 'system'));
```
## No-Legacy-Residues
Explizite Anti-Patterns, gegen die wir uns committen:
1. **Kein `isBot` Boolean.** `users.kind` Enum mit klaren Werten. Boolean-Flags wachsen immer ungeklärt.
2. **Keine parallele Tool-Definition.** Wenn mana-ai und mana-mcp je eigene Tool-Listen hätten, bekommen wir Drift. D3 zwingt die gemeinsame Registry.
3. **Kein Service-Key-Shortcut.** Auch nicht "nur für Personas, weil's einfacher wäre". D1+D2 machen klar: Personas gehen den Nutzer-Pfad.
4. **Kein versteckter Persona-Endpoint.** Alles was MCP kann, könnte auch ein echter Nutzer. Wenn ein Tool "nur für Personas" wäre, ist es Admin-Tool und gehört nicht in MCP.
5. **Keine Placeholder-Daten direkt in DB.** Persona-Content wird ausschließlich via MCP/REST erzeugt, durchläuft RLS, Encryption, Validation. Wenn wir DB-direkt seeden würden, testen wir nicht was wir testen wollen.
6. **Keine `if (user.email.endsWith('mana.test'))` Special-Cases.** Wenn Logik nach Persona verzweigen muss, dann über `users.kind`, und Grund muss in Commit dokumentiert sein.
7. **Keine Screenshot-Baselines ohne Review.** `--update-snapshots` ist kein Routine-Run. Jede Baseline-Änderung erzeugt einen Diff, und der wird menschlich angeschaut.
## Milestones
Jeder Milestone landet als klar erkennbares Commit-Set, ist standalone nützlich, typechecked + validate:all grün.
### M1 — Foundation (Tool-Registry + MCP-MVP, plaintext only)
**Scope-Refinement (gegenüber initialer Plan-Skizze):** Die ursprünglich gelisteten Tools (`todo.create`, `journal.add`, `notes.create`) zielen alle auf **encrypted** Tabellen. Server-side Encryption braucht eine MK-Unwrap-Infrastruktur, die noch nicht existiert. Statt M1 zu vergrößern, hält M1 sich strikt an plaintext-Tabellen — die MCP-Plumbing wird unabhängig von Encryption sauber bewiesen. Encryption-Path wird **M1.5**.
- [ ] `packages/mana-tool-registry/` scaffold: types, register/get, context type
- [ ] 5 Tools implementieren — alle plaintext: `habits.create`, `habits.list`, `habits.log`, `habits.recent_logs`, `spaces.list`
- [ ] `services/mana-mcp/` scaffold + Hono server + JWKS auth middleware
- [ ] MCP-Adapter: Registry → MCP tool definitions
- [ ] Streamable HTTP transport
- [ ] Integration-Test: lokaler Claude Code verbindet sich, listet Tools, ruft `habits.create` auf, Row erscheint in `mana_sync.sync_changes`
- [ ] Port 3069 in `docs/PORT_SCHEMA.md`
- [ ] `services/mana-mcp/CLAUDE.md` mit Architektur + Ports
**Exit criteria:** Claude Code kann mit einem dev-user-JWT gegen lokalen MCP-Server Habits anlegen, loggen und listen. Persona-Browser-Login (M5) zeigt diese Habits später visuell.
### M1.5 — Server-side encryption capability — ✅ SHIPPED 2026-04-22
**Scope-Update gegenüber initialer Skizze:** Der geplante neue Service-Key-gated Endpoint `POST /api/v1/internal/users/:id/mk` wurde **nicht gebaut** — nicht nötig. Der existierende Endpoint `GET /api/v1/me/encryption-vault/key` (JWT-gated, ZK-aware, bereits mit Audit-Trail) erfüllt genau den Zweck: Agent hält das JWT seiner Persona → fragt die Vault-API → bekommt MK für Non-ZK-User, recovery-blob für ZK (wir rejecten das mit `ZeroKnowledgeUserError`). Vorteil: zero neue Angriffsfläche, zero neuer Admin-Pfad.
- [x] `packages/shared-crypto/` extracted — `aes.ts` zieht um, Web-App `$lib/data/crypto/aes.ts` re-exportiert. Identische Wire-Format-Garantie (same code).
- [x] `@mana/tool-registry.MasterKeyClient` — cached Vault-Fetch per userId, 5-min TTL
- [x] `ToolContext.getMasterKey()` lazy — tools that need crypto call it, plaintext tools never do
- [x] `ToolSpec.encryptedFields?` declarative — `{table, fields}` pro tool, matches web-app registry verbatim (M4 audit script wird das cross-checken)
- [x] 5 neue encrypted tools: `todo.create`, `todo.list`, `todo.complete`, `notes.create`, `notes.search`, `journal.add` (ended up being 6 — `todo.complete` landed gratis, reines plaintext-update)
- [x] mana-mcp adapter baut `getMasterKey` in den ToolContext beim Session-Start ein, ein `MasterKeyClient` pro Prozess, cache teilen
**Exit criteria — erfüllt:** Encrypted Module sind über MCP für Non-ZK-User erreichbar. Type-check über alle 3 Pakete grün. Smoke-Boot: Service registriert 9 Tools total (4 habits + 1 spaces + 3 todo + 2 notes + 1 journal = 11, check-me), unauthed bleibt 401.
**Nicht gemacht (bewusst, M4 gehört das hin):**
- Audit-Script der `encryptedFields` vs. Web-App-Registry cross-checkt
- Einheitliche Registry-Daten zwischen Web-App und shared-crypto (heute: Web-App hält seine typed `entry<T>()` Version, tools halten ihre eigene Feldliste pro Spec — CI-Audit muss später drift fangen)
### M2 — Persona-Primitives
- [ ] Drizzle-Schema: `users.kind`, `platform.personas`, `platform.persona_actions`, `platform.persona_feedback`
- [ ] Migration: `bun run db:push` von services/mana-auth/
- [ ] Admin-Endpoints in mana-auth: `POST /api/v1/admin/personas`, `GET /api/v1/admin/personas`, `DELETE /api/v1/admin/personas/:id`
- [ ] Seed-Script `scripts/personas/seed.ts`: lese `catalog.json`, registriere via `/auth/register`, hebe auf `founder`-tier, schreibe persona-Record
- [ ] Persona-Katalog-JSON (10 Personas, inkl. Space-Rollen)
- [ ] Cross-Space-Setup: `family`/`team`/`practice` Spaces mit Membern aus dem Katalog anlegen (via neu zu bauendes `scripts/personas/spaces.ts`)
**Exit criteria:** `pnpm seed:personas` erzeugt 10 User, 10 `personal`-Spaces, 3 shared Spaces, befüllt `platform.personas`.
### M3 — Persona-Runner
- [ ] `services/mana-persona-runner/` scaffold
- [ ] Tick-Loop: liest Personas aus DB, Cadence-Check, pro fällige Persona → Claude Agent SDK Aufruf
- [ ] JWT-Handling: Refresh-Token-Storage in `platform.personas` (encrypted field), auto-refresh
- [ ] Concurrency-Control (default 2)
- [ ] `persona_actions` + `persona_feedback` Writes
- [ ] Prometheus metrics: `persona_ticks_total`, `persona_tool_calls_total`, `persona_errors_total`
- [ ] Observability-Dashboard (Markdown in `docs/observability/personas.md`, evtl. später Grafana)
**Exit criteria:** Service läuft lokal, nach 24h haben alle 10 Personas mindestens einen Tick ausgeführt, in der App ist sichtbarer Content pro Persona.
### M4 — Full Tool Coverage + Three-Catalog Unification
**Aufgefundener Stand (2026-04-22):** Es existieren bereits drei Tool-Kataloge im Repo, die alle gemerged werden:
1. `packages/shared-ai/AI_TOOL_CATALOG` — konsumiert von `apps/api/src/mcp/server.ts`
2. `services/mana-ai/` interne Tool-Definitionen (~67 Tools, 21 Module) — Mission-Runner
3. `packages/mana-tool-registry/` — neu in M1, wird der SSOT
- [ ] Tool-Registry auf 15+ Module ausbauen (siehe Komponente 1)
- [ ] mana-ai migrieren: alle heute hartcodierten Tools ziehen in `mana-tool-registry`, mana-ai konsumiert via `getRegistry()`
- [ ] `packages/shared-ai/AI_TOOL_CATALOG` löschen, `apps/api/src/mcp/server.ts` löschen (mana-mcp übernimmt die Funktion)
- [ ] Alte Tool-Definitionen in mana-ai löschen (harter Cut, keine Parallelität)
- [ ] End-to-end-Test: sowohl eine mana-ai-Mission als auch ein Persona-Runner-Tick nutzen dieselbe `todo.create`
**Exit criteria:** Grep nach duplizierten Tool-Definitionen → leer. Beide Consumer grün.
### M5 — Visual Suite
- [ ] `tests/personas/` Struktur
- [ ] Persona-Login-Fixture (API-Login → storageState)
- [ ] Flow-Specs: `home-tour`, `todo`, `journal`, `notes`, `calendar`, `articles`, `contacts`, `cards`, `missions`, `settings` (10 Flows)
- [ ] 3 Viewports: Desktop, iPad, iPhone
- [ ] Baseline-Capture pro Persona × Flow × Viewport
- [ ] Playwright-Config: threshold 0.2%, animation-handling, font-loading wait
- [ ] Nightly-Run: GitHub Action oder Mac-Mini-Cron, Report zu MinIO, Slack-Notify bei Diff
- [ ] `docs/testing/visual-regression.md`
**Exit criteria:** Nightly läuft, baseline committed, bei Code-Change sehen wir Diff-Report.
### M6 — Polish (optional, nach M1M5)
- [ ] Admin-UI-Tab "Personas" in der Web-App: Liste, letzte Actions, Feedback-Dashboard pro Modul
- [ ] Rating-Aggregations-View: `"Modul X bekommt seit 3 Wochen schlechtere Ratings als vorher"`
- [ ] MCP-OAuth-Flow für externe Clients (wenn wir das je öffnen)
- [ ] Chaos-Personas: gezielt fehlerhafte Inputs, Edge-Cases, Unicode-Chaos
## Risiken + Mitigation
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|--------|-----|-----|------------|
| Claude API Rate-Limits bei 10 Personas × täglich × 15 Turns | Mittel | Mittel | Concurrency-Config (default 2), Retry-with-backoff, Tier-Upgrade falls nötig |
| Persona-Daten "leaken" in Produktion-Dashboards | Niedrig | Hoch | `users.kind` Filter standardmäßig in allen Admin-Queries, Review-Checklist bei neuen Dashboards |
| Tool-Registry wird zu groß / unübersichtlich | Mittel | Niedrig | Pro Modul eine Datei, Index nur re-exportiert; CI-Check max Tools-pro-Modul |
| Visual Baselines drift durch zufällige Daten (z.B. Zeitanzeige, IDs) | Hoch | Mittel | Deterministic persona seed (fixed dates), `data-testid` statt visueller Hashes, frozen clock in Playwright |
| MCP-SDK API ändert sich | Niedrig | Mittel | Dünner Adapter-Layer in `mcp-adapter.ts`, Pin der SDK-Version |
| Persona-Refresh-Token läuft ab | Hoch (über Wochen) | Niedrig | Auto-refresh in Runner, Alert wenn Refresh fehlschlägt |
## Offene Entscheidungen (später)
- **Tool-Versioning:** Wenn wir `todo.create` ändern, Breaking für MCP-Clients? Wir sind noch nicht live, brauchen das noch nicht. Nach M4 einmal reviewen.
- **MCP für externe Nutzer:** Wenn/wann jemals Mana als Tool für Claude Desktop released werden soll → OAuth-Flow statt JWT-Bearer. Phase M6+.
- **Persona-Content-Cleanup:** Nach 90 Tagen werden Persona-Spaces massiv, DB wächst. Brauchen wir `persona.maxHistoryDays`? Beobachten, M3 sammelt erst mal Daten.
- **Langzeit-Konsistenz:** Wenn Anna 30 Tage lang Todos anlegt, wird Claude sich an eigene frühere Einträge erinnern können (durch MCP-List-Tools). Wir müssen prüfen, ob der System-Prompt + letzte N Actions genug ist oder ob wir eine explizite "Anna's story so far"-Zusammenfassung pflegen.
## Shipping Log
(Leer — wird befüllt, während M1 → M6 gehen.)
| Phase | Purpose | Commit |
| --- | --- | --- |
| — | — | — |

View file

@ -0,0 +1,25 @@
{
"name": "@mana/tool-registry",
"version": "0.1.0",
"private": true,
"type": "module",
"sideEffects": false,
"description": "Single-source tool registry consumed by mana-ai (mission runner) and mana-mcp (MCP server). Each module declares its agent-callable operations once; both consumers derive their schemas from here.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./modules": "./src/modules/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"lint": "eslint ."
},
"dependencies": {
"@mana/shared-crypto": "workspace:*",
"zod": "^3.25.76"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,43 @@
export type {
AnyToolSpec,
Logger,
ModuleId,
PolicyHint,
ToolContext,
ToolScope,
ToolSpec,
} from './types.ts';
export {
__resetRegistryForTests,
getRegistry,
getTool,
getToolsByModule,
registerTool,
} from './registry.ts';
export type {
SyncChange,
SyncClientConfig,
SyncFieldChange,
SyncPullResponse,
SyncPushRequest,
} from './sync-client.ts';
export { pullAll, push, pushInsert } from './sync-client.ts';
export {
MasterKeyClient,
MasterKeyFetchError,
ZeroKnowledgeUserError,
type MasterKeyClientConfig,
} from './master-key-client.ts';
export { decryptRecordFields, encryptRecordFields } from '@mana/shared-crypto';
/**
* Consumers call this to register every bundled tool at once. It is a
* side-effect-bearing import that pulls in all module files. If a consumer
* only wants a subset, it can import the individual module barrels directly.
*/
export { registerAllModules } from './modules/index.ts';

View file

@ -0,0 +1,113 @@
/**
* Fetches and caches each caller's master key for the lifetime of a tool-
* context.
*
* Reuses the existing `GET /api/v1/me/encryption-vault/key` endpoint
* rather than building a service-key-gated bypass:
* - The endpoint is already JWT-auth'd, so the caller's own token is
* exactly the right credential.
* - Zero-knowledge users receive a recovery blob (never plaintext MK)
* a server-side agent cannot open their data, and we return null from
* `getKey()` so callers fail loud.
* - The endpoint already writes an audit trail row per fetch, which is
* the observability we want.
*
* Caching: per-userId, in-process, short TTL. A long-running MCP session
* invokes many tools in a row; re-fetching the MK for each tool would
* spam the audit log and add ~20 ms latency per call. The TTL is short
* enough that key-rotation picks up within a tick, not a day.
*/
import { importMasterKey } from '@mana/shared-crypto';
export interface MasterKeyClientConfig {
authUrl: string;
/** How long a cached CryptoKey stays valid. Default 5 minutes. */
ttlMs?: number;
}
interface CacheEntry {
key: CryptoKey;
expiresAt: number;
}
export class ZeroKnowledgeUserError extends Error {
constructor(userId: string) {
super(
`User ${userId.slice(0, 8)}… is in zero-knowledge mode — the server has no way to unwrap their master key. Agent-side encryption is not possible for this user.`
);
this.name = 'ZeroKnowledgeUserError';
}
}
export class MasterKeyFetchError extends Error {
constructor(status: number, body: string) {
super(`mana-auth /encryption-vault/key failed: HTTP ${status}${body.slice(0, 200)}`);
this.name = 'MasterKeyFetchError';
}
}
/**
* Response shape of `GET /api/v1/me/encryption-vault/key` (standard mode).
* ZK-mode returns a different shape (recovery blob, no `masterKey`) we
* detect that and throw `ZeroKnowledgeUserError`.
*/
interface VaultKeyResponse {
masterKey?: string; // base64, 32 bytes when present
formatVersion?: number;
kekId?: string;
zeroKnowledge?: boolean;
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
export class MasterKeyClient {
private readonly cache = new Map<string, CacheEntry>();
private readonly ttlMs: number;
constructor(private readonly config: MasterKeyClientConfig) {
this.ttlMs = config.ttlMs ?? 5 * 60 * 1000;
}
/**
* Returns the caller's master key as a non-extractable CryptoKey.
* Throws `ZeroKnowledgeUserError` for ZK users, `MasterKeyFetchError`
* on any other network/auth failure.
*/
async getKey(userId: string, jwt: string): Promise<CryptoKey> {
const cached = this.cache.get(userId);
const now = Date.now();
if (cached && cached.expiresAt > now) return cached.key;
const url = `${this.config.authUrl}/api/v1/me/encryption-vault/key`;
const res = await fetch(url, {
headers: { authorization: `Bearer ${jwt}` },
});
if (!res.ok) {
const body = await res.text().catch(() => '<unreadable>');
throw new MasterKeyFetchError(res.status, body);
}
const body = (await res.json()) as VaultKeyResponse;
if (body.zeroKnowledge || !body.masterKey) {
throw new ZeroKnowledgeUserError(userId);
}
const raw = base64ToBytes(body.masterKey);
const key = await importMasterKey(raw);
this.cache.set(userId, { key, expiresAt: now + this.ttlMs });
return key;
}
/** Test-only — clears cache. */
__clearForTests(): void {
this.cache.clear();
}
}

View file

@ -0,0 +1,219 @@
/**
* Habits agent-callable operations on the user's habits collection.
*
* Plaintext module: `habits` table is in the plaintext-allowlist
* (no encrypted fields), so handlers write directly through the
* mana-sync push protocol without any key-grant ceremony.
*
* Wire shape mirrors LocalHabit in
* apps/mana/apps/web/src/lib/modules/habits/types.ts keep in sync.
*/
import { z } from 'zod';
import { pullAll, pushInsert, push } from '../sync-client.ts';
import { registerTool } from '../registry.ts';
import type { ToolContext, ToolSpec } from '../types.ts';
const APP_ID = 'habits';
const TABLE = 'habits';
const SYNC_URL = () => process.env.MANA_SYNC_URL ?? 'http://localhost:3050';
const CLIENT_ID = () => process.env.MANA_MCP_CLIENT_ID ?? 'mana-mcp';
// ─── Domain shape (must match LocalHabit on the client) ───────────
const habitSchema = z.object({
id: z.string().uuid(),
title: z.string(),
icon: z.string(),
color: z.string(),
targetPerDay: z.number().int().nullable(),
defaultDuration: z.number().int().nullable(),
order: z.number().int(),
isArchived: z.boolean(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
});
type Habit = z.infer<typeof habitSchema>;
function syncCfg(ctx: ToolContext) {
return { baseUrl: SYNC_URL(), jwt: ctx.jwt, clientId: CLIENT_ID() };
}
// ─── habits.create ────────────────────────────────────────────────
const createInput = z.object({
title: z.string().min(1).max(120),
icon: z.string().min(1).max(40).default('star'),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.default('#6366f1'),
targetPerDay: z.number().int().positive().nullable().default(null),
defaultDuration: z.number().int().positive().nullable().default(null),
});
const createOutput = z.object({ habit: habitSchema });
export const habitsCreate: ToolSpec<typeof createInput, typeof createOutput> = {
name: 'habits.create',
module: 'habits',
scope: 'user-space',
policyHint: 'write',
description:
'Create a new habit (e.g. "Coffee", "Workout"). Returns the created habit. Use `targetPerDay` for habits with a daily count goal.',
input: createInput,
output: createOutput,
async handler(input, ctx) {
const now = new Date().toISOString();
const existing = await pullAll<Habit>(syncCfg(ctx), APP_ID, TABLE);
const order = existing.changes.filter((c) => c.op !== 'delete').length;
const habit: Habit = {
id: crypto.randomUUID(),
title: input.title,
icon: input.icon,
color: input.color,
targetPerDay: input.targetPerDay,
defaultDuration: input.defaultDuration,
order,
isArchived: false,
createdAt: now,
updatedAt: now,
};
await pushInsert(syncCfg(ctx), APP_ID, {
table: TABLE,
id: habit.id,
spaceId: ctx.spaceId,
data: habit as unknown as Record<string, unknown>,
});
ctx.logger.info('habits.create', { habitId: habit.id, spaceId: ctx.spaceId });
return { habit };
},
};
// ─── habits.list ──────────────────────────────────────────────────
const listInput = z.object({
includeArchived: z.boolean().default(false),
});
const listOutput = z.object({
habits: z.array(habitSchema),
});
export const habitsList: ToolSpec<typeof listInput, typeof listOutput> = {
name: 'habits.list',
module: 'habits',
scope: 'user-space',
policyHint: 'read',
description:
'List all habits in the active space. Set `includeArchived: true` to include archived habits.',
input: listInput,
output: listOutput,
async handler(input, ctx) {
const res = await pullAll<Habit>(syncCfg(ctx), APP_ID, TABLE);
const habits = res.changes
.filter((c) => c.op !== 'delete' && c.data)
.map((c) => c.data as Habit)
.filter((h) => input.includeArchived || !h.isArchived);
return { habits };
},
};
// ─── habits.update ────────────────────────────────────────────────
const updateInput = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(120).optional(),
icon: z.string().min(1).max(40).optional(),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.optional(),
targetPerDay: z.number().int().positive().nullable().optional(),
defaultDuration: z.number().int().positive().nullable().optional(),
});
const updateOutput = z.object({ ok: z.literal(true) });
export const habitsUpdate: ToolSpec<typeof updateInput, typeof updateOutput> = {
name: 'habits.update',
module: 'habits',
scope: 'user-space',
policyHint: 'write',
description:
'Update fields on an existing habit. Only the provided fields are changed; others stay as-is.',
input: updateInput,
output: updateOutput,
async handler(input, ctx) {
const now = new Date().toISOString();
const fields: Record<string, { value: unknown; updatedAt: string }> = {};
for (const [k, v] of Object.entries(input)) {
if (k === 'id' || v === undefined) continue;
fields[k] = { value: v, updatedAt: now };
}
fields.updatedAt = { value: now, updatedAt: now };
await push(syncCfg(ctx), APP_ID, [
{
table: TABLE,
id: input.id,
op: 'update',
spaceId: ctx.spaceId,
fields,
},
]);
ctx.logger.info('habits.update', { habitId: input.id, fields: Object.keys(fields) });
return { ok: true };
},
};
// ─── habits.archive ───────────────────────────────────────────────
const archiveInput = z.object({
id: z.string().uuid(),
archived: z.boolean().default(true),
});
const archiveOutput = z.object({ ok: z.literal(true) });
export const habitsArchive: ToolSpec<typeof archiveInput, typeof archiveOutput> = {
name: 'habits.archive',
module: 'habits',
scope: 'user-space',
policyHint: 'write',
description:
'Archive (or unarchive) a habit. Archived habits stay in history but disappear from the active list.',
input: archiveInput,
output: archiveOutput,
async handler(input, ctx) {
const now = new Date().toISOString();
await push(syncCfg(ctx), APP_ID, [
{
table: TABLE,
id: input.id,
op: 'update',
spaceId: ctx.spaceId,
fields: {
isArchived: { value: input.archived, updatedAt: now },
updatedAt: { value: now, updatedAt: now },
},
},
]);
ctx.logger.info('habits.archive', { habitId: input.id, archived: input.archived });
return { ok: true };
},
};
// ─── Registration barrel ──────────────────────────────────────────
export function registerHabitsTools(): void {
registerTool(habitsCreate);
registerTool(habitsList);
registerTool(habitsUpdate);
registerTool(habitsArchive);
}

View file

@ -0,0 +1,33 @@
/**
* Module barrel call `registerAllModules()` at process startup to
* populate the registry with every bundled tool.
*
* Adding a new module:
* 1. Create `src/modules/<module>.ts` with one or more `ToolSpec` exports
* and a `register<Module>Tools()` function that calls `registerTool()`
* for each.
* 2. Import + call it from this file.
* 3. Extend `ModuleId` in `../types.ts`.
*/
import { registerHabitsTools } from './habits.ts';
import { registerJournalTools } from './journal.ts';
import { registerNotesTools } from './notes.ts';
import { registerSpacesTools } from './spaces.ts';
import { registerTodoTools } from './todo.ts';
export function registerAllModules(): void {
registerHabitsTools();
registerJournalTools();
registerNotesTools();
registerSpacesTools();
registerTodoTools();
}
export {
registerHabitsTools,
registerJournalTools,
registerNotesTools,
registerSpacesTools,
registerTodoTools,
};

View file

@ -0,0 +1,127 @@
/**
* Journal daily freeform entries (Tagebuch).
*
* Encrypted module: `journalEntries` table is encrypted in the web-app
* registry as
* journalEntries: entry<LocalJournalEntry>(['title', 'content'])
*
* `mood`, `entryDate`, `tags`, `wordCount` stay plaintext (indexed for
* stats and insights views).
*/
import { z } from 'zod';
import { encryptRecordFields } from '@mana/shared-crypto';
import { pushInsert } from '../sync-client.ts';
import { registerTool } from '../registry.ts';
import type { ToolContext, ToolSpec } from '../types.ts';
const APP_ID = 'journal';
const TABLE = 'journalEntries';
const ENCRYPTED_FIELDS = ['title', 'content'] as const;
const SYNC_URL = () => process.env.MANA_SYNC_URL ?? 'http://localhost:3050';
const CLIENT_ID = () => process.env.MANA_MCP_CLIENT_ID ?? 'mana-mcp';
const MOODS = [
'dankbar',
'glücklich',
'zufrieden',
'neutral',
'nachdenklich',
'traurig',
'gestresst',
'wütend',
] as const;
const entrySchema = z.object({
id: z.string().uuid(),
title: z.string().nullable(),
content: z.string(),
entryDate: z.string(),
mood: z.enum(MOODS).nullable(),
tags: z.array(z.string()),
wordCount: z.number().int().nonnegative(),
isPinned: z.boolean(),
isFavorite: z.boolean(),
isArchived: z.boolean(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
});
type Entry = z.infer<typeof entrySchema>;
function syncCfg(ctx: ToolContext) {
return { baseUrl: SYNC_URL(), jwt: ctx.jwt, clientId: CLIENT_ID() };
}
// ─── journal.add ──────────────────────────────────────────────────
const addInput = z.object({
title: z.string().max(500).nullable().default(null),
content: z.string().min(1).max(200_000),
entryDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
mood: z.enum(MOODS).nullable().default(null),
tags: z.array(z.string()).default([]),
});
const addOutput = z.object({ entry: entrySchema });
export const journalAdd: ToolSpec<typeof addInput, typeof addOutput> = {
name: 'journal.add',
module: 'journal',
scope: 'user-space',
policyHint: 'write',
description:
'Write a journal entry. Defaults to today if `entryDate` is omitted. Title and content are encrypted; mood, tags, and the date stay plaintext for stats aggregation.',
input: addInput,
output: addOutput,
encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS },
async handler(input, ctx) {
const key = await ctx.getMasterKey();
const now = new Date();
const today = now.toISOString().slice(0, 10);
const plaintext: Entry = {
id: crypto.randomUUID(),
title: input.title,
content: input.content,
entryDate: input.entryDate ?? today,
mood: input.mood,
tags: input.tags,
wordCount: input.content.split(/\s+/).filter(Boolean).length,
isPinned: false,
isFavorite: false,
isArchived: false,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
};
const encrypted = await encryptRecordFields(
plaintext as unknown as Record<string, unknown>,
ENCRYPTED_FIELDS,
key
);
await pushInsert(syncCfg(ctx), APP_ID, {
table: TABLE,
id: plaintext.id,
spaceId: ctx.spaceId,
data: encrypted,
});
ctx.logger.info('journal.add', {
entryId: plaintext.id,
entryDate: plaintext.entryDate,
wordCount: plaintext.wordCount,
});
return { entry: plaintext };
},
};
// ─── Registration barrel ──────────────────────────────────────────
export function registerJournalTools(): void {
registerTool(journalAdd);
}

View file

@ -0,0 +1,150 @@
/**
* Notes lightweight markdown notes, flat structure.
*
* Encrypted module: `notes` table is encrypted in the web-app registry as
* notes: entry<LocalNote>(['title', 'content'])
*
* AppId `notes` matches the Dexie sync appId used by the web-app
* notes store.
*/
import { z } from 'zod';
import { decryptRecordFields, encryptRecordFields } from '@mana/shared-crypto';
import { pullAll, pushInsert } from '../sync-client.ts';
import { registerTool } from '../registry.ts';
import type { ToolContext, ToolSpec } from '../types.ts';
const APP_ID = 'notes';
const TABLE = 'notes';
const ENCRYPTED_FIELDS = ['title', 'content'] as const;
const SYNC_URL = () => process.env.MANA_SYNC_URL ?? 'http://localhost:3050';
const CLIENT_ID = () => process.env.MANA_MCP_CLIENT_ID ?? 'mana-mcp';
// ─── Domain shape ─────────────────────────────────────────────────
const noteSchema = z.object({
id: z.string().uuid(),
title: z.string(),
content: z.string(),
color: z.string().nullable(),
isPinned: z.boolean(),
isArchived: z.boolean(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
});
type Note = z.infer<typeof noteSchema>;
type EncryptedNote = Record<string, unknown>;
function syncCfg(ctx: ToolContext) {
return { baseUrl: SYNC_URL(), jwt: ctx.jwt, clientId: CLIENT_ID() };
}
// ─── notes.create ─────────────────────────────────────────────────
const createInput = z.object({
title: z.string().min(1).max(500),
content: z.string().max(200_000).default(''),
color: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/)
.nullable()
.default(null),
isPinned: z.boolean().default(false),
});
const createOutput = z.object({ note: noteSchema });
export const notesCreate: ToolSpec<typeof createInput, typeof createOutput> = {
name: 'notes.create',
module: 'notes',
scope: 'user-space',
policyHint: 'write',
description:
'Create a new markdown note in the active space. `content` supports full markdown. Values are encrypted before storage; the returned note is the plaintext snapshot.',
input: createInput,
output: createOutput,
encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS },
async handler(input, ctx) {
const key = await ctx.getMasterKey();
const now = new Date().toISOString();
const plaintext: Note = {
id: crypto.randomUUID(),
title: input.title,
content: input.content,
color: input.color,
isPinned: input.isPinned,
isArchived: false,
createdAt: now,
updatedAt: now,
};
const encrypted = await encryptRecordFields(
plaintext as unknown as Record<string, unknown>,
ENCRYPTED_FIELDS,
key
);
await pushInsert(syncCfg(ctx), APP_ID, {
table: TABLE,
id: plaintext.id,
spaceId: ctx.spaceId,
data: encrypted,
});
ctx.logger.info('notes.create', { noteId: plaintext.id, spaceId: ctx.spaceId });
return { note: plaintext };
},
};
// ─── notes.search ─────────────────────────────────────────────────
const searchInput = z.object({
query: z.string().max(500).default(''),
includeArchived: z.boolean().default(false),
limit: z.number().int().positive().max(100).default(20),
});
const searchOutput = z.object({
notes: z.array(noteSchema),
});
export const notesSearch: ToolSpec<typeof searchInput, typeof searchOutput> = {
name: 'notes.search',
module: 'notes',
scope: 'user-space',
policyHint: 'read',
description:
'Search notes by case-insensitive substring match against title+content. Empty query returns the most recent notes. Decryption happens server-side after pull; the returned notes are plaintext.',
input: searchInput,
output: searchOutput,
encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS },
async handler(input, ctx) {
const key = await ctx.getMasterKey();
const res = await pullAll<EncryptedNote>(syncCfg(ctx), APP_ID, TABLE);
const alive = res.changes.filter((c) => c.op !== 'delete' && c.data).map((c) => c.data!);
const decrypted = (await Promise.all(
alive.map((row) => decryptRecordFields(row, ENCRYPTED_FIELDS, key))
)) as unknown as Note[];
const q = input.query.toLowerCase().trim();
const filtered = decrypted
.filter((n) => input.includeArchived || !n.isArchived)
.filter((n) => {
if (!q) return true;
return n.title.toLowerCase().includes(q) || n.content.toLowerCase().includes(q);
})
.slice(0, input.limit);
return { notes: filtered };
},
};
// ─── Registration barrel ──────────────────────────────────────────
export function registerNotesTools(): void {
registerTool(notesCreate);
registerTool(notesSearch);
}

View file

@ -0,0 +1,94 @@
/**
* Spaces read-only tools for the caller's space membership.
*
* Spaces are stored in mana-auth's better-auth `organizations` table.
* Better-auth exposes a list endpoint at `/api/auth/organization/list`
* which respects the JWT we forward no special service-key needed.
*
* We expose only `list` and `current` here; mutations (create, invite,
* delete) go through the regular Spaces UI in the web app, not via
* agents.
*/
import { z } from 'zod';
import { registerTool } from '../registry.ts';
import type { ToolContext, ToolSpec } from '../types.ts';
const AUTH_URL = () => process.env.MANA_AUTH_URL ?? 'http://localhost:3001';
const spaceSchema = z.object({
id: z.string(),
name: z.string(),
slug: z.string().nullable().optional(),
type: z.enum(['personal', 'brand', 'club', 'family', 'team', 'practice']).optional(),
role: z.string().optional(),
});
type Space = z.infer<typeof spaceSchema>;
// ─── spaces.list ──────────────────────────────────────────────────
const listInput = z.object({});
const listOutput = z.object({
spaces: z.array(spaceSchema),
activeSpaceId: z.string(),
});
async function fetchSpaces(ctx: ToolContext): Promise<Space[]> {
const res = await fetch(`${AUTH_URL()}/api/auth/organization/list`, {
headers: { authorization: `Bearer ${ctx.jwt}` },
});
if (!res.ok) {
throw new Error(`mana-auth /organization/list failed: ${res.status} ${res.statusText}`);
}
const raw = (await res.json()) as Array<{
id: string;
name: string;
slug?: string | null;
metadata?: { type?: Space['type'] } | string | null;
role?: string;
}>;
return raw.map((row) => {
// better-auth stores metadata as JSON string in some configs
let type: Space['type'] | undefined;
if (typeof row.metadata === 'string') {
try {
const parsed = JSON.parse(row.metadata) as { type?: Space['type'] };
type = parsed.type;
} catch {
type = undefined;
}
} else if (row.metadata && typeof row.metadata === 'object') {
type = row.metadata.type;
}
return {
id: row.id,
name: row.name,
slug: row.slug ?? null,
type,
role: row.role,
};
});
}
export const spacesList: ToolSpec<typeof listInput, typeof listOutput> = {
name: 'spaces.list',
module: 'spaces',
scope: 'user-space',
policyHint: 'read',
description:
'List all Spaces the caller is a member of. The active Space (where data writes land by default) is returned in `activeSpaceId`.',
input: listInput,
output: listOutput,
async handler(_input, ctx) {
const spaces = await fetchSpaces(ctx);
return { spaces, activeSpaceId: ctx.spaceId };
},
};
// ─── Registration barrel ──────────────────────────────────────────
export function registerSpacesTools(): void {
registerTool(spacesList);
}

View file

@ -0,0 +1,196 @@
/**
* Todo agent-callable task operations.
*
* Encrypted module: field list matches the web-app registry entry for
* the `tasks` table:
* tasks: { enabled: true, fields: ['title', 'description', 'subtasks', 'metadata'] }
*
* Keep in lockstep with apps/mana/apps/web/src/lib/data/crypto/registry.ts
* a CI audit will diff these in M4.
*/
import { z } from 'zod';
import { decryptRecordFields, encryptRecordFields } from '@mana/shared-crypto';
import { pullAll, push, pushInsert } from '../sync-client.ts';
import { registerTool } from '../registry.ts';
import type { ToolContext, ToolSpec } from '../types.ts';
const APP_ID = 'todo';
const TABLE = 'tasks';
const ENCRYPTED_FIELDS = ['title', 'description', 'subtasks', 'metadata'] as const;
const SYNC_URL = () => process.env.MANA_SYNC_URL ?? 'http://localhost:3050';
const CLIENT_ID = () => process.env.MANA_MCP_CLIENT_ID ?? 'mana-mcp';
// ─── Domain shape (subset of LocalTask — fields the MCP surface needs) ──
const subtaskSchema = z.object({
id: z.string().uuid(),
title: z.string(),
isCompleted: z.boolean(),
completedAt: z.string().datetime().nullable().optional(),
order: z.number().int(),
});
const taskSchema = z.object({
id: z.string().uuid(),
title: z.string(),
description: z.string().optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']),
isCompleted: z.boolean(),
completedAt: z.string().datetime().nullable().optional(),
dueDate: z.string().datetime().nullable().optional(),
order: z.number().int(),
subtasks: z.array(subtaskSchema).nullable().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
});
type Task = z.infer<typeof taskSchema>;
type EncryptedTask = Record<string, unknown>;
function syncCfg(ctx: ToolContext) {
return { baseUrl: SYNC_URL(), jwt: ctx.jwt, clientId: CLIENT_ID() };
}
// ─── todo.create ──────────────────────────────────────────────────
const createInput = z.object({
title: z.string().min(1).max(500),
description: z.string().max(10_000).optional(),
priority: z.enum(['low', 'medium', 'high', 'urgent']).default('medium'),
dueDate: z.string().datetime().nullable().default(null),
});
const createOutput = z.object({ task: taskSchema });
export const todoCreate: ToolSpec<typeof createInput, typeof createOutput> = {
name: 'todo.create',
module: 'todo',
scope: 'user-space',
policyHint: 'write',
description:
'Create a new task. Title is required; description, priority, dueDate are optional. Returns the created task (decrypted).',
input: createInput,
output: createOutput,
encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS },
async handler(input, ctx) {
const key = await ctx.getMasterKey();
const now = new Date().toISOString();
const existing = await pullAll<EncryptedTask>(syncCfg(ctx), APP_ID, TABLE);
const order = existing.changes.filter((c) => c.op !== 'delete').length;
const plaintext: Task = {
id: crypto.randomUUID(),
title: input.title,
description: input.description,
priority: input.priority,
isCompleted: false,
completedAt: null,
dueDate: input.dueDate,
order,
subtasks: null,
metadata: undefined,
createdAt: now,
updatedAt: now,
};
const encrypted = await encryptRecordFields(
plaintext as unknown as Record<string, unknown>,
ENCRYPTED_FIELDS,
key
);
await pushInsert(syncCfg(ctx), APP_ID, {
table: TABLE,
id: plaintext.id,
spaceId: ctx.spaceId,
data: encrypted,
});
ctx.logger.info('todo.create', { taskId: plaintext.id, spaceId: ctx.spaceId });
return { task: plaintext };
},
};
// ─── todo.list ────────────────────────────────────────────────────
const listInput = z.object({
includeCompleted: z.boolean().default(false),
limit: z.number().int().positive().max(500).default(50),
});
const listOutput = z.object({ tasks: z.array(taskSchema) });
export const todoList: ToolSpec<typeof listInput, typeof listOutput> = {
name: 'todo.list',
module: 'todo',
scope: 'user-space',
policyHint: 'read',
description:
'List tasks in the active space. Completed tasks excluded by default. Values are returned decrypted.',
input: listInput,
output: listOutput,
encryptedFields: { table: TABLE, fields: ENCRYPTED_FIELDS },
async handler(input, ctx) {
const key = await ctx.getMasterKey();
const res = await pullAll<EncryptedTask>(syncCfg(ctx), APP_ID, TABLE);
const alive = res.changes.filter((c) => c.op !== 'delete' && c.data).map((c) => c.data!);
const decrypted = await Promise.all(
alive.map((row) => decryptRecordFields(row, ENCRYPTED_FIELDS, key))
);
const tasks = decrypted
.filter((t) => input.includeCompleted || !t.isCompleted)
.slice(0, input.limit) as unknown as Task[];
return { tasks };
},
};
// ─── todo.complete ────────────────────────────────────────────────
const completeInput = z.object({
id: z.string().uuid(),
});
const completeOutput = z.object({ ok: z.literal(true) });
export const todoComplete: ToolSpec<typeof completeInput, typeof completeOutput> = {
name: 'todo.complete',
module: 'todo',
scope: 'user-space',
policyHint: 'write',
description:
'Mark a task as completed by id. Idempotent — completing an already-completed task is a no-op on the server side.',
input: completeInput,
output: completeOutput,
async handler(input, ctx) {
const now = new Date().toISOString();
await push(syncCfg(ctx), APP_ID, [
{
table: TABLE,
id: input.id,
op: 'update',
spaceId: ctx.spaceId,
fields: {
isCompleted: { value: true, updatedAt: now },
completedAt: { value: now, updatedAt: now },
updatedAt: { value: now, updatedAt: now },
},
},
]);
ctx.logger.info('todo.complete', { taskId: input.id });
return { ok: true };
},
};
// ─── Registration barrel ──────────────────────────────────────────
export function registerTodoTools(): void {
registerTool(todoCreate);
registerTool(todoList);
registerTool(todoComplete);
}

View file

@ -0,0 +1,41 @@
/**
* In-memory tool registry.
*
* Tools are registered at consumer-process startup (see `modules/index.ts`
* barrel). Registration is idempotent by name a duplicate throws, because
* two specs with the same name would be a silent correctness bug.
*/
import type { z } from 'zod';
import type { AnyToolSpec, ModuleId, ToolSpec } from './types.ts';
const registry = new Map<string, AnyToolSpec>();
export function registerTool<I extends z.ZodTypeAny, O extends z.ZodTypeAny>(
spec: ToolSpec<I, O>
): void {
if (registry.has(spec.name)) {
throw new Error(
`tool-registry: duplicate tool name "${spec.name}". ` +
`Each tool must have a unique canonical name across all modules.`
);
}
registry.set(spec.name, spec as unknown as AnyToolSpec);
}
export function getTool(name: string): AnyToolSpec | undefined {
return registry.get(name);
}
export function getRegistry(): readonly AnyToolSpec[] {
return [...registry.values()];
}
export function getToolsByModule(module: ModuleId): readonly AnyToolSpec[] {
return getRegistry().filter((t) => t.module === module);
}
/** Test-only — the registry is a module-level singleton otherwise. */
export function __resetRegistryForTests(): void {
registry.clear();
}

View file

@ -0,0 +1,123 @@
/**
* Thin client for mana-sync's push/pull protocol.
*
* Tool handlers never touch Postgres directly they speak the same
* sync protocol the Dexie-backed clients use. This keeps RLS,
* field-level LWW, and membership checks intact.
*
* Wire format reference: services/mana-sync/CLAUDE.md
*/
export interface SyncFieldChange {
value: unknown;
updatedAt: string;
}
export interface SyncChange {
table: string;
id: string;
op: 'insert' | 'update' | 'delete';
spaceId?: string;
data?: Record<string, unknown>;
fields?: Record<string, SyncFieldChange>;
deletedAt?: string;
}
export interface SyncPushRequest {
clientId: string;
/**
* ISO timestamp; we pass the tool-call start time so the server's
* response only contains anything that changed *since* we started
* (not our own just-inserted row).
*/
since: string;
changes: SyncChange[];
}
export interface SyncPullResponse<TRow = Record<string, unknown>> {
changes: Array<{ table: string; id: string; op: string; data?: TRow }>;
syncedUntil: string;
}
export interface SyncClientConfig {
baseUrl: string;
jwt: string;
/** Stable identifier for the calling process — lands in sync_changes.client_id. */
clientId: string;
}
/**
* Push a single insert. Returns once mana-sync has persisted the row.
* Handlers that need multi-record writes should call `push()` directly
* with a batched changes array.
*/
export async function pushInsert(
config: SyncClientConfig,
appId: string,
change: Omit<SyncChange, 'op'>
): Promise<void> {
await push(config, appId, [{ ...change, op: 'insert' }]);
}
export async function push(
config: SyncClientConfig,
appId: string,
changes: SyncChange[]
): Promise<void> {
const body: SyncPushRequest = {
clientId: config.clientId,
since: new Date().toISOString(),
changes,
};
const res = await fetch(`${config.baseUrl}/sync/${appId}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${config.jwt}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '<unreadable body>');
throw new Error(
`mana-sync push failed: ${res.status} ${res.statusText}${text.slice(0, 500)}`
);
}
}
/**
* Pull all rows of a collection since a given timestamp. The registry
* uses this for `*.list` and `*.recent` tools we fetch the current
* state rather than maintaining our own cache, matching the local-first
* model where mana-sync is the source of truth.
*
* `since` defaults to epoch zero, which returns everything.
*/
export async function pullAll<TRow = Record<string, unknown>>(
config: SyncClientConfig,
appId: string,
collection: string,
since = '1970-01-01T00:00:00.000Z'
): Promise<SyncPullResponse<TRow>> {
const url = new URL(`${config.baseUrl}/sync/${appId}/pull`);
url.searchParams.set('collection', collection);
url.searchParams.set('since', since);
const res = await fetch(url, {
headers: {
authorization: `Bearer ${config.jwt}`,
'x-client-id': config.clientId,
},
});
if (!res.ok) {
const text = await res.text().catch(() => '<unreadable body>');
throw new Error(
`mana-sync pull failed: ${res.status} ${res.statusText}${text.slice(0, 500)}`
);
}
return (await res.json()) as SyncPullResponse<TRow>;
}

View file

@ -0,0 +1,124 @@
/**
* Core types for the shared tool registry.
*
* A ToolSpec is everything a consumer needs to: expose the tool
* (name, description, schemas), gate the tool (scope, policyHint),
* and run the tool (handler + ToolContext).
*
* Consumers today:
* - mana-mcp exposes tools over MCP to external agents
* - mana-ai invokes tools inside the mission-runner tick loop
*
* Both derive their JSON Schema from the zod definitions here; there
* is no parallel source of truth.
*/
import type { z } from 'zod';
export type ModuleId =
| 'habits'
| 'spaces'
// — M1.5+ additions below —
| 'todo'
| 'notes'
| 'journal'
| 'calendar'
| 'contacts'
| 'articles'
| 'missions'
| 'tags';
/**
* `user-space` operates on the caller's data within a specific Space.
* Exposable via MCP.
* `admin` mutates cross-user or system state (tier changes, user
* deletion). Never exposed via MCP; mana-ai consumes only
* if the mission is marked as admin-capable.
*/
export type ToolScope = 'user-space' | 'admin';
/**
* `read` idempotent, no state change. Safe to auto-invoke.
* `write` creates or updates user data. Requires explicit policy
* allowance in the consumer.
* `destructive` deletes or archives irreversibly. MCP refuses to expose
* these tools entirely; mana-ai requires explicit mission
* policy `auto: true` for the specific tool.
*/
export type PolicyHint = 'read' | 'write' | 'destructive';
/**
* Runtime context handed to every tool handler.
*
* Constructed by the consumer from the incoming request:
* - mana-mcp decodes the JWT and pulls spaceId from `X-Mana-Space`
* (defaulting to the user's active personal space)
* - mana-ai builds it from the mission's userId + spaceId
*/
export interface ToolContext {
userId: string;
spaceId: string;
/** Forwarded to downstream service calls (mana-sync etc.). */
jwt: string;
logger: Logger;
/** Identifies the invoker for audit trail rows (persona_actions etc.). */
invoker: 'mcp' | 'mana-ai' | 'admin';
/**
* Lazy master-key fetcher for tools that encrypt/decrypt user-visible
* fields. Throws `ZeroKnowledgeUserError` for ZK users (the server
* cannot open their vault) and `MasterKeyFetchError` on any other
* failure. Tools that only touch plaintext tables should never call
* this.
*/
getMasterKey(): Promise<CryptoKey>;
}
export interface Logger {
debug(msg: string, meta?: Record<string, unknown>): void;
info(msg: string, meta?: Record<string, unknown>): void;
warn(msg: string, meta?: Record<string, unknown>): void;
error(msg: string, meta?: Record<string, unknown>): void;
}
/**
* Single tool definition. Generic in the zod schemas so handlers receive
* the parsed (post-defaults) input type and return the validated output
* type. Schema-first generics keep `.default()`, `.transform()`, and
* `.refine()` working without manual Input/Output juggling.
*
* Registry storage uses `AnyToolSpec` (see registry.ts).
*/
export interface ToolSpec<
InputSchema extends z.ZodTypeAny = z.ZodTypeAny,
OutputSchema extends z.ZodTypeAny = z.ZodTypeAny,
> {
/** Canonical dot-name, e.g. `habits.create`. Unique across registry. */
readonly name: string;
/** Human-facing description — used by MCP, LLM system prompts. */
readonly description: string;
readonly module: ModuleId;
readonly scope: ToolScope;
readonly policyHint: PolicyHint;
readonly input: InputSchema;
readonly output: OutputSchema;
/**
* Declares which record fields the tool encrypts on write and decrypts
* on read. Set to the exact field list from
* `apps/mana/apps/web/src/lib/data/crypto/registry.ts` for the same
* table. Omit entirely for plaintext-only tools.
*
* Declarative rather than handler-internal so a consistency check can
* diff this against the web-app registry at build time (future CI
* audit).
*/
readonly encryptedFields?: {
readonly table: string;
readonly fields: readonly string[];
};
readonly handler: (
input: z.output<InputSchema>,
ctx: ToolContext
) => Promise<z.output<OutputSchema>>;
}
export type AnyToolSpec = ToolSpec<z.ZodTypeAny, z.ZodTypeAny>;

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,20 @@
{
"name": "@mana/shared-crypto",
"version": "0.1.0",
"private": true,
"type": "module",
"sideEffects": false,
"description": "Runtime-agnostic AES-GCM-256 primitives used by the web app, mana-mcp tool handlers, and any future agent-side consumer. Uses Web Crypto API only — works in Bun, Node 20+, and all modern browsers.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"lint": "eslint ."
},
"devDependencies": {
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,220 @@
/**
* AES-GCM-256 wrap/unwrap primitives runtime-agnostic.
*
* Pure crypto layer with no state, no Dexie dependency, no module registry.
* Web app + mana-mcp tool handlers + any future agent-side consumer share
* this exact wire format so round-tripping between writers/readers is safe.
*
* Wire format
* `enc:${VERSION}:${base64(iv)}.${base64(ct)}`
*
* The string-prefix format (rather than a JSON envelope) is deliberate:
* - One scan to detect "is this encrypted?" `value.startsWith('enc:1:')`
* - Survives JSON.stringify when records flow through the sync wire
* - Compact: ~1.4× the original byte length, vs ~2× for a JSON envelope
* - Trivial to bump VERSION for future format migrations
*
* Authenticated encryption: AES-GCM provides both confidentiality and
* tamper-detection. A modified ciphertext fails decryption with an
* OperationError instead of returning silent garbage `unwrapValue`
* surfaces that as a thrown error so callers can react.
*
* Value types: anything JSON-serialisable. The plaintext is JSON.stringified
* before encryption, JSON.parsed after decryption. `null` and `undefined`
* pass through unchanged so callers can blindly wrap optional fields
* without checking each one first.
*/
/** Bumped if the wire format ever changes. Old blobs stay readable as long
* as `unwrapValue` knows how to handle their version prefix. */
export const ENCRYPTION_VERSION = 1;
/** All encrypted blobs start with this exact prefix — used by `isEncrypted`. */
export const ENC_PREFIX = `enc:${ENCRYPTION_VERSION}:`;
/** AES-GCM standard IV length is 96 bits (12 bytes). Larger IVs are not
* recommended by NIST and would only burn entropy. */
const IV_LENGTH = 12;
// ─── Base64 helpers ───────────────────────────────────────────
//
// We avoid `btoa(String.fromCharCode(...bytes))` because the spread operator
// hits the JS argument limit (~65k) for large records. The manual loop is
// O(n) and works for any size.
function bytesToBase64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
/**
* TypeScript 5.7+ parameterised Uint8Array with the underlying buffer
* type, which now includes SharedArrayBuffer. Web Crypto's `BufferSource`
* type still expects a plain ArrayBuffer-backed view, so we need to copy
* the bytes through a fresh ArrayBuffer to satisfy the strict type check.
*
* This is a TypeScript-only annoyance at runtime the call would have
* worked fine with the original Uint8Array. The copy is O(n) and
* negligible for the field sizes we encrypt (< 100 KB typical).
*/
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
const buf = new ArrayBuffer(bytes.length);
new Uint8Array(buf).set(bytes);
return buf;
}
// ─── Public API ───────────────────────────────────────────────
/**
* Returns true iff `value` is a string carrying the encryption prefix.
*
* Cheap synchronous detection no decryption attempted. Use this to
* decide whether a field needs to be unwrapped on read, or whether a
* value coming back from a backend pull is already encrypted.
*/
export function isEncrypted(value: unknown): boolean {
return typeof value === 'string' && value.startsWith(ENC_PREFIX);
}
/**
* Encrypts `value` with `key` and returns the wire-format string. Pass-
* through for `null` / `undefined` so optional-field call sites stay
* concise:
*
* record.title = await wrapValue(record.title, key);
* record.notes = await wrapValue(record.notes, key); // safe even if null
*
* Throws if `key` is unusable (wrong algorithm, wrong usages). Each call
* generates a fresh random IV never reuse one for the same key.
*/
export async function wrapValue(value: unknown, key: CryptoKey): Promise<unknown> {
if (value === null || value === undefined) return value;
const json = JSON.stringify(value);
const plaintext = new TextEncoder().encode(json);
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: toBufferSource(iv) },
key,
toBufferSource(plaintext)
);
return ENC_PREFIX + bytesToBase64(iv) + '.' + bytesToBase64(new Uint8Array(ct));
}
/**
* Decrypts a wire-format string back to its original JS value. Pass-
* through for non-strings, `null`/`undefined`, and any string that
* doesn't carry the encryption prefix that way `unwrapValue` is safe
* to apply unconditionally to mixed records.
*
* Throws on tampered ciphertext (AES-GCM auth tag mismatch), malformed
* blobs, or wrong key. Callers should treat the throw as data corruption
* there's no soft-recovery path.
*/
export async function unwrapValue(blob: unknown, key: CryptoKey): Promise<unknown> {
if (!isEncrypted(blob)) return blob;
const body = (blob as string).slice(ENC_PREFIX.length);
const dotIndex = body.indexOf('.');
if (dotIndex === -1) {
throw new Error('mana-crypto: malformed encrypted blob (missing iv/ct separator)');
}
const iv = base64ToBytes(body.slice(0, dotIndex));
const ct = base64ToBytes(body.slice(dotIndex + 1));
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: toBufferSource(iv) },
key,
toBufferSource(ct)
);
const json = new TextDecoder().decode(plaintext);
return JSON.parse(json);
}
/**
* Generates a fresh AES-GCM-256 key. Used at vault initialisation time
* (Phase 2: server-side; tests: in-memory) to mint the per-user master
* key. The key is `extractable: true` so the server can wrap it with
* the KEK before storing set to `false` for client-side derived keys
* that should never leave the browser.
*/
export async function generateMasterKey(extractable = true): Promise<CryptoKey> {
return crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, extractable, [
'encrypt',
'decrypt',
]);
}
/**
* Imports a raw 32-byte buffer as an AES-GCM-256 key. Used by the
* Phase 3 client to take the bytes the vault endpoint returns and turn
* them into a non-extractable CryptoKey instance for runtime use.
*/
export async function importMasterKey(rawBytes: Uint8Array): Promise<CryptoKey> {
if (rawBytes.length !== 32) {
throw new Error(`mana-crypto: expected 32-byte master key, got ${rawBytes.length}`);
}
return crypto.subtle.importKey(
'raw',
toBufferSource(rawBytes),
{ name: 'AES-GCM', length: 256 },
false, // non-extractable: once it's in the browser, it stays there
['encrypt', 'decrypt']
);
}
/**
* Exports a key back to its raw 32 bytes. Only works on extractable
* keys; non-extractable keys throw. Used by tests and the Phase 2
* server-side wrap path.
*/
export async function exportMasterKey(key: CryptoKey): Promise<Uint8Array> {
const raw = await crypto.subtle.exportKey('raw', key);
return new Uint8Array(raw);
}
// ─── Batch helpers (new in M1.5) ──────────────────────────────
//
// Tool handlers work record-at-a-time and need an explicit field list
// (the web app uses a registry lookup, but we keep shared-crypto registry-
// free to avoid a cyclic import with the module type definitions). The
// caller passes the list — typically derived from the authoritative
// registry in apps/mana/apps/web/src/lib/data/crypto/registry.ts.
/** Shallow-copy `record` with the named fields wrap-encrypted. Nullish fields stay nullish. */
export async function encryptRecordFields<T extends Record<string, unknown>>(
record: T,
fields: readonly (keyof T & string)[],
key: CryptoKey
): Promise<T> {
const out = { ...record };
for (const field of fields) {
out[field] = (await wrapValue(out[field], key)) as T[typeof field];
}
return out;
}
/** Shallow-copy `record` with the named fields decrypted. Pass-through for non-encrypted values. */
export async function decryptRecordFields<T extends Record<string, unknown>>(
record: T,
fields: readonly (keyof T & string)[],
key: CryptoKey
): Promise<T> {
const out = { ...record };
for (const field of fields) {
out[field] = (await unwrapValue(out[field], key)) as T[typeof field];
}
return out;
}

View file

@ -0,0 +1,12 @@
export {
ENC_PREFIX,
ENCRYPTION_VERSION,
decryptRecordFields,
encryptRecordFields,
exportMasterKey,
generateMasterKey,
importMasterKey,
isEncrypted,
unwrapValue,
wrapValue,
} from './aes.ts';

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"lib": ["ES2022", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

473
pnpm-lock.yaml generated
View file

@ -92,7 +92,7 @@ importers:
version: 6.0.154(zod@3.25.76) version: 6.0.154(zod@3.25.76)
drizzle-orm: drizzle-orm:
specifier: ^0.38.0 specifier: ^0.38.0
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
hono: hono:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.12.12 version: 4.12.12
@ -108,7 +108,7 @@ importers:
devDependencies: devDependencies:
'@types/bun': '@types/bun':
specifier: latest specifier: latest
version: 1.3.12 version: 1.3.13
drizzle-kit: drizzle-kit:
specifier: ^0.30.0 specifier: ^0.30.0
version: 0.30.6 version: 0.30.6
@ -138,14 +138,14 @@ importers:
version: link:../../../../packages/shared-landing-ui version: link:../../../../packages/shared-landing-ui
astro: astro:
specifier: ^5.16.0 specifier: ^5.16.0
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
typescript: typescript:
specifier: ^5.9.2 specifier: ^5.9.2
version: 5.9.3 version: 5.9.3
devDependencies: devDependencies:
'@astrojs/tailwind': '@astrojs/tailwind':
specifier: ^6.0.2 specifier: ^6.0.2
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
'@tailwindcss/typography': '@tailwindcss/typography':
specifier: ^0.5.18 specifier: ^0.5.18
version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
@ -154,13 +154,13 @@ importers:
version: 20.19.39 version: 20.19.39
eslint: eslint:
specifier: ^9.0.0 specifier: ^9.0.0
version: 9.39.4(jiti@2.6.1) version: 9.39.4(jiti@1.21.7)
eslint-config-prettier: eslint-config-prettier:
specifier: ^9.1.0 specifier: ^9.1.0
version: 9.1.2(eslint@9.39.4(jiti@2.6.1)) version: 9.1.2(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-astro: eslint-plugin-astro:
specifier: ^1.0.0 specifier: ^1.0.0
version: 1.6.0(eslint@9.39.4(jiti@2.6.1)) version: 1.6.0(eslint@9.39.4(jiti@1.21.7))
prettier: prettier:
specifier: ^3.6.2 specifier: ^3.6.2
version: 3.8.1 version: 3.8.1
@ -253,10 +253,10 @@ importers:
version: 3.7.2 version: 3.7.2
'@astrojs/tailwind': '@astrojs/tailwind':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3)) version: 6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))
astro: astro:
specifier: ^5.16.11 specifier: ^5.16.11
version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) version: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
tailwindcss: tailwindcss:
specifier: ^3.4.17 specifier: ^3.4.17
version: 3.4.19(tsx@4.21.0)(yaml@2.8.3) version: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
@ -525,6 +525,9 @@ importers:
'@mana/shared-branding': '@mana/shared-branding':
specifier: workspace:* specifier: workspace:*
version: link:../../../../packages/shared-branding version: link:../../../../packages/shared-branding
'@mana/shared-crypto':
specifier: workspace:*
version: link:../../../../packages/shared-crypto
'@mana/shared-error-tracking': '@mana/shared-error-tracking':
specifier: workspace:* specifier: workspace:*
version: link:../../../../packages/shared-error-tracking version: link:../../../../packages/shared-error-tracking
@ -1628,7 +1631,7 @@ importers:
version: link:../../../../packages/shared-hono version: link:../../../../packages/shared-hono
drizzle-orm: drizzle-orm:
specifier: ^0.44.7 specifier: ^0.44.7
version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.13)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9)
hono: hono:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.12.12 version: 4.12.12
@ -1650,7 +1653,7 @@ importers:
devDependencies: devDependencies:
drizzle-orm: drizzle-orm:
specifier: ^0.44.7 specifier: ^0.44.7
version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.13)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9)
postgres: postgres:
specifier: ^3.4.7 specifier: ^3.4.7
version: 3.4.9 version: 3.4.9
@ -1680,7 +1683,7 @@ importers:
devDependencies: devDependencies:
'@types/bun': '@types/bun':
specifier: latest specifier: latest
version: 1.3.12 version: 1.3.13
typescript: typescript:
specifier: ^5.7.2 specifier: ^5.7.2
version: 5.9.3 version: 5.9.3
@ -1924,6 +1927,19 @@ importers:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
packages/mana-tool-registry:
dependencies:
'@mana/shared-crypto':
specifier: workspace:*
version: link:../shared-crypto
zod:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
typescript:
specifier: ^5.9.3
version: 5.9.3
packages/notify-client: packages/notify-client:
devDependencies: devDependencies:
'@nestjs/common': '@nestjs/common':
@ -2051,6 +2067,12 @@ importers:
specifier: ^5.7.3 specifier: ^5.7.3
version: 5.9.3 version: 5.9.3
packages/shared-crypto:
devDependencies:
typescript:
specifier: ^5.9.3
version: 5.9.3
packages/shared-drizzle-config: packages/shared-drizzle-config:
dependencies: dependencies:
drizzle-kit: drizzle-kit:
@ -2093,7 +2115,7 @@ importers:
version: link:../shared-logger version: link:../shared-logger
drizzle-orm: drizzle-orm:
specifier: ^0.45.1 specifier: ^0.45.1
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.13)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9)
hono: hono:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.12.12 version: 4.12.12
@ -2106,7 +2128,7 @@ importers:
devDependencies: devDependencies:
'@types/bun': '@types/bun':
specifier: latest specifier: latest
version: 1.3.12 version: 1.3.13
'@types/node': '@types/node':
specifier: ^24.10.1 specifier: ^24.10.1
version: 24.12.2 version: 24.12.2
@ -2568,7 +2590,7 @@ importers:
devDependencies: devDependencies:
'@types/bun': '@types/bun':
specifier: latest specifier: latest
version: 1.3.12 version: 1.3.13
typescript: typescript:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
@ -2580,7 +2602,7 @@ importers:
version: link:../../packages/shared-hono version: link:../../packages/shared-hono
drizzle-orm: drizzle-orm:
specifier: ^0.38.3 specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
hono: hono:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.12.12 version: 4.12.12
@ -2619,10 +2641,10 @@ importers:
version: 3.0.3 version: 3.0.3
better-auth: better-auth:
specifier: ^1.4.3 specifier: ^1.4.3
version: 1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3) version: 1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3)
drizzle-orm: drizzle-orm:
specifier: ^0.38.3 specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
hono: hono:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.12.12 version: 4.12.12
@ -2661,7 +2683,7 @@ importers:
version: 3.0.3 version: 3.0.3
drizzle-orm: drizzle-orm:
specifier: ^0.38.3 specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
hono: hono:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.12.12 version: 4.12.12
@ -2692,7 +2714,7 @@ importers:
dependencies: dependencies:
drizzle-orm: drizzle-orm:
specifier: ^0.38.3 specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
hono: hono:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.12.12 version: 4.12.12
@ -2788,7 +2810,7 @@ importers:
version: link:../../packages/shared-hono version: link:../../packages/shared-hono
drizzle-orm: drizzle-orm:
specifier: ^0.38.3 specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
hono: hono:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.12.12 version: 4.12.12
@ -2812,6 +2834,34 @@ importers:
specifier: ^5.9.3 specifier: ^5.9.3
version: 5.9.3 version: 5.9.3
services/mana-mcp:
dependencies:
'@mana/shared-hono':
specifier: workspace:*
version: link:../../packages/shared-hono
'@mana/tool-registry':
specifier: workspace:*
version: link:../../packages/mana-tool-registry
'@modelcontextprotocol/sdk':
specifier: ^1.29.0
version: 1.29.0(zod@3.25.76)
hono:
specifier: ^4.7.0
version: 4.12.12
jose:
specifier: ^6.1.2
version: 6.2.2
zod:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
'@types/bun':
specifier: ^1.1.16
version: 1.3.13
typescript:
specifier: ^5.9.3
version: 5.9.3
services/mana-media: {} services/mana-media: {}
services/mana-media/apps/api: services/mana-media/apps/api:
@ -2821,7 +2871,7 @@ importers:
version: 5.73.0 version: 5.73.0
drizzle-orm: drizzle-orm:
specifier: ^0.38.3 specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
exifr: exifr:
specifier: ^7.1.3 specifier: ^7.1.3
version: 7.1.3 version: 7.1.3
@ -2881,7 +2931,7 @@ importers:
version: link:../../packages/shared-research version: link:../../packages/shared-research
drizzle-orm: drizzle-orm:
specifier: ^0.38.3 specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
hono: hono:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.12.12 version: 4.12.12
@ -2914,7 +2964,7 @@ importers:
version: link:../../packages/shared-hono version: link:../../packages/shared-hono
drizzle-orm: drizzle-orm:
specifier: ^0.38.3 specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
hono: hono:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.12.12 version: 4.12.12
@ -2947,7 +2997,7 @@ importers:
version: link:../../packages/shared-hono version: link:../../packages/shared-hono
drizzle-orm: drizzle-orm:
specifier: ^0.38.3 specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
hono: hono:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.12.12 version: 4.12.12
@ -2975,7 +3025,7 @@ importers:
version: link:../../packages/shared-rss version: link:../../packages/shared-rss
drizzle-orm: drizzle-orm:
specifier: ^0.38.3 specifier: ^0.38.3
version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
hono: hono:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.12.12 version: 4.12.12
@ -8001,8 +8051,8 @@ packages:
'@types/bun@1.3.11': '@types/bun@1.3.11':
resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==} resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==}
'@types/bun@1.3.12': '@types/bun@1.3.13':
resolution: {integrity: sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A==} resolution: {integrity: sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==}
'@types/chai@5.2.3': '@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -9080,8 +9130,8 @@ packages:
bun-types@1.3.11: bun-types@1.3.11:
resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==} resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==}
bun-types@1.3.12: bun-types@1.3.13:
resolution: {integrity: sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA==} resolution: {integrity: sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==}
bundle-require@5.1.0: bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
@ -16951,6 +17001,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- ts-node - ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
autoprefixer: 10.4.27(postcss@8.5.8)
postcss: 8.5.8
postcss-load-config: 4.0.2(postcss@8.5.8)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies: dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
@ -16971,16 +17031,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- ts-node - ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
autoprefixer: 10.4.27(postcss@8.5.8)
postcss: 8.5.8
postcss-load-config: 4.0.2(postcss@8.5.8)
tailwindcss: 3.4.19(tsx@4.21.0)(yaml@2.8.3)
transitivePeerDependencies:
- ts-node
'@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))': '@astrojs/tailwind@6.0.2(astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.3))':
dependencies: dependencies:
astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) astro: 5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
@ -18525,12 +18575,12 @@ snapshots:
nanostores: 1.2.0 nanostores: 1.2.0
zod: 4.3.6 zod: 4.3.6
'@better-auth/drizzle-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))': '@better-auth/drizzle-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))':
dependencies: dependencies:
'@better-auth/core': 1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) '@better-auth/core': 1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0)
'@better-auth/utils': 0.4.0 '@better-auth/utils': 0.4.0
optionalDependencies: optionalDependencies:
drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
'@better-auth/kysely-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(kysely@0.28.15)': '@better-auth/kysely-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(kysely@0.28.15)':
dependencies: dependencies:
@ -19140,6 +19190,11 @@ snapshots:
'@esbuild/win32-x64@0.27.7': '@esbuild/win32-x64@0.27.7':
optional: true optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
dependencies: dependencies:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
@ -23136,9 +23191,9 @@ snapshots:
dependencies: dependencies:
bun-types: 1.3.11 bun-types: 1.3.11
'@types/bun@1.3.12': '@types/bun@1.3.13':
dependencies: dependencies:
bun-types: 1.3.12 bun-types: 1.3.13
'@types/chai@5.2.3': '@types/chai@5.2.3':
dependencies: dependencies:
@ -24062,6 +24117,108 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
'@astrojs/markdown-remark': 6.3.11
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 4.0.0
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
acorn: 8.16.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.4.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.1.1
cssesc: 3.0.0
debug: 4.4.3
deterministic-object-hash: 2.0.2
devalue: 5.7.0
diff: 8.0.4
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.27.7
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.4.1
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.2.0
js-yaml: 4.1.1
magic-string: 0.30.21
magicast: 0.5.2
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.1
package-manager-detector: 1.6.0
piccolore: 0.1.3
picomatch: 4.0.4
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.4
shiki: 3.23.0
smol-toml: 1.6.1
svgo: 4.0.1
tinyexec: 1.0.4
tinyglobby: 0.2.15
tsconfck: 3.1.6(typescript@5.9.3)
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
vfile: 6.0.3
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu: 1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
optionalDependencies:
sharp: 0.34.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- db0
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@20.19.39)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies: dependencies:
'@astrojs/compiler': 2.13.1 '@astrojs/compiler': 2.13.1
@ -24266,108 +24423,6 @@ snapshots:
- uploadthing - uploadthing
- yaml - yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@1.21.7)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
'@astrojs/markdown-remark': 6.3.11
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 4.0.0
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
acorn: 8.16.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.4.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.1.1
cssesc: 3.0.0
debug: 4.4.3
deterministic-object-hash: 2.0.2
devalue: 5.7.0
diff: 8.0.4
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.27.7
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.4.1
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.2.0
js-yaml: 4.1.1
magic-string: 0.30.21
magicast: 0.5.2
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.1
package-manager-detector: 1.6.0
piccolore: 0.1.3
picomatch: 4.0.4
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.4
shiki: 3.23.0
smol-toml: 1.6.1
svgo: 4.0.1
tinyexec: 1.0.4
tinyglobby: 0.2.15
tsconfck: 3.1.6(typescript@5.9.3)
ultrahtml: 1.6.0
unifont: 0.7.4
unist-util-visit: 5.1.0
unstorage: 1.17.5(@azure/storage-blob@12.31.0)(ioredis@5.10.1)
vfile: 6.0.3
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu: 1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.25.2(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76)
optionalDependencies:
sharp: 0.34.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/functions'
- '@vercel/kv'
- aws4fetch
- db0
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): astro@5.18.1(@azure/storage-blob@12.31.0)(@types/node@24.12.2)(ioredis@5.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3):
dependencies: dependencies:
'@astrojs/compiler': 2.13.1 '@astrojs/compiler': 2.13.1
@ -24703,10 +24758,10 @@ snapshots:
bcryptjs@3.0.3: {} bcryptjs@3.0.3: {}
better-auth@1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3): better-auth@1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3):
dependencies: dependencies:
'@better-auth/core': 1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) '@better-auth/core': 1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0)
'@better-auth/drizzle-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)) '@better-auth/drizzle-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))
'@better-auth/kysely-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(kysely@0.28.15) '@better-auth/kysely-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(kysely@0.28.15)
'@better-auth/memory-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0) '@better-auth/memory-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)
'@better-auth/mongo-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0) '@better-auth/mongo-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)
@ -24725,7 +24780,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@sveltejs/kit': 2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) '@sveltejs/kit': 2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
drizzle-kit: 0.30.6 drizzle-kit: 0.30.6
drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)
react: 19.2.0 react: 19.2.0
react-dom: 19.2.0(react@19.2.0) react-dom: 19.2.0(react@19.2.0)
svelte: 5.55.1 svelte: 5.55.1
@ -24896,7 +24951,7 @@ snapshots:
dependencies: dependencies:
'@types/node': 22.19.17 '@types/node': 22.19.17
bun-types@1.3.12: bun-types@1.3.13:
dependencies: dependencies:
'@types/node': 22.19.17 '@types/node': 22.19.17
@ -25656,30 +25711,30 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0): drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.13)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0):
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@types/pg': 8.6.1 '@types/pg': 8.6.1
'@types/react': 19.2.14 '@types/react': 19.2.14
bun-types: 1.3.12 bun-types: 1.3.13
kysely: 0.28.15 kysely: 0.28.15
postgres: 3.4.9 postgres: 3.4.9
react: 19.2.0 react: 19.2.0
drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9): drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.13)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9):
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@types/pg': 8.6.1 '@types/pg': 8.6.1
bun-types: 1.3.12 bun-types: 1.3.13
gel: 2.2.0 gel: 2.2.0
kysely: 0.28.15 kysely: 0.28.15
postgres: 3.4.9 postgres: 3.4.9
drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9): drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.13)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9):
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
'@types/pg': 8.6.1 '@types/pg': 8.6.1
bun-types: 1.3.12 bun-types: 1.3.13
gel: 2.2.0 gel: 2.2.0
kysely: 0.28.15 kysely: 0.28.15
postgres: 3.4.9 postgres: 3.4.9
@ -26187,6 +26242,11 @@ snapshots:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
semver: 7.7.4 semver: 7.7.4
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
semver: 7.7.4
eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)): eslint-compat-utils@0.6.5(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
@ -26196,6 +26256,10 @@ snapshots:
dependencies: dependencies:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@1.21.7)):
dependencies:
eslint: 9.39.4(jiti@1.21.7)
eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)): eslint-config-prettier@9.1.2(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
eslint: 9.39.4(jiti@2.6.1) eslint: 9.39.4(jiti@2.6.1)
@ -26240,6 +26304,20 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@jridgewell/sourcemap-codec': 1.5.5
'@typescript-eslint/types': 8.58.0
astro-eslint-parser: 1.4.0
eslint: 9.39.4(jiti@1.21.7)
eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@1.21.7))
globals: 16.5.0
postcss: 8.5.8
postcss-selector-parser: 7.1.1
transitivePeerDependencies:
- supports-color
eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)): eslint-plugin-astro@1.6.0(eslint@9.39.4(jiti@2.6.1)):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
@ -26413,6 +26491,47 @@ snapshots:
eslint-visitor-keys@5.0.1: {} eslint-visitor-keys@5.0.1: {}
eslint@9.39.4(jiti@1.21.7):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.5
'@eslint/js': 9.39.4
'@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.14.0
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 1.21.7
transitivePeerDependencies:
- supports-color
eslint@9.39.4(jiti@2.6.1): eslint@9.39.4(jiti@2.6.1):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1))
@ -33490,6 +33609,23 @@ snapshots:
lightningcss: 1.32.0 lightningcss: 1.32.0
terser: 5.46.1 terser: 5.46.1
vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 20.19.39
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.32.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
@ -33524,23 +33660,6 @@ snapshots:
tsx: 4.21.0 tsx: 4.21.0
yaml: 2.8.3 yaml: 2.8.3
vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.12.2
fsevents: 2.3.3
jiti: 1.21.7
lightningcss: 1.32.0
terser: 5.46.1
tsx: 4.21.0
yaml: 2.8.3
vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3): vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
@ -33558,6 +33677,10 @@ snapshots:
tsx: 4.21.0 tsx: 4.21.0
yaml: 2.8.3 yaml: 2.8.3
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@20.19.39)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vitefu@1.1.3(vite@6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies: optionalDependencies:
vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 6.4.2(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
@ -33566,10 +33689,6 @@ snapshots:
optionalDependencies: optionalDependencies:
vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies:
vite: 6.4.2(@types/node@24.12.2)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)): vitefu@1.1.3(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
optionalDependencies: optionalDependencies:
vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3) vite: 6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)

148
services/mana-mcp/CLAUDE.md Normal file
View file

@ -0,0 +1,148 @@
# 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`](../../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
```bash
# 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.
```bash
# 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
```env
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.

View file

@ -0,0 +1,24 @@
{
"name": "@mana/mcp-service",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "MCP (Model Context Protocol) gateway for Mana. Exposes the shared @mana/tool-registry over Streamable HTTP so external agents (Claude Desktop, persona-runner) can drive the user's modules through their normal JWT-authed paths.",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@mana/shared-hono": "workspace:*",
"@mana/tool-registry": "workspace:*",
"@modelcontextprotocol/sdk": "^1.29.0",
"hono": "^4.7.0",
"jose": "^6.1.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@types/bun": "^1.1.16",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,93 @@
/**
* JWT verification for MCP requests.
*
* Mirrors the pattern in services/mana-research/src/middleware/jwt-auth.ts
* JWKS-cached verification against mana-auth, audience pinned to "mana".
*
* Returns the resolved user context (or throws 401) so the MCP transport
* handler can hand it directly to the registry adapter.
*/
import { createRemoteJWKSet, jwtVerify } from 'jose';
export interface VerifiedUser {
userId: string;
email: string;
role: string;
tier: string;
/** Active Space at the moment of the request. May be overridden by `X-Mana-Space`. */
spaceId: string;
/** The raw JWT, forwarded to downstream services in tool handlers. */
jwt: string;
}
let cachedJwks: ReturnType<typeof createRemoteJWKSet> | null = null;
let cachedJwksUrl: string | null = null;
function getJwks(authUrl: string): ReturnType<typeof createRemoteJWKSet> {
const url = `${authUrl}/api/auth/jwks`;
if (cachedJwks && cachedJwksUrl === url) return cachedJwks;
cachedJwks = createRemoteJWKSet(new URL(url));
cachedJwksUrl = url;
return cachedJwks;
}
export class UnauthorizedError extends Error {
constructor(message: string) {
super(message);
this.name = 'UnauthorizedError';
}
}
export async function verifyJwt(
token: string,
authUrl: string,
audience: string
): Promise<Omit<VerifiedUser, 'spaceId' | 'jwt'>> {
try {
const { payload } = await jwtVerify(token, getJwks(authUrl), { audience });
const userId = (payload.sub as string | undefined) ?? '';
if (!userId) throw new UnauthorizedError('Token has no `sub` claim');
return {
userId,
email: (payload.email as string | undefined) ?? '',
role: (payload.role as string | undefined) ?? 'user',
tier: (payload.tier as string | undefined) ?? 'public',
};
} catch (err) {
if (err instanceof UnauthorizedError) throw err;
throw new UnauthorizedError(
err instanceof Error ? `Invalid token: ${err.message}` : 'Invalid token'
);
}
}
/**
* Pull `Authorization: Bearer ...` and `X-Mana-Space: ...` out of an
* incoming Request, verify the token, and return the assembled user
* context. Throws UnauthorizedError on any auth failure.
*/
export async function authenticateRequest(
req: Request,
authUrl: string,
audience: string
): Promise<VerifiedUser> {
const header = req.headers.get('authorization');
if (!header || !header.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing or malformed Authorization header');
}
const token = header.slice('Bearer '.length).trim();
const verified = await verifyJwt(token, authUrl, audience);
const spaceHeader = req.headers.get('x-mana-space');
const spaceId = spaceHeader && spaceHeader.length > 0 ? spaceHeader : '';
if (!spaceId) {
// We *could* default to the user's personal Space, but resolving that
// requires another round-trip to mana-auth. For M1 we require the
// caller to set X-Mana-Space explicitly — Persona-Runner and Claude
// Desktop both set it from `spaces.list` results.
throw new UnauthorizedError('Missing X-Mana-Space header (set to the active Space ID)');
}
return { ...verified, spaceId, jwt: token };
}

View file

@ -0,0 +1,35 @@
/**
* Service configuration. All values come from env. Defaults match local
* dev (`pnpm setup:env` writes the same values into .env files).
*/
export interface Config {
port: number;
authUrl: string;
jwtAudience: string;
manaSyncUrl: string;
corsOrigins: string[];
}
function intEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${name} must be a positive integer, got "${raw}"`);
}
return n;
}
export function loadConfig(): Config {
return {
port: intEnv('PORT', 3069),
authUrl: process.env.MANA_AUTH_URL ?? 'http://localhost:3001',
jwtAudience: process.env.JWT_AUDIENCE ?? 'mana',
manaSyncUrl: process.env.MANA_SYNC_URL ?? 'http://localhost:3050',
corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
};
}

View file

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

View file

@ -0,0 +1,127 @@
/**
* Bridges `@mana/tool-registry` MCP `McpServer`.
*
* For each tool in the registry, we register an MCP tool whose:
* - name comes verbatim from the spec
* - description comes verbatim from the spec
* - input shape is the registry's zod schema (already typed)
* - handler invokes the registry handler with a fully-built ToolContext
*
* MCP's tool-output convention is `{ content: [{ type: 'text', text }] }`,
* so we serialize the registry handler's parsed output to JSON and wrap.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z, type ZodObject, type ZodRawShape } from 'zod';
import {
MasterKeyClient,
getRegistry,
type AnyToolSpec,
type Logger,
type ToolContext,
} from '@mana/tool-registry';
import type { VerifiedUser } from './auth.ts';
/**
* Shared across all sessions the client caches MKs per userId with a
* short TTL so a single MCP session invoking N encrypted tools fetches
* the vault at most once per TTL window.
*/
const masterKeyClient = new MasterKeyClient({
authUrl: process.env.MANA_AUTH_URL ?? 'http://localhost:3001',
});
/** Tools with `scope: 'admin'` are never exposed to MCP clients. */
function isExposable(spec: AnyToolSpec): boolean {
return spec.scope === 'user-space';
}
/**
* Extract the raw shape from a zod object schema. The MCP SDK's
* `server.tool()` API expects `Record<string, ZodTypeAny>`, not a wrapping
* `ZodObject`. Tools that don't use a ZodObject input (uncommon most are
* objects) get registered without parameters.
*/
function shapeOf(schema: AnyToolSpec['input']): ZodRawShape | null {
if (schema instanceof z.ZodObject) {
return (schema as ZodObject<ZodRawShape>).shape;
}
return null;
}
function makeLogger(prefix: string): Logger {
const fmt = (level: string, msg: string, meta?: Record<string, unknown>): string =>
meta && Object.keys(meta).length > 0
? `[${level}] ${prefix} ${msg} ${JSON.stringify(meta)}`
: `[${level}] ${prefix} ${msg}`;
return {
debug: (msg, meta) => console.debug(fmt('debug', msg, meta)),
info: (msg, meta) => console.info(fmt('info', msg, meta)),
warn: (msg, meta) => console.warn(fmt('warn', msg, meta)),
error: (msg, meta) => console.error(fmt('error', msg, meta)),
};
}
/**
* Build an MCP server bound to a single user/session. Each MCP session gets
* its own server instance userId and JWT are captured in closures so tools
* can never leak across sessions.
*/
export function createMcpServerForUser(user: VerifiedUser): McpServer {
const server = new McpServer({ name: 'mana', version: '0.1.0' }, { capabilities: { tools: {} } });
const baseCtx: Omit<ToolContext, 'logger'> = {
userId: user.userId,
spaceId: user.spaceId,
jwt: user.jwt,
invoker: 'mcp',
getMasterKey: () => masterKeyClient.getKey(user.userId, user.jwt),
};
for (const spec of getRegistry()) {
if (!isExposable(spec)) continue;
const shape = shapeOf(spec.input);
const ctxFor = (toolName: string): ToolContext => ({
...baseCtx,
logger: makeLogger(`tool=${toolName} user=${user.userId.slice(0, 8)}`),
});
const invoke = async (rawArgs: unknown) => {
let parsed: unknown;
try {
parsed = spec.input.parse(rawArgs);
} catch (err) {
const msg =
err instanceof z.ZodError
? err.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')
: String(err);
return {
isError: true,
content: [{ type: 'text' as const, text: `Invalid input for ${spec.name}: ${msg}` }],
};
}
try {
const result = await spec.handler(parsed, ctxFor(spec.name));
return {
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
isError: true,
content: [{ type: 'text' as const, text: `Tool ${spec.name} failed: ${msg}` }],
};
}
};
if (shape && Object.keys(shape).length > 0) {
server.tool(spec.name, spec.description, shape, invoke);
} else {
server.tool(spec.name, spec.description, invoke);
}
}
return server;
}

View file

@ -0,0 +1,75 @@
/**
* Streamable HTTP transport handler.
*
* Pattern lifted from apps/api/src/mcp/server.ts (the Mana-internal MCP
* endpoint), but with per-request auth every session is created against
* a verified user, and sessions are scoped to that user for their lifetime.
*
* Lifecycle:
* POST /mcp (no session id) initialize, returns Mcp-Session-Id header
* POST /mcp (with session id) JSON-RPC message in
* GET /mcp (with session id) SSE stream out
* DELETE /mcp (with session id) close
*/
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
import { createMcpServerForUser } from './mcp-adapter.ts';
import type { VerifiedUser } from './auth.ts';
interface SessionEntry {
transport: WebStandardStreamableHTTPServerTransport;
userId: string;
}
const sessions = new Map<string, SessionEntry>();
export async function handleMcpRequest(req: Request, user: VerifiedUser): Promise<Response> {
const sessionId = req.headers.get('mcp-session-id');
// Existing session — must belong to the same user.
if (sessionId && sessions.has(sessionId)) {
const entry = sessions.get(sessionId)!;
if (entry.userId !== user.userId) {
return new Response(JSON.stringify({ error: 'Session belongs to a different user' }), {
status: 403,
headers: { 'content-type': 'application/json' },
});
}
return entry.transport.handleRequest(req);
}
// New session: only POST without session id is a valid initialization.
if (req.method === 'POST' && !sessionId) {
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (id) => {
sessions.set(id, { transport, userId: user.userId });
},
onsessionclosed: (id) => {
sessions.delete(id);
},
});
const server = createMcpServerForUser(user);
await server.connect(transport);
return transport.handleRequest(req);
}
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' },
});
}
/** Test-only — sessions accumulate across requests otherwise. */
export function __resetSessionsForTests(): void {
sessions.clear();
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"lib": ["ES2022"],
"types": ["bun"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}