> **Building with AI coding agents?** Install the authstack plugin with one command. This equips your agent with accurate Scalekit implementation patterns.
>
> **Recommended** (the `-y` flags skip prompts, so the command runs without an interactive terminal):
> ```bash
> npx -y @scalekit-inc/cli setup -y
> ```
>
> Global:
> ```bash
> npm install -g @scalekit-inc/cli
> scalekit setup -y
> ```
>
> Supports Claude Code, Cursor, GitHub Copilot, Codex + skills for other Agent Skills-compatible agents in `~/.agents/skills`.
> Skills: integrate-agentkit, implement-saaskit, add-mcp-oauth, implement-sso, implement-scim.
> [Full setup guide](https://docs.scalekit.com/dev-kit/build-with-ai/)

---

# Limit an agent to read-only tools

Mint a read-only session token so an agent sees and calls only the tools that don't change anything, on a Virtual MCP server or a connection's MCP server.
A read-only session token gives an agent only the tools that read data, such as listing, searching and fetching. Tools that send, create, update or delete are left out of the agent's tool list and refused if it calls them. You set this when you mint the token, for a [Virtual MCP server](/agentkit/mcp/overview/) or a [connection's MCP server](/agentkit/mcp/connection-mcp-servers/), with no change to the server.

## Why use one

- **Least privilege per run.** An agent that summarizes a user's inbox or answers questions about their CRM never needs to send an email or edit a record. A read-only token gives it only what the task needs.
- **Prompt injection can't trigger a write.** Agents read content written by other people: emails, tickets, documents. If that content tells the agent to forward a thread or delete a file, the agent has no tool to do it with. Write tools aren't in its tool list, and a call to one is refused.
- **One field, not a separate server.** You don't build and maintain a second, read-only server for each agent. The same server serves full and read-only runs, and the token decides.
- **Different access per run.** Mint a read-only token for a scheduled digest and a full token for a run where the user has approved an action. Tokens are independent, so one run's access never changes another's.
- **Same user isolation.** A read-only token is still bound to one user and one server, so the agent reads only that user's data, through their own connected accounts.

## How it works

Tools are marked by what they do. On a connector's page, such as [Gmail](/agentkit/connectors/gmail/), each tool is labeled **Read-only**, **Write** or **Destructive**. A tool without a read-only marking counts as a write. With a read-only token:

| The agent | What happens |
| --- | --- |
| Lists tools (`tools/list`) | Only tools marked read-only are returned, plus `connect_account`, so the agent can still ask the user to connect an app. |
| Calls a read-only tool | The call runs as the user, as with a full token. |
| Calls any other tool | The call is refused with JSON-RPC error `-32602`, `unknown tool "<name>"`. Nothing reaches the app. |

On a Virtual MCP server, the token gets the read-only tools among the tools you chose for that server. On a connection's MCP server, it gets the read-only tools of that connection.

Tools from an [MCP connector you bring](/agentkit/bring-your-own-connector/overview/) are read-only when the upstream MCP server marks them that way, with `readOnlyHint`. A tool without that marking is left out of a read-only token.

## Before you start

- A [Virtual MCP server](/agentkit/mcp/configure-mcp-server/) and its ID, or a [connection's MCP server](/agentkit/mcp/connection-mcp-servers/) and the connection name.
- The user's `identifier` and a client set up as in [Mint session tokens](/agentkit/mcp/session-tokens/#before-you-start).

1. ## Mint a read-only token

   Add the access level when you mint the token. Use `READ_ONLY` in capitals: the value is case-sensitive.

   For a Virtual MCP server:

   **Python**

   ```python
   from datetime import timedelta

   session = actions.mcp.create_session_token(
       mcp_config_id=config_id,
       identifier="user_123",
       expiry=timedelta(minutes=15),
       access_level="READ_ONLY",
   )
   session_token = session.token
   ```

   **Node.js**

   ```ts
   const session = await scalekit.actions.mcp.createSessionToken({
     mcpConfigId: configId,
     identifier: 'user_123',
     expirySeconds: 900,
     accessLevel: 'READ_ONLY',
   });
   const sessionToken = session.token;
   ```

   **cURL**

   ```bash
   SESSION_TOKEN=$(curl -sS "$SCALEKIT_ENVIRONMENT_URL/api/v1/mcp/configs/$CONFIG_ID/tokens" \
     -H "Authorization: Bearer $TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"identifier": "user_123", "expiry": "900s", "access_level": "READ_ONLY"}' |
     jq -r .token)
   ```

   For a connection's MCP server:

   **Python**

   ```python
   from datetime import timedelta

   session = actions.mcp.create_session_token(
       connection_name="gmail",
       identifier="user_123",
       expiry=timedelta(minutes=15),
       access_level="READ_ONLY",
   )
   session_token = session.token
   ```

   **Node.js**

   ```ts
   const session = await scalekit.actions.mcp.createSessionToken({
     connectionName: 'gmail',
     identifier: 'user_123',
     expirySeconds: 900,
     accessLevel: 'READ_ONLY',
   });
   const sessionToken = session.token;
   ```

   **cURL**

   ```bash
   SESSION_TOKEN=$(curl -sS "$SCALEKIT_ENVIRONMENT_URL/api/v1/mcp/connections/gmail/tokens" \
     -H "Authorization: Bearer $TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"identifier": "user_123", "expiry": "900s", "access_level": "READ_ONLY"}' |
     jq -r .token)
   ```

   Leave the access level out, or set it to `FULL`, for a token with every tool the server serves.

2. ## Connect the agent

   Pass the token to the MCP client as `Authorization: Bearer <session token>`, exactly as with a full token. [Mint session tokens](/agentkit/mcp/session-tokens/#connect-the-mcp-client) shows the MCP SDK clients.

## Check it worked

List the tools with the read-only token, then with a full one, and compare:

```bash
curl -sS "$MCP_SERVER_URL" \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' |
  sed -n 's/^data: //p' | jq -r '.result.tools[].name'
```

The read-only list has only tools that read, such as `gmail_fetch_mails`, plus `connect_account`. Tools such as `gmail_send_message` appear only with the full token.

To see which tools a read-only token gets before you mint it, read each tool's `read_only_hint` from the tool list:

**Python**

```python
from google.protobuf.json_format import MessageToDict

page, _ = actions.tools.list_scoped_tools(
    identifier="user_123",
    filter={"connection_names": ["gmail"]},
    page_size=100,
)
definitions = [MessageToDict(t.tool)["definition"] for t in page.tools]
print([d["name"] for d in definitions if d.get("annotations", {}).get("read_only_hint") is True])
```

**Node.js**

```ts
const page = await scalekit.tools.listScopedTools('user_123', {
  filter: { connectionNames: ['gmail'] },
  pageSize: 100,
});
const readOnly = page.tools
  .map((t) => t.tool?.definition as { name?: string; annotations?: { read_only_hint?: boolean } } | undefined)
  .filter((d) => d?.annotations?.read_only_hint === true)
  .map((d) => d?.name);
console.log(readOnly);
```

## Common problems

### `unknown tool "<name>"` when the agent calls a tool

The tool isn't read-only, so a read-only token doesn't include it. If the agent needs it for this run, mint a full token. Otherwise, tell the agent in its instructions that it can only read.

### `400` with `access_level` when minting

The value must be exactly `FULL` or `READ_ONLY`, in capitals. `read_only` and other spellings are rejected.

### `tools/list` returns only `connect_account`

None of the tools on this server are read-only. On a Virtual MCP server, [add read-only tools](/agentkit/mcp/configure-mcp-server/#change-or-remove-a-server), such as list and search tools, or mint a full token.

## Next

  - [Mint session tokens](/agentkit/mcp/session-tokens/): Decide when to mint for scheduled, concurrent and long-running agents.
  - [Security and compliance](/agentkit/security/): What Scalekit protects, and what your app is responsible for.

## API reference

The endpoints this page's code calls, with every field and the SDK method for each:

- `GET` [List a user's scoped tools](https://docs.scalekit.com/agentkit/reference/tools/list-scoped-tools.md)
- `POST` [Mint a session token](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/mint-a-session-token.md)
- `POST` [Mint a session token for a connection](https://docs.scalekit.com/agentkit/reference/virtual-mcp-servers/mint-a-connection-session-token.md)


---

## More Scalekit documentation

| Resource | What it contains | When to use it |
|----------|-----------------|----------------|
| [/llms.txt](/llms.txt) | Structured index with routing hints per product area | Start here — find which documentation set covers your topic before loading full content |
| [/llms-full.txt](/llms-full.txt) | Complete documentation for all Scalekit products in one file | Use when you need exhaustive context across multiple products or when the topic spans several areas |
| [sitemap-0.xml](https://docs.scalekit.com/sitemap-0.xml) | Full URL list of every documentation page | Use to discover specific page URLs you can fetch for targeted, page-level answers |
