Files
PaperClipAI/doc/MCP-ACCESS-GOVERNANCE.md
T
DottaandPaperclip 9225796a4e fix(runner): page oversized assigned tool results (#16980)
## 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>
2026-10-20 11:52:59 -05:00

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

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) or local_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, or trust_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 application
  • connection — every catalog entry under one connection
  • catalog_entry — one specific tool
  • tool_name — pattern match on tool name (e.g. "list_*")
  • risk_level — read, write, or destructive

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:

  1. Catalog status: quarantined/disabled → immediate deny.
  2. Profile: not in effective set → deny.
  3. Policies in priority order. block short-circuits. require_approval short-circuits to an action request. rate_limit evaluates the counter.
  4. 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 signedArguments payload the approver sees verbatim,
  • a linked issue-thread request_confirmation interaction 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 starting or running past 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/decisions returns 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 matching tool_gateway.call_allowed / tool_gateway.call_denied entry to see how the approval resolved. Approver identity lands on the action request itself; once approved, the action request body shows resolvedByUserId and resolvedAt.

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:

  1. 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.
  2. 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_http is the preferred path. If you can replace a local stdio fixture with a remote_http endpoint, do.
  • Never set PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST on 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_failures is 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_limit policies 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.