OGuardAI
Integrations

Use OGuardAI in your assistant (MCP)

Connect OGuardAI as an MCP tool in ChatGPT, Claude, Cursor, Windsurf, and any MCP client

OGuardAI ships an MCP (Model Context Protocol) server, so any MCP client can call your OGuardAI runtime as a set of tools. It works with Claude Desktop, Claude Code, Cursor, Windsurf, ChatGPT, Claude for Work, and other clients that speak the protocol.

There are two ways to connect, and every client uses one of them:

  • stdio, for desktop and CLI clients you run locally (Claude Desktop, Claude Code, Cursor, Windsurf). The client launches the server as a child process. This works today with a copy-paste config, no build step.
  • Streamable HTTP, for web clients that add a remote connector by URL (ChatGPT on web and in the app, Claude for Work, Team, and Enterprise). An admin points the client at a POST /mcp endpoint and supplies a bearer token.

What it protects, and what it does not

Be precise about the reach, because it is the same on every client.

Through MCP, OGuardAI protects the data the assistant works over: tool results, retrieved RAG context, and connector data. That content passes through OGuardAI and reaches the model as {{type:id:cap}} tokens with safe metadata instead of raw values.

It does not protect the raw prompt you type. The provider's model receives your typed text before any tool runs, so a tool cannot reach back and scrub it. Scrubbing the typed prompt is a different surface: it needs the client (a browser extension) or the network (an egress gateway). Those are separate surfaces, not this integration.

So this integration changes what the model receives from your data. It does not make ChatGPT or Claude compliant on its own, and it does not protect what you type.

Install

npm install -g @oguardai/mcp-server
# or
pnpm add -g @oguardai/mcp-server

stdio clients: Claude Desktop, Claude Code, Cursor, Windsurf

These clients launch the server locally over stdio. Point the config at your running OGuardAI runtime with GUARDAI_URL, and authenticate with either an API key (GUARDAI_API_KEY) or a bearer token (GUARDAI_BEARER_TOKEN, or GUARDAI_BEARER_TOKEN_FILE for a rotating token). The two are mutually exclusive; set only one. GUARDAI_URL must be https, or http only to a loopback host, so http://localhost:3000 is fine for a local runtime.

Claude Desktop

Edit the config file for your OS:

  • macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: %APPDATA%\Claude\claude_desktop_config.json
  • Linux: ~/.config/claude/claude_desktop_config.json
{
  "mcpServers": {
    "guardai": {
      "command": "npx",
      "args": ["@oguardai/mcp-server"],
      "env": {
        "GUARDAI_URL": "https://guardai.internal.example",
        "GUARDAI_API_KEY": "your-api-key"
      }
    }
  }
}

npx fetches and runs the published server, so there is no build step. If you install it globally with npm install -g @oguardai/mcp-server, use "command": "guardai-mcp" and "args": [] instead.

Claude Code

Add the server from the CLI:

claude mcp add guardai \
  --env GUARDAI_URL=https://guardai.internal.example \
  --env GUARDAI_API_KEY=your-api-key \
  -- npx @oguardai/mcp-server

Or drop the same mcpServers block into .mcp.json at your project root so the whole team shares it.

Cursor and Windsurf

Both read an mcpServers block. Cursor uses .cursor/mcp.json (per project) or the global ~/.cursor/mcp.json; Windsurf uses ~/.codeium/windsurf/mcp_config.json. The block is the same as Claude Desktop's:

{
  "mcpServers": {
    "guardai": {
      "command": "npx",
      "args": ["@oguardai/mcp-server"],
      "env": {
        "GUARDAI_URL": "https://guardai.internal.example",
        "GUARDAI_API_KEY": "your-api-key"
      }
    }
  }
}

HTTP connector: ChatGPT and Claude for Work

Web clients do not launch a local process; they connect to a remote URL. Run the server with the Streamable HTTP transport and register that URL as a connector.

# Exactly one auth decision is required, or the transport refuses to start.
GUARDAI_MCP_HTTP_BEARER_TOKEN=$(openssl rand -base64 32) \
  guardai-mcp --transport http --host 0.0.0.0 --port 8090
# Env-selected transport, same bearer requirement:
# GUARDAI_MCP_TRANSPORT=http GUARDAI_MCP_HTTP_BEARER_TOKEN=... guardai-mcp
# Local loopback pilot only, opt out of auth explicitly instead of a bearer:
# GUARDAI_MCP_HTTP_ALLOW_UNAUTHENTICATED=true guardai-mcp --transport http

The endpoint is POST /mcp (default bind 127.0.0.1:8090), and GET /healthz is a health probe. The transport is stateless: it mints no session id, so it sits behind an ingress cleanly. Terminate TLS at that ingress and expose the connector over https.

The transport refuses to start without an explicit auth decision. Set exactly one of:

  • GUARDAI_MCP_HTTP_BEARER_TOKEN (or GUARDAI_MCP_HTTP_BEARER_TOKEN_FILE): the shared secret the client sends as an Authorization: Bearer header. This is the recommended mode for any reachable deployment.
  • GUARDAI_MCP_HTTP_ALLOW_UNAUTHENTICATED=true (the exact literal true): an explicit, unauthenticated pilot mode, logged as a warning at startup. Use it only for a local developer-mode trial.

For a browser client, also set GUARDAI_MCP_HTTP_ALLOWED_ORIGINS, a comma-separated allowlist. A present Origin outside the list is rejected with 403; requests with no Origin (server-side callers, curl) pass. GUARDAI_MCP_HTTP_ALLOWED_HOSTS pins the accepted Host when you bind to a non-loopback address behind an ingress.

ChatGPT (web and app)

In ChatGPT, open Settings, then Connectors, then Developer mode, add a connector, and enter your https://.../mcp URL. For the private pilot, choose bearer authentication and paste the GUARDAI_MCP_HTTP_BEARER_TOKEN value; choose "No authentication" only if you started the server with the unauthenticated opt-out. Publishing to the public ChatGPT connector directory additionally requires OAuth 2.1, which is future work. A private or organization connector needs only the URL and the token.

Claude for Work, Team, and Enterprise

An admin adds the connector under the organization's connector settings: enter the same https://.../mcp URL and the bearer token. Members of the organization then see the OGuardAI tools in their Claude client.

Give your assistant the habit: reference, don't paste

The tools only help if the assistant calls the right ones, and which tools a client sees depends on how you run the server. There are two postures and a template for each. Paste the matching block into the client's instruction surface: ChatGPT custom instructions or a custom GPT's system prompt, or Claude project instructions.

Two deployment postures

  • Default (model mode), an end-user connector. This is what you add to ChatGPT or Claude. It exposes guardai_rehydrate and the token-domain tools guardai_rag_context and guardai_rag_answer (plus guardai_capabilities and guardai_health). The raw-input tools guardai_transform and guardai_detect are intentionally hidden here, because a model-facing connector should not receive raw typed PII: the model already saw the prompt. Use the model-mode template.
  • Host mode, a trusted middleware deployment. An operator runs the server with GUARDAI_MCP_MODE=host and an allowlist such as GUARDAI_MCP_HOST_TOOLS=guardai_transform,guardai_detect, so the operator's own pipeline, not the end user's model, drives tokenization. Only in this posture does an assistant see a transform tool. Use the host-mode template.

Model-mode template (default end-user connector)

You have OGuardAI MCP tools available (guardai_rehydrate, guardai_rag_context, guardai_rag_answer). Always follow these rules:
1. Work only with the data you are given as {{type:id:cap}} tokens; treat every token as an opaque placeholder and never guess, expand, or reformat it.
2. Reference data by its token or handle; never paste raw sensitive values into your answers, and never invent a real value behind a token.
3. When producing the final answer, use guardai_rehydrate to confirm the tokens resolve; the real values are restored downstream, outside your context, for policy-allowed channels only.
4. This connector has no transform tool. If you are handed raw sensitive data, do not process it: say it must be tokenized upstream first.

Host-mode template (trusted middleware, requires host mode)

Use this only when you run the server in host mode with guardai_transform (and optionally guardai_detect) allowlisted, as described above. It tells the assistant to tokenize first:

You have OGuardAI MCP tools available (guardai_transform, guardai_rehydrate, guardai_detect). Always follow these rules:
1. Before reasoning over or sending onward any content that may contain personal or sensitive data (names, emails, IDs, IBANs, phone numbers, addresses, health or financial details), call guardai_transform first and work only with the returned tokens, never the raw values.
2. Reference data by its token or handle; never paste raw sensitive values back into your answers.
3. When producing the final answer, call guardai_rehydrate so only policy-allowed values are restored.
4. If a guardai tool is unavailable, say so and stop; do not fall back to handling raw sensitive data.

In both templates the key habit is reference, don't paste: point the tools at data by its token or handle, and never copy a raw value into the conversation. That keeps real values out of the model's context and out of the transcript.

On step 3 in both templates, note what guardai_rehydrate does over MCP: it reports which tokens resolve and which do not; it does not hand restored values back to the model. Your application performs the real restore for the end user outside the model loop, so raw values never re-enter the model's context.

Available Tools

Once configured, the AI client has access to these tools.

By default the server exposes only the data tools. The five admin/ops tools (guardai_diagnostics, guardai_metrics, guardai_validate_policies, guardai_revocation_count, guardai_reload_policies) are hidden from tools/list and rejected at dispatch unless GUARDAI_MCP_EXPOSE_ADMIN is enabled, and guardai_revoke is gated separately behind GUARDAI_MCP_EXPOSE_REVOKE, so an operator can enable the ops surface without also handing a model-facing client the ability to revoke entities. The five admin/ops tools are additionally admin-scoped on the OGuardAI server itself, so exposing them here still requires an admin API key upstream. guardai_revoke instead needs the revoke scope, plus global_revoke when the upstream key has no tenant binding (both of which admin implies).

The core tools most assistants use:

  • guardai_transform: replace sensitive values in text with {{type:id:cap}} tokens and return a session handle.
  • guardai_rehydrate: report which tokens resolve against a session; the host restores the real values outside the model loop.
  • guardai_detect: find entities in text without transforming, for auditing what sensitive data is present.

For retrieval-augmented generation there are matching tools: guardai_rag_ingest, guardai_rag_query, guardai_rag_context, guardai_rag_answer, and guardai_rag_delete. Each keeps token identity consistent across a corpus so cross-document references survive tokenization.

guardai_transform

Protect sensitive data in text by replacing PII with semantic tokens.

Parameters:

NameTypeRequiredDescription
textstringYesText that may contain sensitive data
policystringNoPolicy to apply (default: server default)
languagestringNoISO 639-1 language hint

Example interaction:

User: "Please draft a reply to this customer email:
  From: Erika Mustermann <erika.mustermann@example.com>
  Subject: Order complaint ORD-2026-4892"

Claude (inside the MCP tool loop):
  1. Calls guardai_transform with the email text
     -> Gets safe text with {{person:p_001:6a3e91f04d27}}, {{email:e_001:c85b2df1e963}}, {{order:o_001:41f7a9c0b3d5}}
  2. Drafts reply using the semantic tokens (no raw PII seen by the model)

Host application (AFTER the agent/model finishes):
  3. Calls guardai_rehydrate with the model's tokenized output
     and the session_state from step 1
     -> Restores real values server-side for delivery to the end user

NOTE: guardai_rehydrate is NOT called by the model as an MCP tool.
It is called by the host application code after the model loop ends.
The model never sees restored PII.

guardai_rehydrate

Resolve OGuardAI semantic tokens server-side and report resolution metadata (tokens_resolved, tokens_unresolved). The restored text is deliberately NOT returned to the model context; the host application calls /v1/rehydrate directly to obtain restored_text.

Important: This tool is intended to be called by the host application OUTSIDE the model/MCP tool loop, not by the model itself. The model calls guardai_transform during reasoning, but rehydration happens after the agent finishes, in the host code. This ensures the model never sees restored PII.

Parameters:

NameTypeRequiredDescription
textstringYesText containing {{type:id:cap}} tokens
session_statestringYesEncrypted session state blob from a prior transform call
output_channelstringNoOutput channel (customer_email, internal_summary, etc.)

guardai_detect

Detect entities in text without transforming. Useful for auditing and understanding what PII exists in content.

Parameters:

NameTypeRequiredDescription
textstringYesText to analyze
languagestringNoISO 639-1 language hint
policystringNoOptional policy name, so the policy's custom_patterns and NER labels are applied and detect-only sees the same domain entities a transform would

Returned spans are start and end as UTF-8 byte offsets into the submitted text, not character or UTF-16 code-unit offsets. Convert the text to bytes before slicing by them.

guardai_health

Check the OGuardAI server health status.

guardai_evaluate_policy

Evaluate what a policy would do to detected entities without actually transforming.

Parameters:

NameTypeRequiredDescription
textstringYesText to evaluate
policystringNoPolicy name to evaluate (default: server default)
caller_rolestringNoPreview role-conditional rules (honored under dev auth; a real decision binds the role to auth)
caller_purposestringNoPreview purpose-conditional rules
destinationstringNoPreview destination-conditional rules (built-in or a custom [a-z0-9_-]+ class)
output_channelstringNoPreview the channel-resolved restore mode each decision would get (default user_output)

guardai_capabilities

List what the runtime supports: entity types, languages, detectors, restore modes, and limits (for example the max batch size). Returns no PII. Use it to discover what can be detected before transforming. No parameters.

guardai_diagnostics

Get the runtime configuration and health snapshot: detector mode, loaded policies, auth mode, session backend, and feature flags. Returns no PII. Admin/ops scoped server-side, so it fails for a non-admin key. Exposed only when GUARDAI_MCP_EXPOSE_ADMIN is enabled. No parameters.

guardai_validate_policies

Validate a server-side policy directory without loading it: reports whether every policy parses and what would change versus the active set. Returns no PII. Admin/ops scoped server-side. Exposed only when GUARDAI_MCP_EXPOSE_ADMIN is enabled.

Parameters:

NameTypeRequiredDescription
directorystringYesServer-side path to the policy directory to validate

guardai_reload_policies

Hot-reload the server policy directory from disk and report what changed versus the active set: the reloaded flag, policies_loaded, policy_names, and the changes. Returns no PII. Admin/ops scoped server-side. Exposed only when GUARDAI_MCP_EXPOSE_ADMIN is enabled. No parameters.

guardai_batch_transform

Transform many texts in one call. Each result carries tokenized safe_text and its own session_state. Only tokenized output is returned, never raw PII.

Parameters:

NameTypeRequiredDescription
itemsarrayYesItems to transform. Each item takes text (required) plus optional per-item policy and language
policystringNoGlobal policy applied to all items (overridden by a per-item policy)

guardai_batch_detect

Detect PII entities across many texts at once. Returns entity types and spans only, never raw values. Pass a policy (global or per item) to apply that policy's custom detectors and NER labels.

Parameters:

NameTypeRequiredDescription
itemsarrayYesItems to scan. Each item takes text (required) plus optional policy and language
policystringNoGlobal policy applied to all items

guardai_revoke

Right to erasure: revoke a specific PII value for the tenant, so any later rehydrate resolves its token to [DELETED]. Returns counts and status only; the raw value is never echoed back. Exposed only when GUARDAI_MCP_EXPOSE_REVOKE is enabled.

Parameters:

NameTypeRequiredDescription
entity_typestringYesEntity type of the value to revoke (for example email)
valuestringYesThe raw value to revoke for this tenant

guardai_revocation_count

Return the number of revoked entity values in the caller's own scope, an erasure-operations metric. Aggregate count only, no PII. Requires the admin scope server-side, so it fails for a non-admin key; a tenant-scoped admin key returns its own tenant's count, never a cross-tenant total. Exposed only when GUARDAI_MCP_EXPOSE_ADMIN is enabled. No parameters.

guardai_metrics

Return the server Prometheus metrics: operation counts, per-type entity counts, and latency series. Admin/ops scoped server-side. Returns no PII. Exposed only when GUARDAI_MCP_EXPOSE_ADMIN is enabled. No parameters.

guardai_rag_ingest

Ingest a document for RAG: split it into chunks and replace PII with tokens. Returns tokenized safe chunks (store these in the vector DB) plus a session_state. Set corpus_id for stable cross-document token identity.

Parameters:

NameTypeRequiredDescription
textstringYesDocument text to ingest
chunking_strategystringNoparagraph (default), sentence, fixed, or sliding_window
chunk_sizenumberNoMax chunk size in characters
chunk_overlapnumberNoOverlap for sliding-window chunking
policystringNoPolicy name
languagestringNoISO 639-1 language hint
corpus_idstringNoCorpus id for deterministic cross-document token identity (use the same value at query and context)

guardai_rag_query

Transform a user query for RAG retrieval: returns the tokenized safe_query (use it for vector search) and a session_state. Pass the same corpus_id used at ingest so query tokens align with the ingested document tokens.

Parameters:

NameTypeRequiredDescription
querystringYesUser query
policystringNoPolicy name
languagestringNoISO 639-1 language hint
corpus_idstringNoCorpus id (must match the ingest corpus)

guardai_rag_context

Assemble retrieved chunks into a safe LLM context, merging each document's tokens. Returns tokenized safe_chunks and a session_state. Pass document_sessions (the chunk_session_state from ingest, parallel to chunks) so document-only entities resolve in the answer.

Parameters:

NameTypeRequiredDescription
chunksarrayYesRetrieved chunk texts
session_statestringYesSession state from the query call
policystringNoPolicy name
languagestringNoISO 639-1 language hint
access_levelstringNoMaximum access level for chunk filtering
chunk_classificationsarrayNoPer-chunk classification labels, parallel to chunks
document_sessionsarrayNoPer-chunk chunk_session_state from ingest, parallel to chunks
corpus_idstringNoCorpus id (must match the query session)

guardai_rag_answer

Validate that an LLM answer's tokens resolve against the context session. Returns metadata only (token counts). The restored answer with real PII is NOT returned; the host application must call /v1/rag/answer directly to retrieve it, the same host-side rehydrate pattern as guardai_rehydrate.

Parameters:

NameTypeRequiredDescription
answerstringYesLLM answer containing {{type:id:cap}} tokens
session_statestringYesSession state from the context call
output_channelstringNoOutput channel for restore policy (default user_output)

guardai_rag_delete

Forget every value in the given session: each is revoked for the tenant so later rehydrates resolve it to [DELETED]. To erase one document, pass that document's ingest session_state. Returns counts only. This backs GDPR erasure.

Parameters:

NameTypeRequiredDescription
session_statestringYesSession state whose values to forget

guardai_session_status

Report a session's lifecycle state (active or stateless) and backend. Identify the session by exactly one of session_id (memory or redis backend) or session_state (sealed backend). A missing or cross-tenant session returns a non-disclosing not-found. Returns no PII.

Parameters:

NameTypeRequiredDescription
session_idstringNoServer-side session id (memory or redis backend)
session_statestringNoSealed client-held session_state blob (sealed backend)

guardai_session_delete

Invalidate a server-held session so it can no longer be rehydrated. Identify it by exactly one of session_id or session_state. The sealed backend holds no server-side state, so it reports deleted: false. This does NOT revoke the mapped entity values; use guardai_revoke or guardai_rag_delete for right-to-erasure. Returns no PII.

Parameters:

NameTypeRequiredDescription
session_idstringNoServer-side session id (memory or redis backend)
session_statestringNoSealed client-held session_state blob (sealed backend)

Usage Examples

Protecting Customer Data in Conversations

User: "Summarize this customer complaint. Make sure to protect
their personal data:

  My name is Max Mustermann, email max.mustermann@example.de.
  Order Q-2026-0847 was delivered damaged."

Claude will (inside the MCP tool loop):
1. Call guardai_transform to tokenize the PII
2. Process the safe text to generate a summary using tokens

Then the host application (AFTER the model loop ends):
3. Call guardai_rehydrate with the model's tokenized summary
   and the session_state, restoring real values for the end user.
   The model never calls guardai_rehydrate and never sees restored PII

Auditing Text for PII

User: "Check this document for any PII before I send it to the
external analytics team."

Claude will:
1. Call guardai_detect to find all entities
2. Report what was found (types, confidence scores)
3. Suggest which policy to apply

Environment Variables

Runtime and authentication (both transports)

VariableDefaultDescription
GUARDAI_URLhttp://localhost:3000OGuardAI runtime URL. Must be https, or http only to a loopback host.
GUARDAI_API_KEY(none)API key, sent as X-API-Key. Mutually exclusive with bearer auth.
GUARDAI_BEARER_TOKEN(none)Static JWT/OIDC token, sent as Authorization: Bearer. Mutually exclusive with the API key.
GUARDAI_BEARER_TOKEN_FILE(none)Path to a token file, re-read per request so an external rotator can refresh it. Takes precedence over GUARDAI_BEARER_TOKEN.

Tool exposure

VariableDefaultDescription
GUARDAI_MCP_MODEmodelmodel hides the raw-input tools (transform, detect, and the batch/query variants) from the client; host exposes the ones you allowlist so the assistant can call them.
GUARDAI_MCP_HOST_TOOLS(none)Comma-separated raw-input tools to expose in host mode, for example guardai_transform,guardai_detect. Empty exposes none; there is no wildcard.
GUARDAI_MCP_EXPOSE_ADMINfalseExpose the five admin/ops tools (diagnostics, metrics, validate/reload policies, revocation count).
GUARDAI_MCP_EXPOSE_REVOKEfalseExpose the irreversible-mutation tools (guardai_revoke and the delete tools).

HTTP transport (Streamable HTTP connector)

VariableDefaultDescription
GUARDAI_MCP_TRANSPORTstdioSet to http (or pass --transport http) to serve the Streamable HTTP connector.
GUARDAI_MCP_HTTP_HOST127.0.0.1Bind address. Containers behind an ingress bind 0.0.0.0.
GUARDAI_MCP_HTTP_PORT8090Bind port.
GUARDAI_MCP_HTTP_PATH/mcpThe endpoint path that accepts POST.
GUARDAI_MCP_HTTP_BEARER_TOKEN(none)Shared bearer secret the connector must present.
GUARDAI_MCP_HTTP_BEARER_TOKEN_FILE(none)Path to a file holding the bearer secret. Takes precedence over the inline token.
GUARDAI_MCP_HTTP_ALLOW_UNAUTHENTICATED(none)The exact literal true runs an explicit unauthenticated pilot. Exactly one of this or a bearer secret is required, or the transport refuses to start.
GUARDAI_MCP_HTTP_ALLOWED_ORIGINS(none)Comma-separated Origin allowlist for browser clients. With none set, only loopback origins pass.
GUARDAI_MCP_HTTP_ALLOWED_HOSTS(none)Comma-separated Host allowlist when bound to a non-loopback address.

The exposure flags (GUARDAI_MCP_EXPOSE_ADMIN, GUARDAI_MCP_EXPOSE_REVOKE) accept 1, true, yes, or on (case-insensitive). GUARDAI_MCP_HTTP_ALLOW_UNAUTHENTICATED accepts only the exact literal true.

Runnable example: German insurance claims desk

Scenario: the claims desk of Musterversicherung AG, a fictional German motor and property insurer, runs an AI client (Claude Desktop, Cursor, Claude Code) wired to the OGuardAI MCP server. An adjuster drops an inbound claimant email into the assistant; the model triages it and drafts a formal German acknowledgment on semantic tokens only, and the trusted backend restores the real values per channel afterward. German data-protection rules forbid the claimant's name, IBAN, phone, or address ever reaching the LLM, and this example is the runtime seam that enforces that.

The example ships a small MCP client that spawns the real @oguardai/mcp-server over stdio exactly the way an AI client does (initialize, tools/list, tools/call) and drives the full protection cycle end to end. Source: examples/mcp/. Jump to Run the guided trace.

Two planes: model and host

MODEL PLANE  (through the MCP server, JSON-RPC over stdio)
  detect ─▶ evaluate_policy ─▶ transform ─▶ [LLM draft on tokens] ─▶ rehydrate
  the model sees only tokens, entity types, and policy decisions; rehydrate
  confirms how many tokens resolved and returns NO restored text

HOST PLANE  (trusted claims backend, OGuardAI REST directly)
  materializes the real letter, the internal note, the reinsurer extract, and
  the audit line from the one tokenized draft, each on its own output channel

The split is the point. The MCP guardai_rehydrate tool deliberately withholds restored text so the model can never pull PII back through a tool call. A real deployment still needs the finished letter, so the trusted host produces it out of band by calling /v1/rehydrate directly.

Prerequisites

  • Node.js 22+.
  • A running OGuardAI server on http://localhost:3000.
  • Needs-NER: the Python NER detector sidecar must be active so person, address, and company are detected. Check with curl -H "X-API-Key: <key>" localhost:3000/v1/health: the detector component should report builtin_and_ner. The insurance-de policy declares person detection as required, so a runtime without the NER sidecar rejects the transform (fail closed, HTTP 503) rather than let an un-tokenized claimant name reach the model. The built-in regex still covers the structured PII (IBAN, email, phone, policy number, claim id, license plate), but this example needs the full stack with NER.
  • The insurance-de policy, shipped in policies/insurance-de/. It tags the claimant as the policyholder, restores the person in a gendered German form, masks the payout IBAN, redacts regulatory identifiers, and detects carrier-defined policy_number and claim_id values with no code change.

Start the full stack (server plus the NER detector sidecar) from the repo root:

# The bundled stack requires these four secrets, and its client key ships least-privilege (channel
# user_output only), so widen it to the insurance-de policy and the channels this example uses.
export GUARDAI_SESSION_SECRET=$(openssl rand -base64 32)
export GUARDAI_API_KEY_1=$(openssl rand -hex 32)
export GUARDAI_DETECTOR_API_KEY=$(openssl rand -hex 32)
export GUARDAI_REDIS_PASSWORD=$(openssl rand -hex 32)
export GUARDAI_API_KEY_1_ALLOWED_POLICIES=default,default-ner-required,german-support,insurance-de
export GUARDAI_API_KEY_1_ALLOWED_OUTPUT_CHANNELS=user_output,customer_email,internal_summary,reinsurer_export,log_safe
docker compose -f deploy/docker/docker-compose.yml up -d

Build the MCP server

The example spawns the compiled server, so build it once from the repo root:

pnpm install
pnpm --filter @oguardai/mcp-server build   # produces apps/mcp-server/dist/index.js

Run the guided trace

pnpm demo drives the fixture claim in fixtures/helvetia-schaden-erstmeldung.de.txt through both planes and prints, in order: the raw email inside the trust boundary, the detected entity types, the policy disposition, the tokenized text the LLM sees, the tokenized draft, the rehydrate confirmation, the same draft restored into four channels, and the three fail-closed properties.

cd examples/mcp
pnpm install
export GUARDAI_API_KEY="$GUARDAI_API_KEY_1"   # the bundled stack runs api_key auth
pnpm demo

The demo spawns the MCP server with GUARDAI_MCP_MODE=host, set in src/scenario.ts. This process is the trusted host plane: it drives guardai_detect, guardai_evaluate_policy, and guardai_transform as middleware before the model turn. Those tools take raw, untokenized input, so the server's default model posture hides them from tools/list and rejects them at dispatch. Leave the default in place for anything a model drives directly.

Expected output (token ids and hashes differ per run because they are random):

MCP server:  guardai-mcp v0.1.0 (protocol 2024-11-05)
Tools:       14 verfuegbar (host mode; admin and revoke tools stay off)
OGuardAI:    http://localhost:3000   Detector: builtin_and_ner (full entity detection)
Policy:      insurance-de   Sprache: de   LLM: offline

MODELL-EBENE 1/4 - guardai_detect (Triage)
Erkannte Entitaeten (9): address=2, claim_id=1, email=1, iban=1, license_plate=1, person=1, phone=1, policy_number=1

MODELL-EBENE 2/4 - guardai_evaluate_policy (Disposition)
  person   -> tokenize, restore=abstract
  iban     -> tokenize, restore=masked
  email    -> tokenize, restore=formatted
  ...

MODELL-EBENE 3/4 - guardai_transform (Schutz)
Was das LLM sieht (safe_text, nur Tokens):
  Guten Tag, mein Name ist {{person:p_001:...}}, ich bin die Versicherungsnehmerin zu
  Versicherungsschein {{custom:policy_number:x_001:...}}. ... amtliches Kennzeichen
  {{custom:license_plate:x_002:...}} ... Schadennummer {{custom:claim_id:x_003:...}}. ...
  Konto {{iban:ib_001:...}}. ... {{email:e_001:...}} oder telefonisch {{phone:ph_001:...}} ...

MODELL-EBENE 4/4 - LLM-Entwurf + guardai_rehydrate (Bestaetigung)
rehydrate ack -> resolved=5 unresolved=[]
Der wiederhergestellte Text (mit echten Daten) wird dem Modell NICHT zurueckgegeben.

HOST-EBENE - Materialisierung ueber OGuardAI REST (ausserhalb des Modells)
[customer_email]   -> ... Schadenmeldung SCH-2026-558310 zu Versicherungsschein VN-4471902 ...
                       Konto D*** **** **** **** **** *0 ... an k***********************e.
[reinsurer_export] -> ... Schadenmeldung S*************0 zu Versicherungsschein V********2 ...
                       Konto [REDACTED] ... an [REDACTED].
[log_safe]         -> Guten Tag [REDACTED], ... Schadenmeldung [REDACTED] ... Konto [REDACTED] ...

FAIL-CLOSED (ueber die MCP-Tools beobachtet)
1) Manipuliertes session_state -> abgelehnt: ToolError (isError)
2) Erfundener Token -> resolved=1 unresolved=[{{person:p_999:...}}]
3) Fehlendes Argument 'text' -> ProtocolError (-32602)

Read the channels together. On the policyholder letter the claimant may see their own policy and claim numbers in full, but the IBAN and email are masked. The reinsurer extract coarsens the identifiers and drops the bank data entirely. The audit line redacts everything identifying. Under the dev key the internal note equals the customer view: the full name and the partial IBAN that an adjuster is entitled to unlock only when the caller authenticates with the claims_adjuster role, because insurance-de binds that override to auth rather than to a request field.

The example's package scripts (examples/mcp/package.json):

{
  "name": "guardai-mcp-example",
  "version": "1.0.0",
  "description": "MCP client that drives the OGuardAI MCP server for a German insurance claims desk",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "typecheck": "tsc --noEmit",
    "test": "tsx --test src/scenario.test.ts",
    "demo": "tsx src/demo.ts"
  },
  "devDependencies": {
    "@types/node": "^20",
    "tsx": "^4.0",
    "typescript": "^5.5"
  }
}

pnpm test runs the example's boundary assertions (tsx --test src/scenario.test.ts); pnpm build type-checks and compiles with the shipped tsconfig.json (examples/mcp/tsconfig.json):

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Use a real or offline LLM

The model only ever receives tokenized text. When OPENAI_API_KEY is set the adapter in src/llm.ts calls OpenAI Chat Completions over fetch. When it is unset it falls back to a deterministic offline draft, so the example runs end to end against OGuardAI alone with no provider account.

export OPENAI_API_KEY=sk-...        # optional; enables the real provider path
export OPENAI_MODEL=gpt-4o-mini     # optional; default gpt-4o-mini
pnpm demo

The fixture: fixtures/helvetia-schaden-erstmeldung.de.txt

A fictional German first-notice-of-loss email; all PII is invented.

Guten Tag, mein Name ist Frau Dr. Erika Mustermann, ich bin die Versicherungsnehmerin zu Versicherungsschein VN-4471902. Am 3. Juli hatte ich mit meinem Fahrzeug, amtliches Kennzeichen M-KV 2847, einen Auffahrunfall auf der A9. Schadennummer SCH-2026-558310. Der Unfallgegner ist mir bekannt, verletzt wurde niemand. Bitte ueberweisen Sie die Erstattung auf mein Konto DE97 9900 0000 0123 4567 89. Sie erreichen mich unter erika.mustermann@example.de oder telefonisch +49 151 23456789. Meine Anschrift ist Musterstrasse 14, 80331 Muenchen. Ich bitte um eine kurze Eingangsbestaetigung und die naechsten Schritte zur Regulierung.

The MCP client: src/mcp-client.ts

A minimal MCP client over stdio, the same wire an AI client uses to talk to @oguardai/mcp-server. It spawns the server as a subprocess, speaks JSON-RPC 2.0 line by line, and correlates responses to requests by id. Tool handlers are async, so the client keeps the pipe open and reads replies as they arrive; closing stdin early would exit the server before an in-flight tool call flushes its result. ProtocolError surfaces a JSON-RPC error such as a missing argument; ToolError surfaces a tool that ran but failed (an isError result).

/**
 * Minimal MCP client over stdio, the same wire an AI client (Claude Desktop,
 * Cursor, Claude Code) uses to talk to `@oguardai/mcp-server`.
 *
 * It spawns the server as a subprocess, speaks JSON-RPC 2.0 line by line, and
 * correlates responses to requests by id. This is deliberately hand-rolled with
 * Node builtins so the example has no runtime dependency and mirrors exactly how
 * a host process drives the server: initialize, tools/list, tools/call.
 *
 * One subtlety that a naive `printf | server` misses: tool handlers are async
 * (they await an HTTP call to OGuardAI), so the caller must keep the pipe open
 * and read responses as they arrive. Closing stdin early exits the server before
 * an in-flight tool call flushes its result.
 */

import { spawn, type ChildProcess } from "node:child_process";
import { createInterface, type Interface } from "node:readline";

export interface ServerSpec {
  command: string;
  args: string[];
  env: NodeJS.ProcessEnv;
}

interface JsonRpcResponse {
  jsonrpc: "2.0";
  id: number | string | null;
  result?: unknown;
  error?: { code: number; message: string; data?: unknown };
}

interface ToolResult {
  content?: Array<{ type: string; text?: string }>;
  isError?: boolean;
}

/** A JSON-RPC protocol error (bad method, missing argument, malformed envelope). */
export class ProtocolError extends Error {
  constructor(
    message: string,
    readonly code: number,
  ) {
    super(message);
    this.name = "ProtocolError";
  }
}

/** A tool that ran but failed (isError result). The server surfaces this so the
 *  model sees the failure text instead of a silent success. */
export class ToolError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ToolError";
  }
}

export class McpClient {
  private child: ChildProcess | null = null;
  private reader: Interface | null = null;
  private nextId = 0;
  private readonly pending = new Map<
    number,
    { resolve: (r: JsonRpcResponse) => void; reject: (e: Error) => void }
  >();
  private exited = false;

  constructor(private readonly spec: ServerSpec) {}

  start(): void {
    const child = spawn(this.spec.command, this.spec.args, {
      env: this.spec.env,
      // stderr inherits only when debugging; the server logs a startup line there.
      stdio: ["pipe", "pipe", process.env.GUARDAI_MCP_DEBUG ? "inherit" : "ignore"],
    });
    this.child = child;

    if (!child.stdout || !child.stdin) {
      throw new Error("MCP server subprocess has no stdio pipes");
    }

    this.reader = createInterface({ input: child.stdout });
    this.reader.on("line", (line) => this.onLine(line));

    // If the server dies, fail every outstanding request rather than hang.
    child.on("exit", (code) => {
      this.exited = true;
      const err = new Error(`MCP server exited (code ${code ?? "unknown"})`);
      for (const { reject } of this.pending.values()) reject(err);
      this.pending.clear();
    });
  }

  private onLine(line: string): void {
    const trimmed = line.trim();
    if (!trimmed) return;
    let message: JsonRpcResponse;
    try {
      message = JSON.parse(trimmed) as JsonRpcResponse;
    } catch {
      return; // ignore non-JSON noise on stdout
    }
    if (typeof message.id !== "number") return; // no correlation handle
    const waiter = this.pending.get(message.id);
    if (!waiter) return;
    this.pending.delete(message.id);
    waiter.resolve(message);
  }

  private request(
    method: string,
    params?: Record<string, unknown>,
    timeoutMs = 15_000,
  ): Promise<JsonRpcResponse> {
    if (this.exited || !this.child?.stdin) {
      return Promise.reject(new Error("MCP server is not running"));
    }
    const id = ++this.nextId;
    const stdin = this.child.stdin;
    return new Promise<JsonRpcResponse>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pending.delete(id);
        reject(new Error(`MCP request '${method}' timed out after ${timeoutMs}ms`));
      }, timeoutMs);
      this.pending.set(id, {
        resolve: (r) => {
          clearTimeout(timer);
          resolve(r);
        },
        reject: (e) => {
          clearTimeout(timer);
          reject(e);
        },
      });
      stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
    });
  }

  async initialize(): Promise<{ name: string; version: string; protocolVersion: string }> {
    const res = await this.request("initialize", {});
    if (res.error) throw new ProtocolError(res.error.message, res.error.code);
    const r = res.result as {
      serverInfo: { name: string; version: string };
      protocolVersion: string;
    };
    return { ...r.serverInfo, protocolVersion: r.protocolVersion };
  }

  async listTools(): Promise<string[]> {
    const res = await this.request("tools/list");
    if (res.error) throw new ProtocolError(res.error.message, res.error.code);
    const r = res.result as { tools: Array<{ name: string }> };
    return r.tools.map((t) => t.name);
  }

  /** Calls a tool and returns the raw text payload. Throws ProtocolError for a
   *  JSON-RPC error (e.g. a missing argument) and ToolError for an isError result
   *  (a tool that ran but failed, e.g. a rejected session blob). */
  async callToolRaw(name: string, args: Record<string, unknown>): Promise<string> {
    const res = await this.request("tools/call", { name, arguments: args });
    if (res.error) throw new ProtocolError(res.error.message, res.error.code);
    const result = res.result as ToolResult;
    const text = result.content?.find((c) => c.type === "text")?.text ?? "";
    if (result.isError) throw new ToolError(text || "tool reported an error");
    return text;
  }

  /** Convenience wrapper for tools that return a JSON document as text. */
  async callTool<T>(name: string, args: Record<string, unknown>): Promise<T> {
    return JSON.parse(await this.callToolRaw(name, args)) as T;
  }

  async stop(): Promise<void> {
    this.reader?.close();
    if (this.child && !this.exited) {
      this.child.stdin?.end();
    }
  }
}

The scenario: src/scenario.ts

Company config, the four output channels, OGuardAI URL and key resolution, and the fixture loader. resolveServerSpec() pins GUARDAI_MCP_MODE: "host" on the spawned server so the host-plane raw-input tools are advertised; an ambient GUARDAI_MCP_MODE=model would otherwise break the demo silently.

/**
 * Scenario: Musterversicherung AG (fictional German motor/property
 * insurer). Its claims desk runs an AI client (Claude Desktop, Cursor, Claude
 * Code) wired to the OGuardAI MCP server. When an adjuster drops an inbound
 * claimant email into the assistant, the model must triage and draft an
 * acknowledgment without a single real name, IBAN, or email entering its context.
 *
 * Everything here is fictional; all PII in the fixture is invented.
 */

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { ServerSpec } from "./mcp-client.js";

export const COMPANY = {
  name: "Musterversicherung AG",
  assistant: "Helva",
  // A shipped policy (policies/insurance-de/): formal German person restore,
  // masked payout IBAN, redacted regulatory IDs, custom policy/claim identifiers,
  // and per-channel tightening for the policyholder letter, reinsurer, and audit log.
  policy: "insurance-de",
  language: "de",
} as const;

/**
 * The four consumers of one restored draft, each an OGuardAI output channel with
 * a different visibility level defined by the insurance-de policy:
 *  - customer  -> the policyholder's letter (own case ids, IBAN masked)
 *  - adjuster  -> the internal case note
 *  - partner   -> a reinsurer extract (identifiers coarsened, bank data dropped)
 *  - audit     -> the SIEM line (nothing identifying)
 */
export const CHANNELS = {
  customer: "customer_email",
  adjuster: "internal_summary",
  partner: "reinsurer_export",
  audit: "log_safe",
} as const;

export const GUARDAI_URL =
  process.env.GUARDAI_URL ?? process.env.GUARDAI_BASE_URL ?? "http://localhost:3000";

export const GUARDAI_API_KEY = process.env.GUARDAI_API_KEY ?? "";

/**
 * Where to find the MCP server. By default the example spawns the built server
 * exactly as an AI client config would (`node apps/mcp-server/dist/index.js`),
 * resolved relative to this file so it works from any cwd. Override the command
 * or entrypoint to point at a source run (tsx) or an installed binary.
 */
export function resolveServerSpec(): ServerSpec {
  const defaultEntry = fileURLToPath(
    new URL("../../../apps/mcp-server/dist/index.js", import.meta.url),
  );
  const command = process.env.GUARDAI_MCP_COMMAND ?? "node";
  const entry = process.env.GUARDAI_MCP_SERVER ?? defaultEntry;
  return {
    command,
    args: [entry],
    // The server reads the OGuardAI URL and key from its own environment, so the
    // real PII stays server-side and never crosses this host process.
    env: {
      ...process.env,
      GUARDAI_URL,
      ...(GUARDAI_API_KEY ? { GUARDAI_API_KEY } : {}),
      // This process IS the trusted host: it drives guardai_detect / guardai_transform as
      // middleware BEFORE the model turn (see host.ts). Those raw-input tools are hidden and
      // rejected in the server's default `model` posture, so host mode is pinned here rather
      // than inherited: an ambient GUARDAI_MCP_MODE=model would break the demo silently.
      GUARDAI_MCP_MODE: "host",
    },
  };
}

export function loadClaimEmail(): string {
  const path = fileURLToPath(
    new URL("../fixtures/helvetia-schaden-erstmeldung.de.txt", import.meta.url),
  );
  return readFileSync(path, "utf8").trim();
}

The LLM adapter: src/llm.ts

The model receives only safe_text with every sensitive value already replaced by a token. With OPENAI_API_KEY set it calls OpenAI Chat Completions over fetch; unset, it composes a deterministic offline draft that reuses the exact tokens. The system prompt hands the model the token contract: echo every placeholder verbatim, and never write a salutation before a person token because the restore injects the correct gendered form itself.

/**
 * LLM adapter. Through the MCP `guardai_transform` tool the model receives only
 * `safe_text`: the message with every sensitive value already replaced by a
 * `{{type:id:hash}}` token. It never sees a real name, IBAN, or email. The
 * adapter drafts a formal German acknowledgment that reuses those exact tokens.
 *
 * When OPENAI_API_KEY is set it calls OpenAI Chat Completions over fetch, reading
 * the key from the environment exactly as a real integration would. When it is
 * unset it falls back to a deterministic offline draft so the example runs end to
 * end against OGuardAI alone with no provider account.
 */

import { COMPANY } from "./scenario.js";

const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
const OPENAI_MODEL = process.env.OPENAI_MODEL ?? "gpt-4o-mini";

export function llmMode(): "openai" | "offline" {
  return process.env.OPENAI_API_KEY ? "openai" : "offline";
}

/** First token of a given type inside the safe text, or undefined. `person`,
 *  `iban`, `email`, `phone`; custom identifiers use the `custom:<subtype>` form. */
function tokenOf(safeText: string, type: string): string | undefined {
  const escaped = type.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  return safeText.match(new RegExp(`\\{\\{${escaped}:[^}]+\\}\\}`))?.[0];
}

/**
 * The token contract is the load-bearing instruction: echo every placeholder
 * verbatim and never prepend a salutation to a person token, because OGuardAI's
 * restore injects the correct gendered form itself.
 */
function systemPrompt(): string {
  return [
    `Du bist ${COMPANY.assistant}, Schadenservice-Assistentin von ${COMPANY.name}.`,
    "Antworte auf Deutsch in der formellen Sie-Form: knappe Eingangsbestaetigung einer Kfz-Schadenmeldung.",
    "",
    "Die Nachricht enthaelt Platzhalter der Form {{typ:id:hash}} fuer echte Daten,",
    "die du nicht sehen darfst. Regeln:",
    "1. Uebernimm jeden Platzhalter Zeichen fuer Zeichen unveraendert. Erfinde,",
    "   vervollstaendige oder formatiere niemals einen Platzhalter und schreibe",
    "   niemals selbst einen Namen, eine Nummer, IBAN oder E-Mail.",
    "2. Schreibe KEIN 'Herr', 'Frau' oder 'Sehr geehrte' vor einen person-Platzhalter.",
    "   Die Laufzeit setzt die korrekte Anrede selbst ein. Eroeffne mit",
    "   'Guten Tag {{person:...}},'.",
    "3. Nenne Schaden- und Versicherungsscheinnummer, bestaetige den Eingang und",
    "   verweise auf die Pruefung. Erfinde keine Fristen oder Betraege.",
  ].join("\n");
}

export async function draftReply(safeText: string): Promise<string> {
  return llmMode() === "openai" ? draftWithOpenAI(safeText) : draftOffline(safeText);
}

async function draftWithOpenAI(safeText: string): Promise<string> {
  const res = await fetch(`${OPENAI_BASE_URL}/chat/completions`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: OPENAI_MODEL,
      temperature: 0.2,
      messages: [
        { role: "system", content: systemPrompt() },
        { role: "user", content: safeText },
      ],
    }),
  });
  if (!res.ok) {
    throw new Error(`OpenAI request failed: ${res.status} ${res.statusText}`);
  }
  const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
  const content = data.choices?.[0]?.message?.content?.trim();
  if (!content) throw new Error("OpenAI returned an empty completion");
  return content;
}

/**
 * Deterministic stand-in for the model. Composes a formal German acknowledgment
 * reusing the exact tokens from the transformed message, so the example produces
 * the same protection trace with or without a provider key.
 */
function draftOffline(safeText: string): string {
  const person = tokenOf(safeText, "person");
  const claim = tokenOf(safeText, "custom:claim_id");
  const policy = tokenOf(safeText, "custom:policy_number");
  const iban = tokenOf(safeText, "iban");
  const email = tokenOf(safeText, "email");

  const lines: string[] = [];
  lines.push(person ? `Guten Tag ${person},` : "Guten Tag,");
  lines.push("");
  lines.push(
    claim && policy
      ? `vielen Dank fuer Ihre Schadenmeldung ${claim} zu Versicherungsschein ${policy}. Wir haben Ihre Erstmeldung aufgenommen und pruefen den Vorgang umgehend.`
      : "vielen Dank fuer Ihre Schadenmeldung. Wir haben Ihre Erstmeldung aufgenommen und pruefen den Vorgang umgehend.",
  );
  if (iban) {
    lines.push(
      `Eine etwaige Erstattung veranlassen wir nach Abschluss der Pruefung auf das angegebene Konto ${iban}.`,
    );
  }
  if (email) {
    lines.push(`Eine schriftliche Eingangsbestaetigung senden wir an ${email}.`);
  }
  lines.push("");
  lines.push("Mit freundlichen Gruessen");
  lines.push(`${COMPANY.assistant}, Schadenservice ${COMPANY.name}`);
  return lines.join("\n");
}

The trusted host plane: src/host.ts

Because guardai_rehydrate over MCP withholds restored text, the finished letter and the audit line are produced here by calling /v1/rehydrate directly, outside the model's tool surface. Different channels yield different visibility from the same tokenized draft, and restore_overrides can only tighten a channel, never loosen it. residualTokens guards a customer letter against a dangling token.

/**
 * The trusted host plane.
 *
 * The MCP `guardai_rehydrate` tool deliberately does NOT return restored text: it
 * confirms how many tokens resolved and keeps the real values out of the model's
 * context. So the model can drive the whole workflow safely, but it can never pull
 * PII back through a tool call.
 *
 * A real deployment still needs the finished letter and the audit line. Those are
 * produced here, by the trusted claims backend, calling the OGuardAI REST API
 * directly, outside the model's tool surface. Different channels yield different
 * visibility from the same tokenized draft, and `restore_overrides` can only
 * tighten a channel, never loosen it, so this path cannot escalate beyond what the
 * policy and the caller's auth already permit.
 */

import { GUARDAI_URL, GUARDAI_API_KEY } from "./scenario.js";

export interface Materialized {
  channel: string;
  restoredText: string;
  tokensResolved: number;
  tokensUnresolved: string[];
}

interface RehydrateResponse {
  restored_text?: string;
  tokens_resolved?: number;
  tokens_unresolved?: string[];
}

/** Any `{{...}}` still present after restore. On a customer channel a residual
 *  token means an unresolved value, so the letter must be withheld, not sent. */
export function residualTokens(text: string): string[] {
  return text.match(/\{\{[^}]+\}\}/g) ?? [];
}

export async function materialize(
  draft: string,
  sessionState: string,
  channel: string,
  restoreOverrides?: Record<string, string>,
): Promise<Materialized> {
  const res = await fetch(`${GUARDAI_URL}/v1/rehydrate`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...(GUARDAI_API_KEY ? { "X-API-Key": GUARDAI_API_KEY } : {}),
    },
    body: JSON.stringify({
      output: draft,
      session_state: sessionState,
      output_channel: channel,
      ...(restoreOverrides ? { restore_overrides: restoreOverrides } : {}),
    }),
  });
  if (!res.ok) {
    // Fail closed: a rejected session (tampered or expired) never yields raw text.
    throw new Error(`Rehydrate failed on channel '${channel}': ${res.status}`);
  }
  const data = (await res.json()) as RehydrateResponse;
  return {
    channel,
    restoredText: data.restored_text ?? "",
    tokensResolved: data.tokens_resolved ?? 0,
    tokensUnresolved: data.tokens_unresolved ?? [],
  };
}

The guided trace: src/demo.ts

The end-to-end driver. It runs both planes against one live server: the model plane (detect, evaluate_policy, transform, an LLM draft on tokens, rehydrate) and the host plane (materialize the four channels). assertBoundary fails hard if any structured value crosses to the model, and because insurance-de requires NER for the claimant name, a runtime without the NER sidecar fails the transform closed rather than let a name cross. failClosed proves the three safety properties through the MCP tools.

/**
 * Guided end-to-end trace of the Musterversicherung claims desk over MCP.
 *
 * Two planes run against one live OGuardAI server:
 *
 *   MODEL PLANE  (through the MCP server, JSON-RPC over stdio)
 *     detect -> evaluate_policy -> transform -> [LLM draft on tokens] -> rehydrate
 *     Everything the AI client does. The model only ever sees tokens, entity
 *     types, and policy decisions. rehydrate confirms resolution but returns no
 *     restored text.
 *
 *   HOST PLANE  (trusted claims backend, OGuardAI REST directly)
 *     Materializes the real letter, the internal note, the reinsurer extract, and
 *     the audit line from the one tokenized draft, each on its own channel.
 *
 * Run:  GUARDAI_API_KEY=... pnpm demo
 */

import { McpClient, ProtocolError, ToolError } from "./mcp-client.js";
import { CHANNELS, COMPANY, GUARDAI_URL, loadClaimEmail, resolveServerSpec } from "./scenario.js";
import { draftReply, llmMode } from "./llm.js";
import { materialize, residualTokens } from "./host.js";

interface TransformResult {
  safe_text: string;
  session_state: string;
  entities_count: number;
}
interface DetectResult {
  entities: Array<{ type: string | { custom: string }; confidence: number }>;
  count: number;
}
interface PolicyDecision {
  entity_type: string;
  action: string;
  restore_mode?: string;
}
interface EvaluateResult {
  decisions: PolicyDecision[];
  policy_version?: string;
  warnings?: string[];
}
interface HealthResult {
  status: string;
  components?: Record<string, { message?: string }>;
}
interface RehydrateAck {
  tokens_resolved: number;
  tokens_unresolved: string[];
}

// The claimant's identity and payment block, builtin entity types only. We preview
// the policy disposition of these (person, IBAN, email, phone); the custom policy
// and claim identifiers are governed by their own always-on rules in insurance-de.
const CONTACT_BLOCK =
  "Frau Dr. Erika Mustermann, Konto DE97 9900 0000 0123 4567 89, " +
  "E-Mail erika.mustermann@example.de, Telefon +49 151 23456789.";

function rule(label: string): void {
  console.log(`\n${"=".repeat(74)}\n${label}\n${"=".repeat(74)}`);
}

function indent(text: string, spaces = 2): string {
  const pad = " ".repeat(spaces);
  return text
    .split("\n")
    .map((line) => pad + line)
    .join("\n");
}

function typeName(t: string | { custom: string }): string {
  return typeof t === "string" ? t : t.custom;
}

function entitySummary(entities: DetectResult["entities"]): string {
  const counts = new Map<string, number>();
  for (const e of entities) {
    const name = typeName(e.type);
    counts.set(name, (counts.get(name) ?? 0) + 1);
  }
  return [...counts.entries()]
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([k, v]) => `${k}=${v}`)
    .join(", ");
}

// The invented PII in the claim fixture, split by detector. STRUCTURED values are
// builtin-regex-detectable and must never cross to the model on any lane. The claim
// and policy identifiers are custom types governed by their own always-on rules, so
// they stay out of this hard set. A NER name may cross only when the runtime runs
// without NER, and then only as a reported degradation.
const STRUCTURED_PII = [
  "DE97 9900 0000 0123 4567 89",
  "erika.mustermann@example.de",
  "+49 151 23456789",
] as const;
const NER_PII = ["Erika Mustermann"] as const;

/**
 * Check one string that crosses to the model. A STRUCTURED value crossing is a hard
 * failure on every lane. A name crossing fails only when NER is active; without NER
 * the runtime has no name detector, so the name is a reported degradation, never a
 * silent leak. The one signal is ner_active from guardai_capabilities.
 */
function assertBoundary(text: string, nerActive: boolean, nameNotes: string[]): void {
  const structured = STRUCTURED_PII.filter((value) => text.includes(value));
  if (structured.length > 0) {
    throw new Error(`boundary check failed: structured PII crossed to the model: ${structured.join(", ")}`);
  }
  for (const name of NER_PII.filter((value) => text.includes(value))) {
    if (nerActive) {
      throw new Error(`boundary check failed: the name ${name} crossed to the model while NER is active`);
    }
    console.log(`\n  ! HINWEIS: Der Name ${name} erreichte das Modell, weil dieser Runtime ohne NER laeuft.`);
    if (!nameNotes.includes(name)) nameNotes.push(name);
  }
}

async function main(): Promise<void> {
  const client = new McpClient(resolveServerSpec());
  client.start();

  try {
    const info = await client.initialize();
    const tools = await client.listTools();

    // Preflight through the MCP health tool: the same check an AI client runs.
    let health: HealthResult;
    try {
      health = await client.callTool<HealthResult>("guardai_health", {});
    } catch {
      console.error(`OGuardAI is not reachable at ${GUARDAI_URL}.`);
      console.error("Start it first:  docker compose -f deploy/docker/docker-compose.yml up -d   (from the repo root; see this example's README)");
      process.exit(1);
      return;
    }
    if (health.status === "unhealthy") throw new Error("OGuardAI reports unhealthy");

    let nerActive = true;
    try {
      const caps = await client.callTool<{ ner_active?: unknown }>("guardai_capabilities", {});
      nerActive = caps.ner_active !== false;
    } catch {
      nerActive = true;
    }
    const nameNotes: string[] = [];

    rule(`${COMPANY.name} - Schadenservice-Assistentin "${COMPANY.assistant}" (MCP)`);
    console.log(`MCP server:  ${info.name} v${info.version} (protocol ${info.protocolVersion})`);
    console.log(`Tools:       ${tools.length} verfuegbar`);
    console.log(`OGuardAI:    ${GUARDAI_URL}   Detector: ${health.components?.detector?.message ?? "?"}`);
    console.log(
      `Policy:      ${COMPANY.policy}   Sprache: ${COMPANY.language}   LLM: ${llmMode()}   NER: ${nerActive ? "aktiv" : "aus"}`,
    );

    const email = loadClaimEmail();

    rule("EINGANG (RAW, innerhalb der Vertrauensgrenze)");
    console.log("Der Sachbearbeiter fuegt die Schadenmeldung in den Assistenten ein:");
    console.log(indent(email));

    // 1) TRIAGE. The model asks which entity types are present. No values cross
    //    into its context; detect returns type, span, confidence only.
    rule("MODELL-EBENE 1/4 - guardai_detect (Triage)");
    const detected = await client.callTool<DetectResult>("guardai_detect", {
      text: email,
      language: COMPANY.language,
      policy: COMPANY.policy,
    });
    console.log(`Erkannte Entitaeten (${detected.count}): ${entitySummary(detected.entities)}`);

    // 2) POLICY PREVIEW. How would the policy treat the claimant's identity and
    //    payment data, and does a claimed role change anything?
    rule("MODELL-EBENE 2/4 - guardai_evaluate_policy (Disposition)");
    const asAdjuster = await client.callTool<EvaluateResult>("guardai_evaluate_policy", {
      text: CONTACT_BLOCK,
      policy: COMPANY.policy,
      caller_role: "claims_adjuster",
      output_channel: CHANNELS.adjuster,
    });
    for (const d of asAdjuster.decisions) {
      console.log(`  ${d.entity_type.padEnd(8)} -> ${d.action}, restore=${d.restore_mode ?? "-"}`);
    }
    console.log(
      "\nHinweis: caller_role ist ein Vorschau-Parameter. Der Server bindet die echte",
    );
    console.log(
      "Rolle an die Authentifizierung, nicht an das Feld. Unter dem Dev-Key aendert",
    );
    console.log("kein uebergebener caller_role die Entscheidung: eine Anfrage kann nicht eskalieren.");

    // 3) PROTECT. The model transforms the message. From here it works only on
    //    safe_text; the raw values are sealed in session_state (opaque blob).
    rule("MODELL-EBENE 3/4 - guardai_transform (Schutz)");
    const tf = await client.callTool<TransformResult>("guardai_transform", {
      text: email,
      policy: COMPANY.policy,
      language: COMPANY.language,
    });
    console.log(`entities_count: ${tf.entities_count}   session_state: ${tf.session_state.length} Zeichen`);
    console.log("\nWas das LLM sieht (safe_text, nur Tokens):");
    console.log(indent(tf.safe_text));
    assertBoundary(tf.safe_text, nerActive, nameNotes);

    // 4) DRAFT then CONFIRM. The model drafts on tokens, then calls rehydrate.
    //    The tool returns a resolution count, never the restored text.
    rule("MODELL-EBENE 4/4 - LLM-Entwurf + guardai_rehydrate (Bestaetigung)");
    const draft = await draftReply(tf.safe_text);
    console.log("LLM-Entwurf (noch tokenisiert):");
    console.log(indent(draft));
    assertBoundary(draft, nerActive, nameNotes);

    const ack = await client.callTool<RehydrateAck>("guardai_rehydrate", {
      text: draft,
      session_state: tf.session_state,
      output_channel: CHANNELS.customer,
    });
    console.log(
      `\nrehydrate ack -> resolved=${ack.tokens_resolved} unresolved=[${ack.tokens_unresolved.join(", ")}]`,
    );
    console.log("Der wiederhergestellte Text (mit echten Daten) wird dem Modell NICHT zurueckgegeben.");

    // HOST PLANE. The trusted backend materializes the real outputs out-of-band.
    rule("HOST-EBENE - Materialisierung ueber OGuardAI REST (ausserhalb des Modells)");
    const customer = await materialize(draft, tf.session_state, CHANNELS.customer);
    const adjuster = await materialize(draft, tf.session_state, CHANNELS.adjuster);
    const partner = await materialize(draft, tf.session_state, CHANNELS.partner);
    const audit = await materialize(draft, tf.session_state, CHANNELS.audit);

    console.log(`[customer_email]   -> Brief an die Versicherungsnehmerin:`);
    console.log(indent(customer.restoredText, 4));
    console.log(`\n[internal_summary] -> interne Fallnotiz:`);
    console.log(indent(adjuster.restoredText, 4));
    console.log(`\n[reinsurer_export] -> Extrakt fuer den Rueckversicherer:`);
    console.log(indent(partner.restoredText, 4));
    console.log(`\n[log_safe]         -> SIEM-/Audit-Zeile:`);
    console.log(indent(audit.restoredText, 4));

    // Residual-token guard: never send a customer letter with a dangling token.
    const residual = residualTokens(customer.restoredText);
    if (residual.length > 0) {
      console.log(
        `\n! Kundenbrief zurueckgehalten (unaufgeloeste Tokens: ${residual.join(", ")})`,
      );
    }

    await failClosed(client, tf.session_state);

    rule("FERTIG");
    if (nameNotes.length > 0) {
      console.log(
        "Strukturierte Daten (IBAN, E-Mail, Telefon) blieben in der Vertrauensgrenze. Der Name " +
          `${[...new Set(nameNotes)].join(", ")} erreichte das Modell, weil dieser Runtime ohne NER laeuft.`,
      );
    } else {
      console.log("Das LLM hat zu keinem Zeitpunkt einen echten Namen, eine IBAN oder eine E-Mail gesehen.");
    }
    console.log("Der Kundenbrief und die Fallnotiz entstanden erst auf der Host-Ebene, ausserhalb des Modells.");
  } finally {
    await client.stop();
  }
}

/** Three safety properties, all observed through the MCP tools. */
async function failClosed(client: McpClient, sessionState: string): Promise<void> {
  rule("FAIL-CLOSED (ueber die MCP-Tools beobachtet)");

  // A person token that exists in this session, plus one the model invented.
  const t = await client.callTool<TransformResult>("guardai_transform", {
    text: "Guten Tag, ich bin Frau Erika Mustermann.",
    policy: COMPANY.policy,
    language: COMPANY.language,
  });
  const realToken = t.safe_text.match(/\{\{person:[^}]+\}\}/)?.[0] ?? "{{person:p_001:0000}}";
  const invented = "{{person:p_999:deadbeef0000}}";

  // 1) A tampered session blob: the tool runs but fails, surfaced as isError.
  const tampered = `${sessionState.slice(0, -40)}${"A".repeat(40)}`;
  let tamperAccepted = false;
  try {
    await client.callToolRaw("guardai_rehydrate", {
      text: `Guten Tag ${realToken}.`,
      session_state: tampered,
      output_channel: CHANNELS.customer,
    });
    tamperAccepted = true;
  } catch (err) {
    const label = err instanceof ToolError ? "ToolError (isError)" : String(err);
    console.log(`1) Manipuliertes session_state -> abgelehnt: ${label}`);
  }
  if (tamperAccepted) {
    throw new Error("fail-closed check failed: a tampered session was accepted");
  }

  // 2) An invented token is reported as unresolved, never guessed.
  const ack = await client.callTool<RehydrateAck>("guardai_rehydrate", {
    text: `${realToken} und ${invented} melden sich.`,
    session_state: t.session_state,
    output_channel: CHANNELS.customer,
  });
  console.log(
    `2) Erfundener Token -> resolved=${ack.tokens_resolved} unresolved=[${ack.tokens_unresolved.join(", ")}]`,
  );
  if (!ack.tokens_unresolved.some((t) => t.includes("p_999"))) {
    throw new Error("fail-closed check failed: a forged token was resolved instead of left unresolved");
  }

  // 3) A missing required argument is a protocol-level error (-32602).
  let missingArgAccepted = false;
  try {
    await client.callToolRaw("guardai_transform", {});
    missingArgAccepted = true;
  } catch (err) {
    const label = err instanceof ProtocolError ? `ProtocolError (${err.code})` : String(err);
    console.log(`3) Fehlendes Argument 'text' -> ${label}`);
  }
  if (missingArgAccepted) {
    throw new Error("fail-closed check failed: a tool call missing a required argument was accepted");
  }
}

main().catch((err) => {
  console.error("Demo failed:", err);
  process.exit(1);
});

Use the same server in a real MCP client

To use the same server inside a real assistant, add it to your client config (claude_desktop_config.json for Claude Desktop, or the equivalent for Cursor, Windsurf, or Claude Code). Set GUARDAI_API_KEY only if the server runs with auth.

{
  "mcpServers": {
    "guardai": {
      "command": "node",
      "args": ["/absolute/path/to/apps/mcp-server/dist/index.js"],
      "env": {
        "GUARDAI_URL": "http://localhost:3000",
        "GUARDAI_API_KEY": ""
      }
    }
  }
}

That config deliberately omits GUARDAI_MCP_MODE, so the server runs in its fail-closed model posture: the raw-input tools (guardai_transform, guardai_detect, guardai_evaluate_policy, the batch and RAG-ingest tools) are neither advertised nor callable. Set GUARDAI_MCP_MODE=host only for a trusted host process that drives those tools as middleware, the way this example's demo does.

Example environment variables

VariableDefaultDescription
GUARDAI_URLhttp://localhost:3000OGuardAI server URL (passed to the spawned MCP server and used by the host plane)
GUARDAI_BASE_URL(fallback)Alias for GUARDAI_URL
GUARDAI_API_KEY(none)Sent as X-API-Key when OGuardAI auth is enabled
GUARDAI_MCP_COMMANDnodeCommand used to launch the MCP server
GUARDAI_MCP_SERVERapps/mcp-server/dist/index.jsServer entrypoint (set to a src/index.ts path with tsx to run the TypeScript entrypoint directly)
GUARDAI_MCP_DEBUG(unset)When set, the server's stderr is inherited for debugging
OPENAI_API_KEY(none)Enables the real OpenAI path; offline draft when unset
OPENAI_MODELgpt-4o-miniModel for the OpenAI path
OPENAI_BASE_URLhttps://api.openai.com/v1Override for an OpenAI-compatible endpoint

Files

FileRole
src/mcp-client.tsMinimal MCP client over stdio: spawn, initialize, tools/list, tools/call
src/scenario.tsCompany config, the four output channels, server resolution, fixture loader
src/llm.tsLLM adapter: OpenAI over fetch, or a deterministic offline draft on tokens
src/host.tsTrusted host plane: materialize real values per channel via OGuardAI REST
src/demo.tsGuided end-to-end trace across both planes
fixtures/helvetia-schaden-erstmeldung.de.txtFictional German first-notice-of-loss email (all PII invented)