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 or a connection’s MCP server, with no change to the server.
Why use one
Section titled “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
Section titled “How it works”Tools are marked by what they do. On a connector’s page, such as 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 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
Section titled “Before you start”- A Virtual MCP server and its ID, or a connection’s MCP server and the connection name.
- The user’s
identifierand a client set up as in Mint session tokens.
-
Mint a read-only token
Section titled “Mint a read-only token”Add the access level when you mint the token. Use
READ_ONLYin capitals: the value is case-sensitive.For a Virtual MCP server:
from datetime import timedeltasession = actions.mcp.create_session_token(mcp_config_id=config_id,identifier="user_123",expiry=timedelta(minutes=15),access_level="READ_ONLY",)session_token = session.tokenconst session = await scalekit.actions.mcp.createSessionToken({mcpConfigId: configId,identifier: 'user_123',expirySeconds: 900,accessLevel: 'READ_ONLY',});const sessionToken = session.token;Terminal window 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:
from datetime import timedeltasession = actions.mcp.create_session_token(connection_name="gmail",identifier="user_123",expiry=timedelta(minutes=15),access_level="READ_ONLY",)session_token = session.tokenconst session = await scalekit.actions.mcp.createSessionToken({connectionName: 'gmail',identifier: 'user_123',expirySeconds: 900,accessLevel: 'READ_ONLY',});const sessionToken = session.token;Terminal window 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. -
Connect the agent
Section titled “Connect the agent”Pass the token to the MCP client as
Authorization: Bearer <session token>, exactly as with a full token. Mint session tokens shows the MCP SDK clients.
Check it worked
Section titled “Check it worked”List the tools with the read-only token, then with a full one, and compare:
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:
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])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
Section titled “Common problems”unknown tool "<name>" when the agent calls a tool
Section titled “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
Section titled “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
Section titled “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, such as list and search tools, or mint a full token.
API reference
The endpoints this page's code calls, with every field and the SDK method for each: