# Govern Claude Code and Cursor

> Put every tool call a coding agent makes behind the gate. The MCP proxy holds the tool's credential and the agent connects only to Immiscible, so there is no path to the tool that skips the decision.

Source: https://immiscible.fly.dev/docs/guides/mcp-proxy

A gate an agent can choose not to call is advice. This guide closes that gap for coding agents in three layers, strongest first:

1. **The MCP proxy.** Immiscible sits between the agent and each tool server and holds the tool's credential. The agent never has it, so it cannot reach the tool any other way.
2. **The Claude Code hook.** Claude Code runs a command before every tool call, including its built-in shell and file tools. The model cannot skip it.
3. **The gateway.** The agent's model traffic goes through Immiscible too, so what entered its context is observed, not just declared. See [route model traffic](https://immiscible.fly.dev/docs/guides/gateway.md).

## How a proxied call flows

1. The agent connects to `https://immiscible.fly.dev/mcp/proxy/<upstream id>` with its agent key or an OAuth access token. Streamable HTTP, JSON-RPC 2.0, protocol `2025-06-18`.
2. `initialize` is answered by Immiscible from what the upstream advertised when it was registered. No upstream round trip, no credential used.
3. `tools/list` returns the upstream's tools filtered twice: to the tools an owner or admin allowed, and to what this agent's mandates could authorise at all.
4. Every `tools/call` becomes an action request and goes through the gate:
   - **allow**: forwarded once, with the credential injected into the outbound request only. Any echo of the credential in the answer is removed.
   - **approval_required**: nothing is sent. The agent gets a tool result saying a person has been asked, with the link. Once approved, the same call with the same arguments goes through, exactly once.
   - **deny**: nothing is sent. The agent gets JSON-RPC error `-32003` with the reasons in plain English.
5. The outcome settles the action: `completed`, or `failed` if the upstream timed out or errored. An upstream error never turns into a retry or an allow.

## 1. Register the upstream

Owners and admins register tool servers once per workspace. The credential is sealed with AES-256-GCM, bound to the workspace and the upstream, and never returned: the field is absent from every response, not masked.

curl:

```bash
curl -X POST "https://immiscible.fly.dev/api/w/$WORKSPACE/mcp-upstreams" \
  -H "cookie: __Host-sid=$IMMISCIBLE_SESSION" -H "x-immiscible-csrf: 1" \
  -H "content-type: application/json" \
  -d '{
    "name": "github",
    "url": "https://api.githubcopilot.com/mcp/",
    "transport": "mcp",
    "auth": { "kind": "bearer", "secret": "'"$GITHUB_TOKEN"'" },
    "allowedTools": ["list_issues", "get_issue", "create_pull_request"],
    "toolMap": {}
  }'
```

Node:

```ts
await fetch(`${base}/api/w/${workspace}/mcp-upstreams`, {
  method: 'POST',
  credentials: 'include',
  headers: { 'content-type': 'application/json', 'x-immiscible-csrf': '1' },
  body: JSON.stringify({
    name: 'github',
    url: 'https://api.githubcopilot.com/mcp/',
    transport: 'mcp',
    auth: { kind: 'bearer', secret: process.env.GITHUB_TOKEN },
    allowedTools: ['list_issues', 'get_issue', 'create_pull_request'],
    toolMap: {},
  }),
});
```

The answer carries the upstream's `id` (`mcu_...`) and a `discovery` block: what Immiscible found when it asked the upstream what it offers. Registering a tool server also registers it as an **application** with a version; see [identity and access](https://immiscible.fly.dev/docs/guides/identity-and-access.md).

| Field | Meaning |
|---|---|
| `transport` | `mcp` for a Streamable HTTP MCP server, or `http` for a plain HTTP API described in `httpTools` |
| `auth.kind` | `oauth` (sign in to the server, below), `none`, `bearer`, `header` (your header name in `auth.name`) or `query` (a parameter named in `auth.name`) |
| `allowedTools` | the tools agents may see and call, or `["*"]`; empty means none |
| `toolMap` | gives tools their real meaning; see below |

### Servers that support MCP authorisation: sign in, paste nothing

If the server follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization), register it with `"auth": { "kind": "oauth" }` and no secret, then choose **Sign in** on it (in the console, **Settings**, **Connections**, MCP servers). Immiscible signs in to the server's own authorisation server as a client, on the workspace's behalf:

1. It asks the server, reads the `resource_metadata` from its 401 (or the well-known protected resource metadata), and refuses metadata that describes a different server.
2. It finds the authorisation server's metadata in the order the specification gives, and refuses one that does not support PKCE with S256.
3. It identifies itself with its client id metadata document (`https://immiscible.fly.dev/connect/mcp/client.json`) where the server accepts one, else registers itself (dynamic client registration).
4. You approve at the server. The token is requested for this one server (the `resource` parameter) and checked against the issuer that answered.
5. The tokens are sealed like any upstream credential and refreshed before they expire. Agents never see them.

A new address for an `oauth` upstream drops its tokens, since they were issued for the old one; sign in again.

## 2. Write the mandate

By default a proxied call is a `tool.call` whose target is the upstream's host:

```json
{ "kind": "action", "title": "GitHub tools", "actions": ["tool.call"], "domains": ["api.githubcopilot.com"] }
```

Name the domain. A mandate with no domains means any destination, and once third-party tool output is in the session (which it is after the first proxied call) the Rule of Two asks a person before each further call.

A **tool map** gives individual tools their meaning, so a mandate can say which tools an agent may use and how:

```json
{
  "delete_repo":    { "type": "repo.delete" },
  "send_email":     { "type": "email.send", "targetPath": "to" },
  "create_payment": { "type": "payment", "amountPath": "amount", "amountUnit": "minor", "currency": "GBP", "merchantPath": "merchant" }
}
```

A mapped field that is missing or malformed refuses the call; nothing is guessed. A tool whose type no mandate covers is left out of `tools/list`, and refused if called anyway.

## 3. Point the agent at the proxy

Claude Code:

```bash
claude mcp add --transport http github \
  https://immiscible.fly.dev/mcp/proxy/mcu_6c1d0e \
  --header "Authorization: Bearer $IMMISCIBLE_AGENT_KEY"
```

Cursor:

```json
{
  "mcpServers": {
    "github": {
      "url": "https://immiscible.fly.dev/mcp/proxy/mcu_6c1d0e",
      "headers": { "Authorization": "Bearer ${env:IMMISCIBLE_AGENT_KEY}" }
    }
  }
}
```

For Cursor, put that in `.cursor/mcp.json` in the project, or `~/.cursor/mcp.json` for every project. Then **remove the agent's direct connection to the same tool**. The proxy governs the tools you put behind it; a tool the agent can still reach with its own credential is not governed.

> **Warning**
> Tool descriptions come from the upstream and reach the model unchanged. Allow only upstreams you trust to describe their own tools. When a tool server's tools change, the new or changed tools are hidden until an owner accepts the new manifest.

## What the agent sees on approval

```json
{
  "content": [{ "type": "text", "text": "A person must approve this call to github before it runs. It has not been made." }],
  "structuredContent": {
    "decision": "approval_required",
    "actionId": "act_9b2f",
    "approval": { "id": "apr_3k9d02aa", "url": "https://immiscible.fly.dev/app/approvals/apr_3k9d02aa", "expiresAt": "2026-10-04T10:30:00Z" }
  },
  "isError": true
}
```

Calling again with the same arguments while the approval is pending returns the same answer; it does not ask twice. Clients that can set request metadata may send `_meta: { "immiscible/idempotencyKey": "<key>" }` on `tools/call` and retry with the same key. After a call has been forwarded, the same action is never forwarded again (error `-32006`).

## The Claude Code hook

The proxy covers MCP tools. Claude Code's own tools (Bash, Write, Edit, WebFetch) never go through MCP, so the hook covers those. `scripts/claude-code-hook.mjs` is a single file with no dependencies that sends each tool call to [`/v1/actions/authorize`](https://immiscible.fly.dev/docs/api/post-v1-actions-authorize.md) and returns the decision to Claude Code before anything runs.

```bash
mkdir -p ~/.immiscible
curl -fsSL https://immiscible.fly.dev/downloads/claude-code-hook.mjs -o ~/.immiscible/claude-code-hook.mjs
export IMMISCIBLE_URL=https://immiscible.fly.dev
export IMMISCIBLE_AGENT_KEY=ask_...
```

Then in `~/.claude/settings.json` (every project) or `.claude/settings.json` (one project):

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash|Write|Edit|MultiEdit|NotebookEdit|WebFetch|mcp__(?!immiscible__(check_action_status|explain_decision|spend_summary|find_waste|unwatched_keys)$).*",
        "hooks": [{ "type": "command", "command": "node ~/.immiscible/claude-code-hook.mjs || exit 2", "timeout": 60 }]
      }
    ]
  }
}
```

| Decision | What the hook does | What Claude Code does |
|---|---|---|
| `allow` | prints nothing and exits 0 | carries on under its own permission settings, exactly as without the hook: a tool you have not allowed in Claude Code still asks you |
| `deny` | prints `permissionDecision: "deny"` with the reasons | refuses the tool call and shows the model the reasons |
| `approval_required` | prints `permissionDecision: "ask"` with the reasons and the approval link | asks you |

The hook **fails closed**: if Immiscible cannot be reached, answers with an error (a `500` reads "Immiscible answered 500") or does not answer inside `IMMISCIBLE_TIMEOUT_MS` (10 seconds by default, at most 50, so it answers before Claude Code's 60), the call is refused. Claude Code blocks a call only when a hook exits with code 2, so the command ends in `|| exit 2`: a missing file or a crash blocks the call rather than letting it through. To work offline, remove the hook; do not teach it to allow on error.

The matcher leaves out Immiscible's own read-only MCP tools, as `npx immiscible init` does; its tools that act still go through. An MCP tool (`mcp__<server>__<tool>`) is sent with its server as the destination, `mcp:<server>`, never as a local call. A rule with domains decides which MCP servers the agent may use: add `mcp:github` (or `mcp:*`) to its domains.

For a coding agent, an `action` mandate for `tool.call` with domains such as `github.com`, `registry.npmjs.org` and your own is the usual start (**Agents**, **Agent limits**, **Add a rule**, **Coding agent**). With domains listed, a shell command that posts your environment anywhere else is refused outright (`recipient_not_allowed`). A rule that names `tool.call` is judged ahead of a wildcard such as `tool.*`, and a new rule that would allow tool calls to any domain beside a narrower one is saved only when you confirm it. Once the agent is past its intern stage, Coding agent lets edits, test runs and builds inside the project go ahead (`localWrites: "allow-after-intern"`); pushes, deploys, publishes, installs, destructive commands and anything outside the project ask a person at every standing.

### How many tool calls before a person is asked

Tool calls (`tool.call`, `tool.*` and `mcp.*`) have their own burst line: by default **200 in 10 minutes**, after which the next call asks a person (`velocity`). They do not count towards the line for payments and other actions (10 in 10 minutes). An owner or admin raises it in the workspace settings:

```bash
curl -X PUT "https://immiscible.fly.dev/api/w/$IMMISCIBLE_WORKSPACE/settings" \
  -H "cookie: __Host-sid=$IMMISCIBLE_SESSION" -H "x-immiscible-csrf: 1" -H "content-type: application/json" \
  -d '{ "agentVelocity": { "toolVelocityCount": 500 } }'
```

`velocityCount` and `velocityMinutes` go in the same object. On a local http server the cookie is called `sid`, and either name is accepted there.

## The MCP server

Agents that speak MCP can also connect to `https://immiscible.fly.dev/mcp` itself, where Immiscible offers the gate's six tools (`authorize_action`, `request_payment`, `request_personal_data`, `check_action_status`, `explain_decision` and `settle_action`) and the [AI spend analyst](https://immiscible.fly.dev/docs/analyst.md)'s five. The [MCP server reference](https://immiscible.fly.dev/docs/ai-agents.md#the-mcp-server) describes each. This is the agent asking, which a model can choose not to do, so pair it with the proxy, the hook or the card rail.

## Claude and ChatGPT as connectors

Claude, ChatGPT and other connector-capable clients add `https://immiscible.fly.dev/mcp` as a custom connector and sign in. Immiscible's OAuth 2.1 server (dynamic registration, PKCE, refresh tokens) issues an access token for one agent, and the person chooses which agent on the consent screen. Connected apps appear on the agent's page in the console and can be disconnected there, or by the person under their account.

## Safety properties of the proxy

- **The URL is checked.** HTTPS only. Private, loopback, link-local, carrier-grade NAT, multicast and internal names are refused, as written and as resolved, and the checked address is the one connected to.
- **Bounded.** No redirects, a 15 second timeout per call, responses above 1 MB not passed on, arguments above 64 KB refused.
- **Recorded as digests.** One `mcp_proxy_call` record per call with SHA-256 digests of the arguments and result. Never the credential, argument values or tool output.
- **Tool output counts as untrusted.** What a proxied tool returns is recorded as observed provenance for the agent, and later requests are judged with it.
- **Tenant-bound.** An agent from another workspace gets a `404`.
