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:
integrations.mcp_policy— global default (also governs the master key and open single-user installs).integrations.mcp_team_policies.<team>— per team name, applied to keys created with--team.- 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
initializeonPOST /mcpis HTTP403with 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/callonPOST /mcpis JSON-RPC error-32003(Tool denied by policy: <name>,data.toolset). - A denied call on the legacy
/v1/mcp/queryis HTTP403withMCP_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 /mcpis JSON-RPC error-32003(Tool call blocked by guardrail <rule>: <tool>,datacarriestool,ruleanddirection: input). On/v1/mcp/queryit is HTTP403withMCP_ERR_GUARDRAIL_BLOCKEDand the same fields underdetails. - An output
redactrewrites every text item of the result in place (<aws_key>,<email>,<redacted>, …). An outputblockreplaces the whole result withblock_messageand setsisError: 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_blockedwarning 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
Semantic tool search¶
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".