## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native agents receive assigned app-tool results through the durable Runner protocol. > - The assigned-tool bridge returned complete provider responses without a size bound. > - A large response could exceed the encrypted command limit after the upstream call had completed. > - The provider then waited for a result that the controller could not deliver. > - This pull request saves large governed responses and returns a small reference with a scoped page reader. > - Agents can retrieve the full result without repeating the original action. ## Linked Issues or Issue Description **What happened?** Large app-tool responses could leave native tool calls pending even though the upstream service completed. The durable command limit is about 510 KiB. The assigned-tool bridge did not account for that limit. A 720,048-byte regression response failed the delivery-size assertion before this change. **Expected behavior** Deliver a bounded response and a way to retrieve all saved content. Keep the original action at one execution. Preserve task, agent, company, responsible-user, and current-run access checks. **Steps to reproduce** 1. Run a native agent with an assigned tool that returns more than 510 KiB of JSON. 2. Let the upstream tool complete. 3. Observe that the original bridge returns the oversized object directly, so the durable controller cannot queue its result. Related: #14734 preserves uncertain tool outcomes across shutdown and restart. This change keeps that protection. #16517 handles native MCP content projection on a different transport; it does not bound native assigned-tool results. ## What Changed - Save governed assigned-tool responses above 500,000 encoded bytes using the existing response asset storage, quotas, and audit path. - Add `read_tool_result` for UTF-8 JSON pages from the same task and agent. Each encoded page is capped at 500,000 bytes, including JSON escaping and metadata, with remaining content available through the next byte offset. It works when generic API tools are disabled. - Reuse existing authenticated asset reads and responsible-user checks. Retrieval does not invoke the upstream service. - Preserve uncertain delivery if storage fails after an action completes. - Add regression, fault, authorization, and opt-in live Codex checks. Document the transport and access contract. ## Verification - Red: the original integration regression returned 720,048 bytes against unchanged production code and failed its bounded-response assertion. The follow-up regression also showed that a 480 KB result was unnecessarily saved under the original 24 KiB policy. - Green: results through 500,000 encoded bytes stay inline. Larger responses are saved in full. Real HTTP integration tests reconstruct the complete Unicode result after controller recreation in exactly two pages, in both standard and low-trust configurations, with one upstream execution. - The encoded-size boundary and escape-heavy Unicode tests verify that every page fits 500,000 bytes, cursors advance on valid UTF-8 boundaries, and concatenating pages exactly reconstructs the original JSON. Storage-failure tests preserve an uncertain outcome without repeating the action. - The focused unit/API run passes 1,957 tests; the changed real HTTP tests pass. `pnpm -r typecheck`, `pnpm build`, `node scripts/check-maps.mjs`, and `git diff --check` pass. - Real Codex acceptance passes with `gpt-6.1-sol` through Codex CLI 0.160.0 and native Runner, on macOS ARM64 with `low_trust_review` and generic API tools disabled. Codex requests every page in order, receives all 660,098 original bytes, and returns the final verification field. The two encoded pages are 500,000 and 160,543 bytes; the upstream operation executes once. Runner closes successfully in a 34.1-second probe. Only the disposable database's connection/worker counts were reduced to fit the host's remaining semaphore capacity. - The native-PRP regression also passes with escape-heavy Unicode pages over 1 MB in total and a separate inline result containing 200,000 backslashes. A scripted provider verifies both complete results by SHA-256. It proves the intermediate text wrapper can exceed 700 KB because the native transport decodes it before queuing the bounded PRP result; every result object stays within 500,000 encoded bytes. Native binaries are required for the local run, preventing a silent skip. - A second real-Codex probe with escaped Unicode, quotes, backslashes, newlines, and NULs also passes: every original byte of a 720,098-byte JSON response is retrieved in two encoded pages of 500,000 and 460,543 bytes. The model returns the final field, the upstream call executes once, and Runner closes in 32.6 seconds. This uses the same production source with only the synthetic fixture content varied. - The live probe exercises real PRP, persistence, and authenticated asset reads. It does not claim the original staging task resumed or full heartbeat checkpoint acceptance. - Earlier broader local testing passed 103 tests but could not start the second database for the existing `rejects a stale source-run question over public HTTP and accepts it from the next run` test. Extending its timeout exposed PostgreSQL initialization failure on this semaphore-exhausted host. The earlier local `pnpm test:run` attempt was interrupted and is not claimed as passing. Full CI passes on current head `cd32237ee5429765b8f349402ac3c79cceec3e6b`. GitHub reports 55 successful checks, two skipped Storybook checks, and one neutral Paperclip agent review. Greptile is 5/5 on this head with no review threads. - Additional Paperclip agent review is incomplete: it withdrew the earlier double-escaping finding after inspecting the native regression, found no new actionable defects, and reported stalled large-source reads prevented independent inspection of the transport decoder. Its neutral assessment is [recorded here](https://github.com/paperclipai/paperclip/pull/16980#issuecomment-6295579705). This limitation remains separate from the passing local native transport, real Codex acceptance, full CI, and Greptile results. ## Risks - Large responses now consume existing response-storage quota and require page reads. Larger pages use more provider context. Results are preserved in full; each encoded page leaves about 22 KiB below the PRP command limit for the command envelope. - A storage failure after execution retains an uncertain delivery outcome. The bridge must not report that the upstream action failed or invite a replay. - The dedicated reader grants access only to saved responses for the current task and agent. The asset route also checks the original responsible user. - Runner command limits and receipt rules remain unchanged. No migration or separate Runner protocol rollout is required. - This PR does not deploy the repair or resume an existing blocked task. ## Model Used OpenAI Codex (GPT-6), with reasoning, tool use, and code execution. The exact serving model ID and context-window size are not exposed in this session. The opt-in acceptance probe requests `gpt-6.1-sol` through Codex CLI 0.160.0. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass for the changed behavior (focused unit/API, real HTTP, and real Codex acceptance); the broader local database limitation is documented above - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
32 KiB
MCP Access Governance
Operator guide for Paperclip's MCP tool access surface. Audience: board users and CloudOps engineers who install connections, write policies, approve action requests, and respond to runtime alerts. For runtime alert response, pair this with MCP-RUNTIME-OPERATIONS.md.
Time-to-first-success: under 5 minutes if you follow Quick start and use the bundled example. Everything else is reference and how-to material that you read on demand.
Contents
- Mental model
- Canonical integration model
- Quick start
- Paperclip as MCP endpoint vs MCP gateway
- Managed connections
- Catalog and risk classification
- Profiles and bindings
- Scoped tool grants
- Policies
- Approval flow and trust rules
- Runtime slots
- Audit and the call event log
- Local trusted deployment
- Known limitations
- Reference
Mental model
Paperclip governs MCP tool access by separating four concerns:
┌────────────┐ ┌──────────────┐ ┌──────────┐ ┌────────────────┐
│Application │───▶│ Connection │───▶│ Catalog │ │ Profile │
│ (logical) │ │ (endpoint+ │ │ (tools │ │ (allow/deny │
│ │ │ transport) │ │ schema) │ │ entries) │
└────────────┘ └──────────────┘ └──────────┘ └────────┬───────┘
│ bound to
▼
agent / project / routine / issue / company
│
▼
┌──────────┐ policy ┌───────────┐ decision ┌──────────────┐
│ Agent │─────────▶│ Gateway │───────────▶│ Tool call │
│ tool call│ │ (policy │ │ result │
└──────────┘ │ engine) │ └──────────────┘
└─────┬─────┘
│
▼
┌──────────┐
│ Action │ human approve / reject
│ Request │ (UI / API)
└──────────┘
Plain prose version of the same graph:
- An Application is a logical grouping ("GitHub", "Linear", "Local todo fixture"). It owns one or more connections.
- A Connection is a single MCP endpoint. Transport is either
remote_http(preferred) orlocal_stdio(gated by trusted deployment). - A Catalog Entry is one tool discovered on a connection. Each entry carries a risk classification (
read,write,destructive) and a status (active,quarantined,disabled). - A Profile is a named bundle of allow/deny entries that picks which catalog entries an actor sees. Profiles attach to scopes via Bindings (
company,agent,project,routine,issue). - A Policy is an orthogonal rule applied at call time:
allow,block,require_approval,rate_limit, ortrust_rule. Deny beats allow. - The Gateway is the runtime that an agent calls. It walks profiles + policies, returns a decision, records a Call Event in the audit log, and (if needed) opens an Action Request for human approval.
If you remember one thing: profile says can this agent see the tool; policy says is this exact call allowed right now.
Canonical integration model
This runbook covers the MCP gateway slice of the broader Apps v2 integration model. Before adding a new provider, plugin-provided app, token store, or connection UX path, read Apps, Connections, and Integrations. That document records the board decision that Apps v2 is canonical, defines the shared vocabulary, and links the harvested v1 security threat model and first-30 connector matrix.
Quick start
The fastest path is the bundled example. From the Tools & Access UI (/<prefix>/companies/<companyId>/tools/examples), pick Safe read-only Todo / KV fixture and click Install. Then run Smoke. You can also do it via API:
# Install the example application + connection + profile in one call.
curl -fsS -X POST \
-H "Authorization: Bearer $BOARD_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/companies/$COMPANY_ID/tools/examples/safe-read-only-todo-kv/install" \
-d '{}' | jq .
# Run the bundled smoke check (validates an allowed read, a denied write, audit visibility).
curl -fsS -X POST \
-H "Authorization: Bearer $BOARD_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/companies/$COMPANY_ID/tools/examples/safe-read-only-todo-kv/smoke" \
-d '{}' | jq '{ok, checks: [.checks[] | {name, ok, decision, reasonCode}]}'
Expected: ok: true with three green checks: allow_read_tool, deny_write_tool, audit_written.
If the smoke fails, fix the failing check before introducing any production connection. The bundled fixture only depends on local code, so any failure is a control-plane problem rather than an upstream MCP issue.
Paperclip as MCP endpoint vs MCP gateway
Paperclip plays two roles in the MCP graph, and confusing them is the most common operator error.
Paperclip as MCP endpoint
(clients call Paperclip)
┌──────────────┐
Claude Code ─────▶ │ Paperclip │
IDE / CLI ─────▶ │ /mcp surface │
└──────────────┘
(task ops, agent ops)
Paperclip as MCP gateway / proxy
(agents call upstream MCP through Paperclip)
┌─────┐ ┌────────────┐ ┌──────────────┐
│Agent│──────▶│ Paperclip │──────▶│ Upstream MCP │
│ run │ │ gateway │ │ (GitHub etc.)│
└─────┘ │ + policy │ └──────────────┘
│ + audit │
└────────────┘
Endpoint mode — Paperclip exposes its own MCP surface so external clients (Claude Code, IDEs, scripts) can manipulate Paperclip tasks and agents. This is what doc/TASKS-mcp.md covers. Access control here is the standard Paperclip auth model (DEPLOYMENT-MODES.md): bearer keys, sessions, board API key.
Gateway mode — Paperclip proxies tool calls from a Paperclip agent to an upstream MCP server (GitHub, Linear, a local stdio fixture, etc.). Every call goes through profile selection, policy evaluation, optional human approval, rate limiting, redaction, and audit. This is what the rest of this document covers.
Operators usually mean gateway when they say "MCP access governance". For Paperclip-managed local adapter runs, Paperclip writes adapter MCP config that points at named gateway endpoints with short-lived scoped bearer tokens. Policies, approvals, and the audit log only exist for calls that enter gateway mode.
Native assigned-tool responses larger than 500,000 encoded bytes are saved after gateway
validation and redaction. The provider receives a bounded asset reference and
uses read_tool_result to retrieve UTF-8 JSON pages. The entire result is preserved:
each page includes the next byte offset until EOF.
The reader fits each encoded page within 500,000 bytes, including JSON escaping,
with space left for PRP framing. Reading the saved response
does not call the upstream tool again. The reader requires a currently authorized
run, the same task and agent, and the existing asset/responsible-user access checks.
It works when generic API tools are disabled. Storage uses the existing response
capture quotas and audit path. A failure to save a completed action remains an
uncertain delivery outcome; it must not invite an automatic repeat of the action.
Saved provider content remains untrusted.
Connected tool names reserve the mcp__paperclip-assigned__ provider prefix
within the 128-character limit. Short existing names stay compatible. Longer
names use a readable prefix and a stable hash of the connection, application,
and upstream tool identity; duplicate catalog entries also include their entry
identity. Gateway metadata retains the original upstream name for dispatch,
permissions, and audit. An alias never changes which connection executes a call.
V1 does not claim host-wide MCP enforcement. If an unmanaged external client, hand-edited adapter config, or process outside the Paperclip-controlled workspace calls an upstream MCP server directly, Paperclip can warn about known overlapping config entries but cannot prevent or audit that bypass. Treat managed MCP config as a control-plane containment feature for Paperclip-launched agents, not as an endpoint firewall for the operator's whole machine.
Managed connections
A connection is an enabled, governed link to one MCP server. Two transports are supported:
| Transport | When to use | Trust posture |
|---|---|---|
remote_http |
Hosted SaaS MCP servers (GitHub, Linear, custom remote MCP). Default for cloud. | Paperclip authenticates with stored credential refs and proxies calls. Process supervision is upstream's problem. |
local_stdio |
Local fixtures or approved stdio templates that must run as a child process. | Only allowed when the host is explicitly trusted; see Local trusted deployment. Cloud public deployments fail closed unless a trusted runtime host is configured. |
Operators do not paste arbitrary command / args for stdio. Allowed stdio entries are limited to the approved template catalog (e.g. paperclip.echo-calculator-time, paperclip.synthetic-todo-kv). To add a new template, ship a code change.
Create a connection (remote_http)
curl -fsS -X POST \
-H "Authorization: Bearer $BOARD_API_KEY" \
-H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/companies/$COMPANY_ID/tools/connections" \
-d '{
"applicationId": "'"$APPLICATION_ID"'",
"name": "Linear (remote)",
"transport": "remote_http",
"transportConfig": {
"url": "https://mcp.linear.app/mcp",
"headers": []
},
"credentialRefs": [
{ "name": "Authorization", "secretId": "'"$LINEAR_SECRET_ID"'", "placement": "header", "key": "Authorization", "prefix": "Bearer " }
],
"enabled": false
}' | jq '{id, name, transport, status, enabled, healthStatus}'
Connections are created enabled: false. Run a health check and a catalog refresh before flipping enabled: true.
Connection lifecycle
# Health check (no secrets in output)
curl -fsS -X POST -H "Authorization: Bearer $BOARD_API_KEY" -H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/tool-connections/$CONNECTION_ID/health-check" -d '{}' \
| jq '{connection: {healthStatus: .connection.healthStatus, healthMessage: .connection.healthMessage}}'
# Catalog refresh (pulls schema, sets risk levels, quarantines unexpected writes)
curl -fsS -X POST -H "Authorization: Bearer $BOARD_API_KEY" -H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/tool-connections/$CONNECTION_ID/catalog/refresh" -d '{}' \
| jq '{discoveredCount, quarantinedCount}'
# Enable
curl -fsS -X PATCH -H "Authorization: Bearer $BOARD_API_KEY" -H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/tool-connections/$CONNECTION_ID" \
-d '{"enabled": true, "status": "active"}' | jq '{id, enabled, status, healthStatus}'
# Disable (does not delete; preserves audit history)
curl -fsS -X PATCH -H "Authorization: Bearer $BOARD_API_KEY" -H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/tool-connections/$CONNECTION_ID" \
-d '{"enabled": false, "status": "disabled"}' | jq '{id, enabled, status}'
Connection statuses: draft, active, disabled, archived. Health statuses: ok, unchecked, degraded, failed, error, missing_secret.
Catalog and risk classification
Each tool discovered on a connection becomes a catalog entry with a risk level Paperclip infers from MCP annotations:
| Risk | Trigger | Default treatment |
|---|---|---|
read |
annotations.readOnlyHint: true or schema implies read-only |
Allowed by read-friendly profiles. |
write |
annotations.readOnlyHint: false or writeHint: true |
Requires approval by default unless the profile or a policy says otherwise. |
destructive |
annotations.destructiveHint: true |
Quarantined on first sight. Requires explicit operator action before any agent call can succeed. |
When a catalog refresh discovers a new write/destructive tool that did not exist on the prior schema, Paperclip sets status: quarantined and records the reason in quarantineReason. Quarantined entries are never returned to the agent's tool list until an operator reviews and re-enables them. This is the changed-tool quarantine rule and the primary defense against an upstream server silently adding a destructive verb.
To inspect and re-enable a quarantined entry, use the UI Catalog view, or PATCH the entry's status via the catalog routes (see Reference).
Profiles and bindings
A profile is a named bundle of allow/deny rules over the catalog. It does not, by itself, attach to anyone. Bindings put profiles on actors.
Example: create a profile that allows only read-only tools and bind it to a project.
# Profile with default-deny and one include entry for read-only catalog entries.
curl -fsS -X POST -H "Authorization: Bearer $BOARD_API_KEY" -H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/companies/$COMPANY_ID/tools/profiles" \
-d '{
"profileKey": "engineering.read-only",
"name": "Engineering read-only",
"defaultAction": "deny",
"entries": [
{ "selectorType": "risk_level", "selectorValue": "read", "effect": "include" }
]
}' | jq '{id, name, defaultAction}'
# Bind it to a project so every agent run in that project gets this profile.
curl -fsS -X POST -H "Authorization: Bearer $BOARD_API_KEY" -H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/companies/$COMPANY_ID/tools/profiles/$PROFILE_ID/bind" \
-d '{ "targetType": "project", "targetId": "'"$PROJECT_ID"'", "priority": 10 }' | jq .
Selector types:
application— every catalog entry under an applicationconnection— every catalog entry under one connectioncatalog_entry— one specific tooltool_name— pattern match on tool name (e.g."list_*")risk_level—read,write, ordestructive
Effects: include adds to the allowed set, exclude removes. defaultAction is the fallback when no entry matches.
Binding scopes, narrowest first: issue > routine > agent > project > company. The effective profile for an agent is computed at session time and cached on the gateway session. To preview:
curl -fsS -H "Authorization: Bearer $BOARD_API_KEY" \
"$PAPERCLIP_URL/api/companies/$COMPANY_ID/tools/profiles/effective/agents/$AGENT_ID" \
| jq '{profileIds, allowedToolNames}'
Scoped tool grants
A stored tool grant can restrict access with scope.allow and tool selectors.
An explicit allow must contain a matching tool:<name>, connection:<id>, or
application:<id>. Tool names can use the gateway name or the upstream name.
If the scope also includes selectors, those selectors must match as well.
For example, {"allow":["tool:read_item"],"connectionId":"<connection-id>"}
allows that tool only on the selected connection. A matching allow entry does
not override a mismatched connection selector.
Unknown selector names, malformed values, empty restrictions, and non-object
scopes deny access. Singular selectors require a nonempty string. Plural
selectors require a nonempty array of nonempty strings. The supported selector
names are actorType, agentId, projectId, routineId, issueId, gatewayId,
applicationId, connectionId, catalogEntryId, applicationKey,
providerType, toolName, and riskLevel, plus their plural forms.
A null scope or an empty object preserves the existing unrestricted grant behavior. Discovery and execution evaluate the current grant. A revoked grant cannot authorize a later call merely because discovery previously listed it.
Policies
Policies run after profile selection. A profile decides can this agent see the tool; a policy decides is this exact call allowed right now. Policy types:
| Type | Effect |
|---|---|
allow |
Explicit allow for matching selectors. Adds positive evidence; does not override a block. |
block |
Deny matching calls. Deny always beats allow. |
require_approval |
Force human approval for matching calls; opens an action request. |
rate_limit |
Apply a sliding-window counter. Match → consume; over limit → rate_limited. |
trust_rule |
Approval-derived allow rule scoped to specific argument shapes. See Approval flow and trust rules. |
Order of evaluation:
- Catalog status:
quarantined/disabled→ immediate deny. - Profile: not in effective set →
deny. - Policies in priority order.
blockshort-circuits.require_approvalshort-circuits to an action request.rate_limitevaluates the counter. - If no policy matched and the profile allowed the tool:
allow.
To dry-run a policy decision without making a real call. The dry-run endpoint takes a structured { companyId, actor, request, runContext? } body and returns the decision under .decision:
curl -fsS -X POST -H "Authorization: Bearer $BOARD_API_KEY" -H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/companies/$COMPANY_ID/tools/policy/test" \
-d '{
"companyId": "'"$COMPANY_ID"'",
"actor": {
"actorType": "agent",
"actorId": "'"$AGENT_ID"'",
"agentId": "'"$AGENT_ID"'"
},
"request": {
"toolName": "create_item",
"arguments": { "title": "test" }
}
}' | jq '{decision: .decision.decision, matchedPolicyIds: .decision.matchedPolicyIds, reasonCode: .decision.reasonCode}'
Decisions: allow, deny, require_approval, rate_limited, defer_runtime. defer_runtime means the policy engine asked the gateway to consult runtime state (e.g. slot availability) before producing the final verdict.
Approval flow and trust rules
When a call resolves to require_approval, the gateway opens an Action Request carrying:
- the agent, run, and tool identity,
- a canonical hash of the arguments (so we can match later trust rules),
- a
signedArgumentspayload the approver sees verbatim, - a linked issue-thread
request_confirmationinteraction for the in-app card, - an expiry.
The gateway responds to the agent's tool call with HTTP 409, reasonCode: "approval_required", and the new actionRequestId in the body. The agent's run is paused on this exact call until a decision lands. Once approved, the agent retries the same tool call with approvedActionRequestId set; the gateway re-validates that the canonical arguments hash matches and then executes the tool.
After approval, the operator can promote that approval into a trust rule: a policy of policyType: trust_rule that allows the same tool with the same argument shape for the same actor scope, optionally for a limited number of approvals or until an expiry. This is how you avoid clicking Approve on every safe repetition of the same action.
# Approve via API (UI does the same). Approval requires companyId — body or query.
curl -fsS -X POST -H "Authorization: Bearer $BOARD_API_KEY" -H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/tool-gateway/action-requests/$ACTION_REQUEST_ID/approve" \
-d '{ "companyId": "'"$COMPANY_ID"'" }' | jq '{id, status, resolvedAt, resolvedByUserId}'
# Retry the original tool call with approvedActionRequestId (the agent does this).
curl -fsS -X POST -H "X-Paperclip-Tool-Gateway-Token: $GATEWAY_TOKEN" -H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/tool-gateway/tools/call" \
-d '{
"tool": "create_item",
"parameters": { "title": "Approved item" },
"approvedActionRequestId": "'"$ACTION_REQUEST_ID"'"
}' | jq '{invocationId, status, tool, result}'
# Promote the approval to a trust rule
curl -fsS -X POST -H "Authorization: Bearer $BOARD_API_KEY" -H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/companies/$COMPANY_ID/tools/action-requests/$ACTION_REQUEST_ID/trust-rule" \
-d '{
"approvalThreshold": 2,
"expiresAt": "2026-09-01T00:00:00.000Z"
}' | jq '{id, policyType, priority, config: {trustRule: .config.trustRule}}'
# Revoke a trust rule (audit-safe; does not delete)
curl -fsS -X POST -H "Authorization: Bearer $BOARD_API_KEY" -H "Content-Type: application/json" \
"$PAPERCLIP_URL/api/companies/$COMPANY_ID/tools/trust-rules/$POLICY_ID/revoke" \
-d '{ "reason": "Catalog schema changed." }' | jq '{id, enabled, config: {revokedAt: .config.trustRule.revokedAt}}'
Trust rules carry the catalog and schema hashes captured at approval time. If the upstream tool changes its schema or the canonical argument hash drifts, the trust rule stops applying — the next matching call falls back to require_approval. The retry-with-approvedActionRequestId flow enforces the same invariant for a single approval: change the arguments between approval and retry and the call fails with reasonCode: "signed_arguments_mismatch". This is intentional: an approval is for a specific argument shape, not for "future versions of this tool, sight unseen".
In v1, trust-rule promotion is deliberately narrow: the server derives the reviewed actor/tool scope from the approved invocation and stores the exact reviewed argument hash. Promotion requests that try to widen the scope or replace exact-hash matching with broader argument predicates are rejected. Broader trust authoring needs a separately governed mechanism.
Runtime slots
Local stdio connections run as supervised child processes. Each process is a runtime slot with a lifecycle: stopped → starting → running → idle → (stopped|failed).
You don't normally touch slots. They're spun up on first call and evicted after idle expiry. You only touch them when:
- a slot is stuck
startingorrunningpast 5 minutes, - restart suppression has fired (too many restart attempts),
- a connection is being decommissioned and you need to free the process.
Day-to-day runtime response — health summary, stuck-slot diagnosis, stop/restart, restart-storm playbook — lives in MCP-RUNTIME-OPERATIONS.md. Read that doc when paged.
Cloud reminder: in authenticated/public, local stdio slots fail closed unless PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST is set on a worker explicitly designated to supervise local processes. Set it on one worker, not on the API edge.
Audit and the call event log
Every tool invocation lands in the call event log (tool_call_events). Each event records:
- decision (
allow,deny,require_approval,rate_limited), - matched policy IDs,
- reason code (
deny_default,deny_policy_block,quarantined_catalog_entry,missing_secret, etc.), - redaction plan applied to arguments and results,
- latency,
- final outcome (
success,pending,denied,failure,timeout).
To pull recent audit:
curl -fsS -H "Authorization: Bearer $BOARD_API_KEY" \
"$PAPERCLIP_URL/api/tool-gateway/audit?companyId=$COMPANY_ID&limit=100" \
| jq '[.[] | {createdAt, action: .action, decision: .details.decision, tool: .details.tool, outcome: .details.outcome, reasonCode: .details.reasonCode}]'
Two practical patterns:
- Per-run timeline:
GET /api/companies/:companyId/tools/runs/:runId/decisionsreturns every policy decision attached to a single agent run. Use this in QA to prove an agent never touched a denied tool. - Approval audit: the audit log itself is the approval-request ledger — filter the gateway audit response for
action == "tool_gateway.approval_requested"to get the queue, and pair each row with the matchingtool_gateway.call_allowed/tool_gateway.call_deniedentry to see how the approval resolved. Approver identity lands on the action request itself; once approved, the action request body showsresolvedByUserIdandresolvedAt.
Audit is the source of truth for the security memo. It is intentionally append-only — there is no edit or delete route.
Local trusted deployment
Local stdio MCP connections introduce a different trust model from remote_http: Paperclip is running a child process under its own credentials. Two questions decide whether this is safe in your deployment:
- Who controls the host? If the operator and the host are the same human (developer laptop, internal CI runner), you can opt into local stdio. If the host is multi-tenant (shared cloud worker), you must not.
- Who controls the template? Only approved templates baked into the Paperclip build can run. Agents cannot supply arbitrary
command/args.
The decision matrix:
| Deployment mode | Local stdio default | When to enable |
|---|---|---|
local_trusted |
Available, used for fixtures and developer flows | Always; this mode exists for it. |
authenticated/private (Tailnet/VPN/LAN) |
Available with explicit opt-in | When the operator has root on the host and trusts the template list. |
authenticated/public (internet-facing) |
Fail closed | Only when one worker is designated trusted by setting PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST and is isolated from the public-facing edge. |
In all modes:
remote_httpis the preferred path. If you can replace a local stdio fixture with a remote_http endpoint, do.- Never set
PAPERCLIP_TRUSTED_MCP_RUNTIME_HOSTon the same worker that serves public HTTP traffic. - Treat the approved-template list as a code-review surface: a PR that adds a new template ships a new code-execution path.
For deployment mode and bind semantics generally, see DEPLOYMENT-MODES.md.
Known limitations
These are intentional gaps as of the MCP Access Governance v1 launch. Track or work around as noted.
- Audit-write failures are production incidents.
mcp_runtime_audit_write_failuresis backed by the durable runtime metric counter and fires when MCP audit-event persistence fails. Treat any firing alert as a control-plane incident — page CloudOps and freeze tool calls until audit durability is restored. - No CLI surface for tool access yet. Connections, profiles, policies, approvals, and trust rules are managed via the UI and the REST API only. There is no
paperclipai tool ...subcommand. - No bulk catalog review. When an upstream server adds many new tools at once, you review each quarantined entry individually. Bulk operations are planned but not in v1.
- Trust rules match exact argument shapes only. A trust rule built from one approval covers calls whose canonical argument hash matches and whose catalog schema hash is unchanged. Wildcards and structural filters across the rest of the schema are not supported in v1.
- Rate limits are per-policy. Rate limit counters are scoped to the matching policy and counter key. There is no cross-policy aggregation (e.g. "300 requests/hour across all GitHub policies"). Operators who need that wire two
rate_limitpolicies and accept the additive behavior. - Action request expiry is fixed by policy. The approval card carries a server-set expiry; the human approver cannot extend it from the UI. If a request expires before approval, the agent must retry the tool call.
- Endpoint mode (Paperclip as MCP server) is not policy-governed. Tool access governance applies only to gateway mode — Paperclip's own MCP endpoint surface (
/mcp) uses standard Paperclip auth and is not subject to the profile/policy stack. - No multi-region runtime supervisor. Local stdio slots run on the worker that serves the request that started them. If you scale workers, slots do not migrate. Plan capacity per worker, not per cluster.
Reference
| Surface | Path / endpoint | Notes |
|---|---|---|
| UI overview | /<prefix>/companies/<companyId>/tools |
All tabs: Overview, Examples, Applications, Connections, Profiles, Policies, Runtime, Audit. |
| Examples | POST /api/companies/:companyId/tools/examples/:id/install and …/smoke |
Bundled fixtures for first-run validation. |
| Applications | GET|POST /api/companies/:companyId/tools/applications, PATCH /api/tool-applications/:id |
Logical groupings. |
| Connections | GET|POST /api/companies/:companyId/tools/connections, GET|PATCH|DELETE /api/tool-connections/:id |
POST …/health-check, POST …/catalog/refresh, GET …/catalog for lifecycle. |
| Profiles | GET|POST /api/companies/:companyId/tools/profiles, PATCH /api/tool-profiles/:id |
Entries: POST /api/tool-profiles/:id/entries, PATCH|DELETE /api/tool-profile-entries/:id. |
| Bindings | POST /api/companies/:companyId/tools/profiles/:id/bind and …/unbind |
Targets: company, agent, project, routine, issue. |
| Effective profile | GET /api/companies/:companyId/tools/profiles/effective/agents/:agentId |
Use for QA proofs and debugging selector misses. |
| Policies | GET|POST /api/companies/:companyId/tools/policies, PATCH|DELETE /api/companies/:companyId/tools/policies/:id |
Types: allow, block, require_approval, rate_limit, trust_rule. |
| Policy dry-run | POST /api/companies/:companyId/tools/policy/test |
Structured { companyId, actor, request, runContext? } body; decision returned under .decision. |
| Gateway sessions | POST /api/tool-gateway/sessions, POST /api/tool-gateway/sessions/:sessionId/revoke |
Board callers must supply companyId, agentId, runId to create and companyId to revoke; agent JWTs auto-fill from the token. Revocation invalidates the session immediately and emits tool_gateway.session_revoked without logging the raw session token. |
| Gateway calls | POST /api/tool-gateway/tools/call |
X-Paperclip-Tool-Gateway-Token header; body uses tool + parameters. Approval-required calls respond 409 with reasonCode: approval_required and an actionRequestId; the agent retries with approvedActionRequestId. |
| Action requests | POST /api/tool-gateway/action-requests/:id/approve |
Requires companyId (body or query). Listing is via the audit log: filter for tool_gateway.approval_requested. |
| Trust rules | POST /api/companies/:companyId/tools/action-requests/:id/trust-rule, POST /api/companies/:companyId/tools/trust-rules/:id/revoke |
Approval-derived allow policies. |
| Runtime health | GET /api/companies/:companyId/tools/runtime-health |
Alerts and metrics. Pair with MCP-RUNTIME-OPERATIONS.md. |
| Runtime slots | GET /api/companies/:companyId/tools/runtime-slots, POST /api/companies/:companyId/tools/runtime-slots/:id/stop|restart |
Process supervision. |
| Audit | GET /api/tool-gateway/audit?companyId=…&limit=…, GET /api/companies/:companyId/tools/runs/:runId/decisions |
Call event log. |
| Stdio templates | GET /api/companies/:companyId/tools/stdio-templates |
Approved local stdio template IDs only. |
| Bulk import preview | POST /api/companies/:companyId/tools/mcp/import-json |
Inspect a discovery JSON without persisting anything. |
| Demo script | MCP-DEMO-SCRIPT.md | Walks read / approval-gated write / denied flows end-to-end. |
| Runtime runbook | MCP-RUNTIME-OPERATIONS.md | Alerts, stuck slots, recovery. |
| Deployment modes | DEPLOYMENT-MODES.md | Auth, exposure, bind. |