# Observations, actions, and approval queues

> The read/write split that makes asynchronous human-in-the-loop work: `ObservationDescription` (including `prohibitAllSharing`), `ActionDescription` and `ActionKind`, the `ObservationAuthorizer` and `ApprovalQueue` interfaces, simulated results while an action is pending, and `ActionState` transitions. Includes the MCP trust boundary where `readOnlyHint` decides observation versus queued action.

- Repository: cloudflare/cloudflare-os
- GitHub: https://github.com/cloudflare/cloudflare-os
- Human docs: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd
- Complete Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/llms-full.txt

## Source Files

- `packages/workshop-shared/src/gatekeeper.ts`
- `packages/mcp-shared/src/tools.ts`
- `packages/mcp-shared/src/action-store.ts`
- `packages/workshop-backend/src/auto-approval.ts`
- `packages/workshop-shared/src/api.ts`
- `packages/mcp-shared/README.md`

---

---
title: "Observations, actions, and approval queues"
description: "The read/write split that makes asynchronous human-in-the-loop work: `ObservationDescription` (including `prohibitAllSharing`), `ActionDescription` and `ActionKind`, the `ObservationAuthorizer` and `ApprovalQueue` interfaces, simulated results while an action is pending, and `ActionState` transitions. Includes the MCP trust boundary where `readOnlyHint` decides observation versus queued action."
---

Every gatekeeper call a gadget makes is classified as either a read (an observation, executed immediately and recorded) or a write (an action, staged for approval before dispatch). In `packages/mcp-shared`, `classifyTool()` is the single place where this decision is made, and `packages/workshop-backend/src/auto-approval.ts` is where a staged action can be drained without a prompt. The lifecycle of a staged call lives in `ActionStore` (`packages/mcp-shared/src/action-store.ts`), which persists to a facet-local SQLite table and prefers losing a result over repeating a write.

## The read/action split

`ClassifiedTool` in `packages/mcp-shared/src/tools.ts` carries the decisions the gatekeeper made about one tool:

```ts
export type ClassifiedTool = {
  tool: McpTool;
  // `read` runs immediately and is recorded as an observation; `action` goes to the queue.
  mode: "read" | "action";
  // Whether the deployment may let this action through without a prompt.
  autoApprovable: boolean;
  // Whose word `mode` rests on.
  classifiedBy: ClassificationSource;
};
```

`ClassificationSource` is `"server-annotation" | "default"`. It is recorded rather than re-derived so no consumer can answer "who decided this was a read?" differently from the classifier that decided it. `toolInfo()` carries `classifiedBy` through to the gadget-facing `McpToolInfo`, so an audit can find every call that was trusted on the server's word.

<Note>
Nothing outside `packages/mcp-shared/src/tools.ts` reads a tool's `annotations`. This is stated both in the module header and in the package README's module table.
</Note>

## The MCP trust boundary

`classifyTool(tool, trust)` reduces an MCP server's self-description to a policy decision:

```ts
export function classifyTool(tool: McpTool, trust: ServerTrust): ClassifiedTool {
  const annotations = tool.annotations ?? {};
  const readOnly = isDeclaredReadOnly(tool);

  const autoApprovable = !readOnly
    && trust === "vetted"
    && annotations.destructiveHint === false
    && annotations.idempotentHint === true;

  return {
    tool,
    mode: readOnly ? "read" : "action",
    autoApprovable,
    classifiedBy: readOnly ? "server-annotation" : "default",
  };
}
```

`isDeclaredReadOnly()` tests `tool.annotations?.readOnlyHint === true`. Every hint is compared with `=== true` or `=== false` rather than for truthiness, so an unannotated tool is an action, needs approval, and can never auto-apply — matching the MCP spec's own defaults (`readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`).

### Trust tiers

`ServerTrust` is `"vetted" | "byo"`. It governs trust in annotations only, is deployment configuration rather than account state, and is read afresh at each point of use, so withdrawing it takes effect without a reconnect.

| Tier | Endpoint provenance | `readOnlyHint` classifies reads | Annotations may drive auto-approval |
| --- | --- | --- | --- |
| `byo` | a user typed the URL in | yes | no |
| `vetted` | a deployment asserted the annotations are reliable | yes | yes (`destructiveHint: false` and `idempotentHint: true`) |

Honouring `readOnlyHint` on `byo` is documented as a knowing tradeoff, not a free win: a tool the server mislabels runs with no approval, where an unlabelled one would have been queued. It is accepted because prompting on every read makes the connector unusable for its main purpose, and because the owner chose to connect the server. Auto-applying a write is not accepted on those terms and additionally requires a vetted endpoint, so the deployment casts the deciding vote.

<Warning>
Configuring an endpoint does not by itself earn `vetted`. A portal aggregates upstream servers whose annotations the administrator never saw, which is why `gatekeeper-mcp-portal` defaults to `byo` and requires `MCP_PORTAL_TRUST_ANNOTATIONS=true`.
</Warning>

An account records `provenance` (`"user"` or `"deployment"`) instead of a tier, settled when it connects. Provenance decides whether a server may rename itself over an administrator's chosen label in an approval prompt — a question that should not move when an annotation setting does.

```mermaid
flowchart TD
  subgraph gk["MCP gatekeeper (byo or vetted)"]
    tool["McpTool + annotations"] --> classify["classifyTool()<br/>tools.ts — sole reader of annotations"]
  end
  classify -->|"mode: read"| obs["Runs immediately<br/>recorded as an observation"]
  classify -->|"mode: action"| store["ActionStore.stage()<br/>action-store.ts"]
  subgraph queue["Approval queue"]
    store --> pending["state: pending"]
    pending -->|"discard()"| gone["row deleted"]
    pending -->|"apply()"| applying["state: applying (claimed)"]
  end
  classify -->|"autoApprovable: true"| drain["AutoApprovalDrainer.drain()<br/>workshop-backend/auto-approval.ts"]
  drain -->|"requires enabled AutoApproveTagRecord"| pending
  applying --> settled["applied / failed"]
```

## Scoping an approval to a tool

`actionKindFor()` builds the approval-policy identity of one tool on one binding:

```ts
export function actionKindFor(scopeTag: string, toolName: string): ActionKind {
  return { tag: `${encodeURIComponent(scopeTag)}:${encodeURIComponent(toolName)}`, label: toolName };
}
```

`ActionKind` is imported from `@gadgets/workshop-shared/gatekeeper`. `scopeTag` is caller-supplied so that two connectors using the same binding id cannot share pre-approvals. Both components are percent-encoded before being joined, so a tool name containing `:` cannot forge another tool's tag.

## Detecting a catalog that changed under you

`catalogRevision(tools)` produces a stable 16-hex-character fingerprint (`SHA-256`, truncated) over each tool's name plus every claim a grant was decided against:

```ts
function policyClaims(tool: McpTool): string {
  return [
    isDeclaredReadOnly(tool) ? "r" : "w",
    claimChar(tool.annotations?.destructiveHint),
    claimChar(tool.annotations?.idempotentHint),
  ].join("");
}
```

`claimChar()` is tri-state (`"1"` / `"0"` / `"-"`), so a server starting or stopping making a claim is visible even where both lead to the same decision today. Descriptions are excluded so copy edits do not fire the signal.

## Action state transitions

The `mcp_actions` table constrains `state` to `'pending' | 'applying' | 'applied' | 'rejected' | 'failed'`, with a `STRICT` table and `json_valid` checks on `args_json` and `result_json`.

```mermaid
stateDiagram-v2
  [*] --> pending: stage()
  pending --> [*]: discard() (row deleted)
  pending --> applying: apply() claims + persists claimedAt
  applying --> applied: call returned; state settled before result attached
  applying --> failed: call threw
  applying --> failed: activation died / APPLY_CLAIM_TIMEOUT_MS<br/>retryable = 0
  failed --> applying: apply() again, only if retryable !== false
  rejected --> [*]
  note right of failed
    retryable = !callMayHaveTakenEffect(err)
  end note
```

`apply(id, call, log)` rejects re-entry explicitly before claiming:

| Stored state | `apply()` behavior |
| --- | --- |
| `applied` | returns immediately (idempotent no-op) |
| `rejected` | throws `MCP action <id> was already rejected.` |
| `failed` with `retryable === false` | throws the stored `error`, or `MCP action <id> cannot be retried.` |
| `applying` | throws `MCP action <id> is already being applied.` |
| unknown id | throws `MCP action <id> is unknown.` |

## At-most-once application

The guarantee is *at most once*, not exactly once. MCP has no idempotency key that would make a repeated call harmless and no inverse operation that would undo one, so where the two conflict the store prefers losing a result over repeating a write.

<Steps>
<Step title="Claim before I/O">
`apply()` sets `state = "applying"`, stamps `claimedAt = Date.now()`, clears `error` and `result`, and persists — before the call is sent. This is what stops two concurrent `applyAction` calls from both reaching the server.
</Step>
<Step title="Settle before attaching the result">
Once the call returns, `state` is set to `"applied"` in its own small write *before* the result is attached, so nothing about handling a server-controlled payload — normalizing it, encoding it, or finding it too large for the Durable Object to store — can lose the fact that the write already happened.
</Step>
<Step title="Never release a stale claim">
The `ActionStore` constructor runs on every fresh Durable Object activation and closes any persisted `applying` row: `UPDATE mcp_actions SET state = 'failed', retryable = 0, error = ?` with `APPLY_OUTCOME_UNKNOWN_MESSAGE`. The claim is not released for another attempt; after `APPLY_CLAIM_TIMEOUT_MS` an action is closed the same way.
</Step>
</Steps>

### Failure classification

Failures are split by what the server is known to have done, because the caller cannot work that out afterwards. `callMayHaveTakenEffect(err)` fails safe: anything it cannot positively identify as declined counts as possibly performed.

| Outcome | `retryable` | Recorded `error` |
| --- | --- | --- |
| Refused before dispatch (`401`, `403`) | `true` | the underlying error message |
| Generic HTTP / JSON-RPC error, dropped connection, malformed reply, oversized body | `false` | "This call failed after it had been sent, so it may or may not have taken effect. Check the server before staging it again." |
| Activation died between send and reply, or claim expired | `false` | `APPLY_OUTCOME_UNKNOWN_MESSAGE` |

The log event is `action.apply.outcome-unknown` when the call may have landed and `action.apply.failed` when it was declined, both with `actionId`, `toolName`, and `error`.

## Staging limits

Fixed rather than configurable.

| Constant | Value | Enforced in |
| --- | --- | --- |
| `MAX_ARGUMENT_BYTES` | 64 KiB | `stage()` — throws `MCP tool arguments are too large (maximum 65536 bytes).` |
| `MAX_PENDING_ACTIONS` | 50 | `stage()` — counts rows in `('pending', 'applying')` |
| `MAX_RESULT_BYTES` | 128 KiB | `apply()` — oversized results are replaced with an `status: "ok"` placeholder |
| `MAX_RETAINED_ACTIONS` | 100 | `#prune()` |
| `MAX_TOOLS_PER_SERVER` | 200 | `tools.ts` |
| Catalog size | 96 KiB UTF-8 | `client.ts` — leaves room below the Durable Object 128 KiB per-value limit |
| `MAX_DESCRIPTION` | 600 chars | approval-prompt rendering |
| `MAX_ARGUMENTS` | 4000 chars | approval-prompt rendering |

`stage()` also round-trips arguments through `JSON.stringify` / `JSON.parse` and rejects `null` or arrays, throwing `MCP tool arguments must be JSON-compatible.` The pending-queue message is: `50 calls to this MCP server are already awaiting approval. Wait for them to be approved or rejected before queueing more.`

<Warning>
Approval prompts reproduce server-supplied text. `defuseFences()` rewrites runs of three or more backticks to `'''` before the text is placed inside a fence — without it a tool description can close the fence and continue in the prompt's own voice, writing its own "Endpoint:" line and arguing the server's case.
</Warning>

## Auto-approval drain

`AutoApprovalDrainer` in `packages/workshop-backend/src/auto-approval.ts` applies eligible pending actions in ascending id order, over a storage shape of two typed collections:

```ts
export interface AutoApprovalStorage {
  actions: Collection<ActionRecord, number>;
  autoApproveTags: Collection<AutoApproveTagRecord>;
}
```

Eligibility requires **both** signals:

<ParamField body="record.description.autoApprovable" type="boolean" required>
Must be `=== true`. This is the author's verdict on the action, ultimately sourced from `classifyTool()` on a `vetted` endpoint.
</ParamField>

<ParamField body="autoApproveTags[`${gatekeeperId}:${tag}`]" type="AutoApproveTagRecord" required>
A user-enabled rule for `record.description.actionKind?.tag` on this gatekeeper. If the action has no `actionKind`, no rule can match.
</ParamField>

Ordering rules the drainer preserves:

- The first pending action that is **not** auto-eligible is a manual gate: the drain `break`s rather than skipping ahead, so nothing is silently applied past a human gate.
- An action that throws while applying is left `pending` for manual handling and also stops the drain. The failure is logged as `auto.approval.failed` with `actionId` under the `workshop.auto.approval` logger.
- `applyPendingAction(fresh, rule.enabledBy, true)` attributes the auto-approval to the `AiChatAuthorInfo` of the user who enabled the rule — it runs under their authority.

Concurrency is handled by a per-gatekeeper single-flight map, because the Durable Object input gate is open across the `apply` await:

```ts
async drain(gatekeeperId: number): Promise<void> {
  if (this.#draining.has(gatekeeperId)) {
    this.#draining.set(gatekeeperId, true);  // ask the running drain to loop again
    return;
  }
  this.#draining.set(gatekeeperId, false);
  try {
    do {
      this.#draining.set(gatekeeperId, false);
      await this.#drainOnce(gatekeeperId);
    } while (this.#draining.get(gatekeeperId));
  } finally {
    this.#draining.delete(gatekeeperId);
  }
}
```

`#drainOnce()` materializes a snapshot with `[...this.storage.actions.list()]` first, because `list()` is a lazy generator over storage and the actions collection is mutated as the drain proceeds. Immediately before applying, it re-reads `this.storage.actions.get(record.id)` and skips the record unless it is still a pending `"action"` — a guard against a concurrent drain having already taken it. `applyPendingAction` is injected, which keeps the drainer constructible over mock storage in tests.

## Result shape a gadget sees

`toCallResult()` flattens MCP content into the gadget-facing `McpCallResult`:

```ts
{
  status: "ok",
  content,                        // the raw McpContentBlock[]
  text,                           // text blocks joined with "\n"
  structuredContent: result.structuredContent,
  isError: result.isError,
}
```

When the encoded result exceeds `MAX_RESULT_BYTES`, `apply()` stores a `status: "ok"` record with `content: []` and a `text` note that the server's response was too large to retain — the action still settles as `applied`.

## Sharing

Neither trust tier can be shared. A gadget bound to any MCP endpoint is owner-only, enforced by `packages/mcp-shared/src/sharing-policy.ts` (listed in the module table as "The owner-only sharing rule"), for reasons unrelated to annotation provenance.

## Related pages

<CardGroup cols={2}>
<Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
The interfaces every connector implements, including `VendorDescription`, `AccountDescription`, and resource URL-pattern matching.
</Card>
<Card title="Sharing, roles, and observer re-verification" href="/sharing-and-observers">
Collaborator roles, the `use` allowlist, and how a failing observer re-check blocks new observations.
</Card>
<Card title="Build a gatekeeper" href="/build-a-gatekeeper">
Adding a connector package: vendor entrypoint, Durable Object classes and migrations, and structured logging.
</Card>
<Card title="Environment variables" href="/environment-variables">
Deployment configuration including `MCP_PORTAL_URL` and `MCP_PORTAL_TRUST_ANNOTATIONS`.
</Card>
</CardGroup>
