Skip to content

MCP

Outcome: Cursor and Claude Desktop call daari as an MCP server (route, stats, configured integrations).

daari speaks JSON-RPC 2.0 at POST /mcp over streamable HTTP. When server.api_key is set, send the same Bearer / x-api-key the rest of the daemon expects. initialize advertises tools, resources, and prompts capabilities; resources/list and prompts/list return empty arrays (tools-only server — explorer probes succeed instead of Method not found). GET /v1/daari/stats (and daari web-ui serve) expose mcp_tool_calls outcome counts and an mcp_tasks status summary for local ops without Prometheus. Admin ops can list in-flight tools/call work via GET /v1/daari/mcp/activity and force-abort with POST /v1/daari/mcp/activity/{request_id}/abort (idempotent when finished; 404 when unknown; audit action mcp.activity.abort). daari doctor probes each configured integrations.mcp_servers URL (optional mcp:<id> rows) so a typo'd egress host fails before the first @mcp call. Egress URLs are SSRF-checked (http/https only; loopback, RFC1918, link-local, and cloud metadata denied after DNS resolution). Lab/docker targets need integrations.mcp_egress.allow_private_networks: true. Opt-in Responses server-side MCP (integrations.mcp_egress.server_side_responses: true) accepts type: mcp tools whose server_label matches a configured server id, lists tools, and runs the same egress tools/call path on model tool rounds (default off keeps the honesty 400). Opt-in Messages server-side MCP (integrations.mcp_egress.server_side_messages: true) accepts Anthropic mcp_servers + tools: [{type: mcp_toolset, ...}] whose mcp_server_name matches a configured server id (client URLs are ignored; egress uses the configured entry). Default off fails closed with an honesty 400.

Opt-in MCP OAuth discovery (integrations.mcp_oauth.protected_resource: true) serves RFC 9728 GET /.well-known/oauth-protected-resource (and the /mcp scoped variant) and adds a WWW-Authenticate challenge on unauthenticated /mcp when API-key auth is on. Default off leaves the existing 401 body unchanged. Set authorization_servers / scopes_supported for your IdP.

Opt-in aggregate egress catalog (integrations.mcp_aggregate_egress.enabled: true) merges tools from configured integrations.mcp_servers into /mcp tools/list, namespaced as {server_id}__{tool} (same scheme as Responses server-side MCP). tools/call on those names routes through the existing egress path (SSRF, breaker, guardrails, spend/--by-tool). Server/tool/client policies still apply — denied servers are omitted from the list. Per-server list failures log mcp_aggregate_egress_list_failed and skip that server without emptying the rest of the catalog. Default off keeps the first-party catalog only (core + local ProviderRegistry stubs).

Opt-in OBO token exchange per egress server (auth_type: oauth2_token_exchange) performs RFC 8693 token exchange before tools/list / tools/call: daari uses the inbound Authorization bearer as subject_token, posts to token_exchange_endpoint with client_id / client_secret (optional audience, scopes, subject_token_type), caches the exchanged access token until expiry, and sends only that token upstream (never the inbound subject). The exchange endpoint is SSRF-checked like other egress URLs. Missing subject → 401; exchange error or blocked endpoint → 502 (log_gateway_event; secrets never logged).

integrations:
  mcp_servers:
    - id: corp
      url: https://mcp.corp.example/rpc
      auth_type: oauth2_token_exchange
      token_exchange_endpoint: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
      client_id: daari-mcp
      client_secret: secret://keychain/daari-mcp/client-secret
      audience: api://mcp-corp
      scopes: [mcp.read, mcp.call]

Opt-in local authorization server (integrations.mcp_oauth.local_as: true) adds GET /.well-known/oauth-authorization-server, POST /oauth/token (grant_type=client_credentials), and POST /oauth/revoke (RFC 7009). Exchange an existing API key or virtual key as client_secret (form body or HTTP Basic) for a short-lived Bearer JWT (token_ttl_seconds, default 900) that /mcp accepts like the API-key path. Mint rejects scopes outside scopes_supported (invalid_scope); /mcp requires the mcp scope claim (missing/wrong → 403). Revoke denylists by jti until remaining exp. Invalid, expired, or revoked tokens get the same 401 + WWW-Authenticate challenge. Tokens are HS256-signed (signing_secret, or derived from the master key); plaintext secrets are never logged. When authorization_servers is empty, discovery advertises the local issuer. No external IdP required.

Opt-in OpenAPI → MCP proxy (integrations.mcp_openapi_proxy.enabled: true) exposes POST /mcp/proxy for schema discovery (action: tools/list) and HTTP-backed tools/call against allowlisted specs[] (id, openapi_url, optional base_url / operations). Spec fetch and call URLs use the same SSRF guards as MCP egress (mcp_egress.allow_private_networks). Default off returns 503. The route maps to the mcp rate-limit family.

Opt-in MCP Registry (integrations.mcp_registry.enabled: true) serves GET /v1/mcp/registry.json listing the built-in /mcp ingress and each configured integrations.mcp_servers id with its streamable HTTP URL. Advertisement-only — /mcp still enforces API-key / allowlists. Default off returns 404. Auth-agnostic so IDE/agent discovery can run without a key.

Edge proxies can validate a virtual key without the signing secret via RFC 7662 POST /introspect (JSON {"token":"…"} or form token=…), authenticated with a master or virtual key. Active responses include client_id, username, optional exp / team / tier_cap / rpm·tpm; revoked, expired, or unknown tokens return {"active": false} with HTTP 200.

Cursor

~/.cursor/mcp.json (or project .cursor/mcp.json):

{
  "mcpServers": {
    "daari": {
      "url": "http://127.0.0.1:11435/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_DAARI_KEY"
      }
    }
  }
}

Drop the headers object when the daemon has no API key.

Claude Desktop

This is the tools path. To route Claude Desktop's chat itself through daari (third-party gateway mode), see the Claude Desktop guide — daari setup claude-desktop.

claude_desktop_config.json (Claude Desktop) or Claude Code MCP settings:

{
  "mcpServers": {
    "daari": {
      "type": "http",
      "url": "http://127.0.0.1:11435/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_DAARI_KEY"
      }
    }
  }
}

Tools

Tool What it does
route Send input through daari's local-first pipeline
stats Current tier metrics snapshot
sourcegraph / ghe / gitlab Registered integration providers
mcp_* Each integrations.mcp_servers entry

Request deadlines on tools/call

tools/call (JSON-RPC POST /mcp and the legacy /v1/mcp/query path) honors the same wall-clock budget as chat: send X-Daari-Deadline-Ms (or set upstream.request_deadline_seconds in config). When the budget is spent before the tool finishes, the gateway returns 504 with request_deadline_exceeded and does not keep running the call. See Request headers.

Tool governance

Any authenticated caller can reach every tool unless you say otherwise. Policies are glob-style allow / deny lists on tool names; deny always wins, and an empty allow means "everything not denied". Three layers stack, narrowest last:

  1. integrations.mcp_policy — global default (also governs the master key and open single-user installs).
  2. integrations.mcp_team_policies.<team> — per team name, applied to keys created with --team.
  3. The virtual key's metadata.mcp — per key.

Denies from every layer accumulate; the most specific allow list wins.

integrations:
  mcp_policy:
    deny: ["mcp_prod_*"]
  mcp_team_policies:
    eng:
      allow: ["route", "stats", "mcp_*"]

Per-key policy is set at creation and stored in the key's metadata:

daari keys create ci-bot --team eng --mcp-allow route --mcp-allow 'mcp_*' --mcp-deny mcp_prod

Server allowlists

Tool-name globs cannot express which egress MCP server a key may reach — get_forecast on weather and get_forecast on shell collide. Nested servers.allow / servers.deny lists grant server ids (same merge rules: deny wins, empty allow = all, key over team over global):

integrations:
  mcp_policy:
    servers:
      deny: ["shell"]
  mcp_team_policies:
    finance:
      servers:
        allow: ["weather", "github"]
daari keys create finance-bot --team finance \
  --mcp-server-allow weather \
  --mcp-server-deny github
daari keys show <key_id>   # prints mcp servers: allow=… deny=…
daari doctor               # mcp_server_policy row summarises global/team grants

A denied server never leaves the machine: tools/list hides that server's mcp_<id> catalog entry, a tools/call targeting it returns the same policy denial as a denied tool (-32003 / MCP_ERR_TOOL_DENIED, with data.server set), and the egress client refuses to POST. Audit rows include the server id.

Client allowlists

A second gate matches the client identity that opens /mcp — not which tools it may call. Nested clients.allow / clients.deny use the same merge rules (deny wins, empty allow+deny = passthrough, key over team over global). Identity prefers _meta.io.modelcontextprotocol/clientInfo.name, then OAuth/VK client_id, then User-Agent.

integrations:
  mcp_policy:
    clients:
      deny: ["evil-*"]
  mcp_team_policies:
    eng:
      clients:
        allow: ["claude-*", "cursor"]
daari keys create ci-bot --team eng \
  --mcp-client-allow 'claude-*' \
  --mcp-client-deny 'evil-*'
daari keys show <key_id>   # prints mcp clients: allow=… deny=…

When configured, initialize, server/discover, tools/list, and tools/call reject non-matching clients with JSON-RPC -32004 (MCP client denied by policy) and an mcp.client audit row. Unset lists leave every authenticated caller through (backward compatible).

Require key MCP grant (fail-closed)

By default a virtual key with no metadata.mcp still sees the full first-party catalog. Opt in to LiteLLM-style fail-closed with integrations.mcp_policy.require_key_access_defined: true:

integrations:
  mcp_policy:
    require_key_access_defined: true

When the flag is on, a virtual key whose metadata.mcp has no tool/server grant — none of allow, deny, or nested servers.allow / servers.deny — fails initialize (HTTP 403 JSON-RPC -32003), gets an empty tools/list, and every tools/call is denied (-32003 / MCP_ERR_TOOL_DENIED). Keys that declare any of those fields keep normal allow/deny merge behavior and still complete initialize. A clients block alone is not a tool grant. The master server.api_key and flag-off installs are unchanged. daari doctor surfaces an optional mcp_require_key_access tip when the flag is on.

What the caller sees:

  • A denied initialize on POST /mcp is HTTP 403 with JSON-RPC error -32003 (MCP initialize denied: virtual key has no MCP grant).
  • tools/list (JSON-RPC and /v1/mcp/query) only returns allowed tools.
  • A denied tools/call on POST /mcp is JSON-RPC error -32003 (Tool denied by policy: <name>, data.tool set).
  • A denied call on the legacy /v1/mcp/query is HTTP 403 with MCP_ERR_TOOL_DENIED.

Every tools/call — allowed or denied — appends a row to the enterprise audit log (enterprise.audit_path) with the key id or master, the team, the tool name, the decision and the transport. Tool arguments are never written.

Metering and rate limits

Every successful tools/call writes a usage-ledger row (and a spend row when usage.spend is enabled), attributed to the virtual key / team like chat. The route tool meters through the normal router path; provider-backed and egress mcp_* tools write a zero-cost count row when no model cost is known (#1202).

POST /mcp and /v1/mcp/* use the dedicated mcp rate-limit family, so a key can set --rate-family mcp:30 without starving chat. Unset families keep the global key rpm/tpm only.

Each integrations.mcp_servers[] entry may also set optional rpm / tpm (0 = unlimited) ceilings that apply to egress tools/call and tools/list for that server id across all callers. Exhaustion returns HTTP 429 with Retry-After and X-RateLimit-Scope: mcp_server:<id>. These counters are independent of the key-family mcp budget — both are enforced (whichever is tighter denies first). Day-2 ops can PATCH integrations.mcp_servers as {id, rpm, tpm} rows via the config editor (GET/PATCH /v1/daari/config when observability.config_editor is on), or set integrations.mcpServers in the Helm chart (rendered as DAARI_INTEGRATIONS__MCP_SERVERS) without hand-editing config.yaml on every pod.

daari's MCP egress client sends the MCP 2026-07-28 routing headers Mcp-Method and Mcp-Name on outbound requests, and the ingress honours the same headers for policy when a client supplies them. When those headers are present they must match the JSON-RPC method / params.name (tools/call); mismatches return HTTP 400 with JSON-RPC error -32023 (HEADER_MISMATCH, data.header / header_value / body_value). Absent headers stay allowed.

Tool guardrails (arguments and results)

Policy decides which tools a caller may invoke; guardrails inspect what flows through them. integrations.mcp_guardrails has the same shape as the top-level guardrails block and is off by default. When enabled, input_rules (plus max_prompt_chars and the built-in prompt-injection heuristics) run over the flattened tools/call arguments before execution, and output_rules run over the result after — the same block / warn / redact semantics chat requests get, applied on the machine where the tools run.

integrations:
  mcp_guardrails:
    enabled: true
    block_message: "Tool result withheld by daari guardrail."
    input_rules:
      - { name: no_rm_rf, pattern: 'rm\s+-rf', action: block }
    output_rules:
      - { name: secrets, kind: secret, action: redact }
      - { name: pii, kind: pii, action: redact }

What the caller sees:

  • An input trip on POST /mcp is JSON-RPC error -32003 (Tool call blocked by guardrail <rule>: <tool>, data carries tool, rule and direction: input). On /v1/mcp/query it is HTTP 403 with MCP_ERR_GUARDRAIL_BLOCKED and the same fields under details.
  • An output redact rewrites every text item of the result in place (<aws_key>, <email>, <redacted>, …). An output block replaces the whole result with block_message and sets isError: true (legacy route: ok: false). Task results (tasks/get) are checked the same way before they are stored.
  • The egress client applies the same rules: outbound arguments are checked before the request leaves the machine (a block returns a guardrail_blocked warning instead of calling the server), and inbound results are scrubbed before the model sees them.

Every trip, input or output, appends an mcp.guardrail row to the audit log next to the mcp.tools/call decision rows: key id / team, tool, rule name, direction, action, transport. The payload itself is never written.

Protocol bootstrap (server/discover and legacy initialize)

MCP 2026-07-28 clients call server/discover (no session) to learn supportedVersions, capabilities, and serverInfo. Every subsequent request MAY declare the version in params._meta["io.modelcontextprotocol/protocolVersion"]; daari honors it without a prior handshake. Unsupported versions return JSON-RPC -32022 (Unsupported protocol version) with data.supported / data.requested. Legacy clients keep using initialize for pre-2026-07-28 revisions.

Tasks (long-running tools/call)

When the negotiated protocol is 2026-07-28 or newer, initialize (and server/discover) advertises the io.modelcontextprotocol/tasks capability. Clients opt in per call:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "route",
    "arguments": {"input": "run the suite"},
    "_meta": {"io.modelcontextprotocol/tasks": true}
  }
}

Eligible tools (default: route, or any tool when integrations.mcp_tasks.threshold_ms > 0) return a taskId immediately; poll with tasks/get, acknowledge state with tasks/update, and stop work with tasks/cancel. Calls without the _meta opt-in keep today's blocking behavior. Governance (#277) still audits the call before a task is created.

integrations:
  mcp_tasks:
    enabled: true
    long_running_tools: [route]
    threshold_ms: 0
    path: ~/.daari/mcp-tasks

When an upstream MCP server's aggregated tools/list catalog grows past a threshold (default 40 after cursor pagination), small local models suffer — tool lists burn context and degrade choice quality. Opt in:

integrations:
  mcp_tool_search:
    enabled: true
    min_catalog_size: 40
    top_k: 40

daari ranks tools by cosine similarity between (name + description) and a query, using the same local embed path as L1 (cache.l1.embedding_model). Embeddings are cached per (server, tool name, description hash).

Query heuristic (at listing time): trailing text after @mcp … tools/list / list (e.g. @mcp:demo tools/list weather), else the most recent user/tool message content. Empty query or an embed failure leaves the (post-governance) catalog unranked and logs mcp_tool_search_degraded — never an error to the client.

integrations.mcp_policy allow/deny runs before ranking, so search cannot resurface a denied tool. Off by default; catalogs at or under min_catalog_size are unchanged.

tools/list cache hints (MCP 2026-07-28)

tools/list (JSON-RPC and /v1/mcp/query) includes result _meta with ttlMs and cacheScope so clients can cache the catalog between turns:

integrations:
  mcp_list_cache:
    ttl_ms: 60000   # default; emitted as _meta.ttlMs

cacheScope is public when no allow/deny ACL filters the catalog, and private when a policy filtered the list (Kong-compatible: never advertise public on ACL-filtered catalogs).

Deprecated alias

POST /v1/mcp/query remains for older callers. Responses include Deprecation: true and Link: </mcp>; rel="successor-version".

Next

→ Clients and gateways · Auth and keys