Agent protocol: secrets through MCP

For authors of AI agents and MCP clients. This document explains the "agent never sees the value" invariant (ADR-023 §3.7), the full list of MCP tools in the secrets_* family, their semantics, request lifetimes, and response shapes.

See ADR-023 §3.7 for the formal spec and crates/devboy-mcp/src/secrets_tool.rs + secrets_provision.rs for the implementation.

Russian translation: ru/agent-protocol.md.


Core invariant

The agent never sees secret values. Metadata only.

This is not a "best practice" or a runtime check — it is a typed boundary in the code. The reply structs of every secrets_* tool physically have no value field. If a future refactor tries to add a SecretString to a reply, compilation fails on the AgentSafeReply marker trait. On top of that:

  • CI grep gate in crates/devboy-mcp/tests/no_expose_secret_outside_allowlist.rs. Any .expose_secret() outside the allowlist → fail.
  • Negative integration test in crates/devboy-mcp/tests/secret_tool_responses_never_leak_value.rs. Pumps a fixture sentinel through every secrets_* tool and asserts the value never appears in the reply.

For the agent side this means: you cannot write secrets.get and receive a token. If you need a value, it reaches the work through a high-level provider tool (e.g. get_issues with the token already wired into a proxy alias). The bypass is available only to the user — through the UI dialog, which never returns a string back to the agent.


Tool list

Tool namePurposeReturnsLifecycle
secrets_listBrowse the inventory of the active contextList of metadata recordsSynchronous
secrets_describeDetail card for one pathMetadata for one pathSynchronous
secrets_request_provisionOpen the UI dialog for first-time provisionrequest_idAsynchronous, polling
secrets_request_rotationSame with destructive-confirm for replacementrequest_idAsynchronous, polling
secrets_propose_metadataSuggest metadata edits for an existing pathrequest_idAsynchronous, polling
secrets_propose_new_pathSuggest registering a new path in the manifestrequest_idAsynchronous, polling
secrets_request_use_approvalAsk for approval to use a path whose approve_on_use is session / per-callrequest_idAsynchronous, polling
secrets_poll_statusGet the status of a previously issued request_id{kind, status, age_seconds, …}Synchronous

UI-dialog requests are asynchronous: the agent kicks off the dialog and polls for status. The user types into the window/TUI at their own pace; the agent only sees the event flow.


secrets_list

List the paths declared in the active context's manifest.

Request

{
  "name": "secrets_list",
  "arguments": {
    "path_contains": "jira",
    "scope": "team",
    "status": "expiring",
    "include_internal": false
  }
}

All arguments are optional. Filters AND-combine. include_internal defaults to false — internal paths (__sys/...) are hidden.

Response

[
  {
    "path": "team/<provider>/api-key",
    "status": "expiring",
    "expires_at": "2026-05-23",
    "source_name": null,
    "capabilities_hint": "read,rotate"
  }
]

Fields:

  • path — ADR-020 path (<scope>/<provider>/<purpose>).
  • statusregistered / expiring (≤14 days to expiry) / expired. Computed from expires_at, not from a live source probe. This is intentional: a probe could accidentally reveal that the user has already revoked the token.
  • expires_at — ISO-8601 (YYYY-MM-DD) or null.
  • source_name — currently always null (the router is not exposed to the MCP server; the field is reserved in the wire format).
  • capabilities_hint"read" (manual rotation) or "read,rotate" (provider-ui / provider-api).
  • approve_on_use"session" / "per-call". Omitted from the reply when the manifest leaves the path at the default "never" so that "absent" and "never" are wire-equivalent. Lets the agent pre-filter the inventory and warn the user up-front about paths that will surface a dialog on resolve.

The response is sorted by path for test stability.


secrets_describe

Detail card for one path.

Request

{
  "name": "secrets_describe",
  "arguments": { "path": "team/<provider>/api-key" }
}

Response

A strict superset of a secrets_list element plus extra metadata fields:

{
  "path": "team/<provider>/api-key",
  "status": "registered",
  "expires_at": "2026-12-01",
  "source_name": null,
  "capabilities_hint": "read",
  "description": "API token used by team CI",
  "retrieval_url": "https://example.invalid/<provider>/tokens",
  "rotation_method": "manual",
  "last_rotated_at": "2026-04-15",
  "rotate_every_days": 90,
  "pattern_id": "<pattern-id>",
  "approve_on_use": "session"
}

Errors:

  • not-found — path not visible in the active context (neither in the project manifest nor in the global index with overrides).
  • invalid-path — syntax doesn't match ADR-020.
  • merge-failed — manifest conflict (duplicate entry, invalid structure).

Manifest-gating: only paths the active project manifest references are visible. The global index is not leaked wholesale.

The approve_on_use field is omitted from the reply when the manifest leaves the path at the default "never". Otherwise it carries "session" or "per-call" so the agent can pre-warn the user about paths that will surface a dialog at resolve time.


secrets_request_provision

Ask the framework to open a UI dialog where the user types a new value for the secret.

Request

{
  "name": "secrets_request_provision",
  "arguments": {
    "path": "team/<provider>/api-key",
    "mode": "provision"
  }
}

mode is optional, defaulting to "provision". The value "rotation" forces destructive-confirm — but for rotation the dedicated secrets_request_rotation (below) is usually clearer.

Response

{ "request_id": "prov-1f9b3a2c5e7d" }

request_id is an opaque string of the form prov-<12-hex>. Store it and poll via secrets_poll_status.

Lifecycle

secrets_request_provision  ──►  request_id, status = pending

                                       │  (user types the value into the dialog)

                                daemon stores value, status = ok

secrets_poll_status(request_id) ──►   status = ok

agent ──► high-level provider tool (e.g. get_issues),
          token already available through the proxy alias

The request expires after 5 minutes if the user hits neither Save nor Cancel. The agent should add a polling timeout of its own.


secrets_request_rotation

Same semantics as request_provision { mode: "rotation" }, but without ambiguity at the call site. The tool passes mode = Rotation to the registry; the UI shows a dialog with an explicit destructive-confirm checkbox ("I understand this overwrites the current secret").

Request

{
  "name": "secrets_request_rotation",
  "arguments": { "path": "team/<provider>/api-key" }
}

Response

{ "request_id": "prov-7c3e5a2d8b9f" }

Lifecycle is identical to secrets_request_provision. Use secrets_poll_status to watch progress.


secrets_propose_metadata

Ask the user to apply edits to the metadata of an existing path. An edit-metadata dialog opens with a diff preview: current values on the left are read from the manifest (the trusted source), proposed values on the right come from the agent's payload.

Request

{
  "name": "secrets_propose_metadata",
  "arguments": {
    "path": "team/<provider>/api-key",
    "fields": {
      "description": "Updated description",
      "retrieval_url": "https://example.invalid/<provider>/new-url",
      "rotate_every_days": 60,
      "expires_at": "2027-01-01",
      "pattern_id": "<new-pattern-id>"
    }
  }
}

Fields in fields are all optional. Omitted fields are not proposed for change; they stay at their current value in the diff.

Response

{ "request_id": "prov-9a2b8c4f7e1d" }

Trust boundary against prompt injection

This is a critical part of the protocol. The UI renders only the current column from the manifest — agent strings appear exclusively in proposed. That means:

  • The agent cannot "rewrite" metadata via cleverly composed values that pretend to be existing.
  • The user sees exactly what is being proposed.
  • Any attempt to "swap in" a path through a description with special characters fails because the path itself is rendered from the manifest, not from the payload.

For details, see the "Trust boundary" section in crates/devboy-mcp/src/secrets_provision.rs.


secrets_propose_new_path

Suggest registering a new path in the project manifest. The UI opens a discovery-style dialog with suggested_path as an editable starting point.

Request

{
  "name": "secrets_propose_new_path",
  "arguments": {
    "suggested_path": "team/<provider>/<new-purpose>",
    "metadata": {
      "description": "Newly discovered credential",
      "pattern_id": "<pattern-id>",
      "retrieval_url": "https://example.invalid/<provider>/tokens",
      "rotate_every_days": 90,
      "expires_at": null
    }
  }
}

Response

{ "request_id": "prov-2e4f6a8c1b3d" }

Unlike propose_metadata, the path is editable — the user can decline the agent's suggestion and pick a name of their own. The final decision stays with the human.


secrets_request_use_approval

Ask the user for permission to use a secret whose manifest entry sets approve_on_use to session or per-call. This is not a provision step — the value already exists; the user is approving the resolve. Paths with the default approve_on_use = never resolve silently and never need this tool.

Request

{
  "name": "secrets_request_use_approval",
  "arguments": {
    "path": "team/<provider>/api-key",
    "reason": "pushing image to staging registry",
    "ttl_seconds": 60
  }
}
  • path — the path the agent intends to resolve.
  • reason — short human-facing string rendered verbatim in the dialog (no markdown, no HTML). Mandatory and non-empty; the dialog uses it to explain why the agent wants the value.
  • ttl_seconds (optional) — narrow the per-request lifetime below the default 5 minutes. Capped server-side at the registry-wide TTL — agents cannot enlarge the window.

Response

{ "request_id": "prov-b8d7f9c1a3e5" }

Status flows through secrets_poll_status like the other request_* tools. The kind field is use-approval; the status settles to one of once / session / denied (in addition to the universal pending / expired / failed and unlike provision/rotation, not ok / cancelled).

Lifecycle

secrets_request_use_approval(path, reason)


   request_id, status = pending

       │ user picks a button in the dialog

   poll_status ──► one of:
       │  status = once     — resolve permitted this once, no caching
       │  status = session  — resolve permitted; cached for the session
       │  status = denied   — agent must surface a hard error
       │  status = expired  — TTL elapsed without a click
       │  status = failed   — dialog launcher returned an error

agent proceeds (or aborts) accordingly

once and denied are not cached. Only session populates the in-process SessionApprovalCache (devboy-core::secret_approval); subsequent resolves of the same path within the cached window observe ApprovalGate::AlreadyApproved and skip the dialog.

Threat model: agent cannot escalate a deny

The dialog is the only way to flip the decision. There is no MCP tool — and never will be — for the agent to override a denied, extend a TTL, or forge an approval. ttl_seconds is the one client-side knob, and it can only narrow the window. If the user clicks Deny, the agent's only path forward is to ask the user (via chat) and have them re-issue a fresh request from the UI.

When this tool fires

The orchestration layer (alias resolver / proxy MCP) inspects the manifest's approve_on_use field. The tool only fires on resolves whose policy is session or per-call:

  • never (default) → resolve silently; secrets_request_use_approval is never called.
  • session → first resolve in the process surfaces the dialog; subsequent resolves consult the cache.
  • per-call → every resolve surfaces the dialog (cache is bypassed even if a matching session entry exists).

The agent generally does not call this tool by hand. The proxy alias resolver invokes it on the agent's behalf when a high-level provider tool ("get_issues", "send_message") tries to resolve a gated path. Agents authoring custom resolves (rare) call it directly.


secrets_poll_status

Status check for any request_id from any request_* or propose_* tool. One endpoint — uniform lifecycle semantics.

Request

{
  "name": "secrets_poll_status",
  "arguments": { "request_id": "prov-1f9b3a2c5e7d" }
}

Response

{
  "request_id": "prov-1f9b3a2c5e7d",
  "path": "team/<provider>/api-key",
  "kind": "provision",
  "status": { "kind": "ok" },
  "age_seconds": 27
}

Fields:

  • request_id — echo of the request.
  • path — the path the dialog originally opened on. Echoed for confirmation.
  • kindprovision / rotation / metadata-proposal / new-path-proposal / use-approval.
  • status.kind — one of:
    • pending — dialog is open, the user has not picked yet.
    • ok — user saved the value / accepted the proposal (provision / rotation / proposals).
    • cancelled — user closed a provision / rotation / proposal dialog without saving.
    • expired — the 5-minute TTL elapsed; the registry marked the entry as Expired.
    • failed { reason } — the dialog failed to open (no launcher, daemon down) or the provider rejected the submission.
    • once — use-approval: user approved this resolve only, no caching.
    • session — use-approval: user approved for the rest of the session; the orchestration layer caches the decision.
    • denied — use-approval: user refused; agent must surface a hard error.
  • age_seconds — how long ago the request was created.

If the request_id does not exist (was swept), the response is an error: unknown request_id: <id>.

Polling pattern

loop:
  resp = secrets_poll_status(request_id)
  if resp.status.kind == "pending":
    sleep(2..5 seconds)
    continue
  break

handle resp.status.kind:
  ok        → proceed (e.g. retry the high-level tool that needed this secret)
  cancelled → ask the user "what now?" — retry, abandon, etc.
  expired   → tell the user the dialog timed out; offer a fresh request
  failed    → show resp.status.reason; suggest devboy secrets agent start
  once      → resolve once; do NOT cache the approval (use-approval only)
  session   → resolve and cache; further resolves of this path skip the dialog (use-approval only)
  denied    → surface a hard error; do NOT retry without user input (use-approval only)

Do not poll faster than once every 2 seconds — the dialog is no faster than a human types. A tight loop heats up CPU and telemetry without speeding anything up.


End-to-end scenario: provision + retry

User: "Use Jira to fetch the latest issues."

Agent: get_issues({"limit": 10})
       → ProviderError: missing token at team/<provider>/api-key

Agent thinks: secret missing, request provision.
Agent: secrets_describe({"path": "team/<provider>/api-key"})
       → { ... description, retrieval_url, capabilities_hint: "read", ... }

Agent: secrets_request_provision({"path": "team/<provider>/api-key"})
       → { "request_id": "prov-1f9b3a2c5e7d" }

Agent → User: "I've opened a dialog for the missing token. Paste the value
              from <retrieval_url>, then click Save."

# Agent polls every ~3 seconds
Agent: secrets_poll_status({"request_id": "prov-1f9b3a2c5e7d"})
       → { ..., "status": { "kind": "pending" }, "age_seconds": 12 }
... (repeat) ...
Agent: secrets_poll_status(...)
       → { ..., "status": { "kind": "ok" }, "age_seconds": 47 }

Agent → User: "Got it, retrying."
Agent: get_issues({"limit": 10})
       → 10 issues returned

Note: the agent never asks for the token's value. It only learns that a save happened; the token went from the dialog into the daemon, from the daemon into a proxy alias, and get_issues picked it up through the same proxy without the agent's involvement.


What is not on the agent surface

These tools do not exist and will not appear:

  • secrets_get / secret.get — handing the value to the agent. Replacement: a high-level provider tool with a proxy alias.
  • secrets_set / secret.put — writing directly from the agent's payload. Replacement: secrets_request_provision → user types it themselves.
  • secrets_export / dump — bulk export. Not in the architecture.

If a "convenience" tool seems like a shortcut, look again. The convenience of "passing the token directly" violates the invariant; the framework will not offer a workaround.

Operational tools that are NOT in the agent surface

For DevOps / setup scenarios where convenience outweighs agent-safety, separate CLI commands exist (locally only — never via MCP):

  • devboy secrets validate — manifest format check + live source probe.
  • devboy secrets rotate <path> — interactive rotation for a developer (P13.1).
  • devboy secrets ui — open the TUI/GUI inventory for manual review/edits (P12.2).
  • devboy secrets catalog list / validate — token-catalog inspection (P20.9 / P20.10).

These commands require a human at the terminal and are not callable through JSON-RPC. The agent can recommend the user run them, but cannot run them itself.


Protocol versioning

PROTOCOL_VERSION = "1.0" (see crates/devboy-storage/src/plugin_protocol.rs). Semantic breakdown:

  • Major bump (2.0) — reply fields removed/renamed, kind enum semantics not back-compatible. Old agents get an error trying to use the new server.
  • Minor bump (1.x) — new fields added (always optional), new status variants (the agent should treat unknown kind as failed), new tools in the secrets_* family.
  • Patch (1.x.y) — bugfix, no wire-format change.

Recommendation for agent code: parse responses tolerantly — ignore unknown fields, interpret unknown status.kind as failed. That gives compatibility with future minor versions without an agent update.


See also

  • onboarding.md — manifest + router setup, without which secrets_list returns nothing.
  • local-vault.md — where values physically live after a save from the dialog.
  • token-catalog.md — the JSON catalog that drives the dialog's "where to take from" / "how to validate" content.
  • catalog-url-sources.md — serving the catalog over the network with sha-pinning + audit log.
  • source-plugin-protocol.md — how to add your own source that the agent tools can see.
  • ADR-023 §3.7 — formal spec of the trust boundary and invariants.
  • crates/devboy-mcp/src/secrets_tool.rs + secrets_provision.rs — implementation source.