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 everysecrets_*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
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
All arguments are optional. Filters AND-combine. include_internal defaults to false — internal paths (__sys/...) are hidden.
Response
Fields:
path— ADR-020 path (<scope>/<provider>/<purpose>).status—registered/expiring(≤14 days to expiry) /expired. Computed fromexpires_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) ornull.source_name— currently alwaysnull(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
Response
A strict superset of a secrets_list element plus extra metadata fields:
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
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 is an opaque string of the form prov-<12-hex>. Store it and poll via secrets_poll_status.
Lifecycle
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
Response
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
Fields in fields are all optional. Omitted fields are not proposed for change; they stay at their current value in the diff.
Response
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
Response
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
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
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
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_approvalis 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
Response
Fields:
request_id— echo of the request.path— the path the dialog originally opened on. Echoed for confirmation.kind—provision/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
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
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,
kindenum 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
kindasfailed), new tools in thesecrets_*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 whichsecrets_listreturns 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.