Skip to content

Guides

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.

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.

#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 -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": {}
  }'

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.

FieldMeaning
transportmcp for a Streamable HTTP MCP server, or http for a plain HTTP API described in httpTools
auth.kindoauth (sign in to the server, below), none, bearer, header (your header name in auth.name) or query (a parameter named in auth.name)
allowedToolsthe tools agents may see and call, or ["*"]; empty means none
toolMapgives tools their real meaning; see below

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

If the server follows the MCP authorization specification, 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 mcp add --transport http github \
  https://immiscible.fly.dev/mcp/proxy/mcu_6c1d0e \
  --header "Authorization: Bearer $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.

#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 and returns the decision to Claude Code before anything runs.

Shell
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 }]
      }
    ]
  }
}
DecisionWhat the hook doesWhat Claude Code does
allowprints nothing and exits 0carries on under its own permission settings, exactly as without the hook: a tool you have not allowed in Claude Code still asks you
denyprints permissionDecision: "deny" with the reasonsrefuses the tool call and shows the model the reasons
approval_requiredprints permissionDecision: "ask" with the reasons and the approval linkasks 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:

Shell
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’s five. The MCP server reference 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.