Every app on Substrio is an MCP server at https://<app>.substrio.com/mcp
(mcp.md). Your own agent reaches it with the app key. Your users
reach the same endpoint with a credential of their own: an OAuth connection
their client makes for them, or an agent token they make. Either one acts as
that person, so the agent gets exactly what the person may do and nothing more.
You write no auth code for it.
How a user's agent connects
A user has two ways in. Both produce an agent token (substrio_at_ and 43
characters) that belongs to that person in your app.
| Way in | What the user does | Fits |
|---|---|---|
| OAuth | Adds https://<app>.substrio.com/mcp as a connector in Claude.ai, ChatGPT, Claude Code or Cursor and signs in on your app's consent page. |
Chat clients and any client that opens a browser. |
| Agent token | Signs in to your app and calls auth.agent_token.create (POST /v1/_auth/agent_tokens); the token comes back once. |
Scripts, headless agents and clients that take a header. |
OAuth. The client's first call gets a 401 whose WWW-Authenticate
header names your app's OAuth metadata. The client registers itself, sends the
user to your app's consent page, and exchanges the code for an access token and
a refresh token. The consent page signs the user in with your app's own sign-in
and names your app and the client. An app that wants its own page names it with
consent_path in its MCP manifest. Every rule the server enforces (PKCE S256,
one-use codes, redirect URIs, token rotation) is in mcp.md.
Agent token. A signed-in user makes one with a name and an optional
lifetime in days. Only a person's own session makes a token: the app key, an
account key and another agent token are refused, so a token never mints more
tokens. A person holds at most 20 live tokens (agent_tokens_max_per_user), and
OAuth connections count toward them.
What each agent sees
Credential on your app's /mcp |
What it gets |
|---|---|
| The app key | Your app's tools and the built-in administration tools. |
| A user's agent token or OAuth token | Your app's tools only, each run as that user. |
| No credential, or a revoked, expired, forged or other app's token | 401, on every method, so the client signs in again. |
A user's agent never sees a built-in tool: the server serves it the app's tool
list alone. Each call then runs your function with that person as
request.auth, so three layers decide what the agent may do, all before or
inside your code:
- The route's gate word, judged before the function runs, exactly as for
the same route over HTTP:
authtakes any signed-in person;verifiedtakes a person with a proven address;auth:<NAME>takes a person who holds the declared permission<NAME>;ownertakes only a key of your account, so no user's agent passes it.
- Your collections' security rules and the person's role and permissions (permissions.md).
- Your function, which reads
request.authand answers that person's data.
The token carries the person's permissions as they are now. A permission you revoke, a role you lower or an account you disable takes effect on the next call.
Choosing which tools you expose
Every route your functions declare with a method is a tool by default. You decide the set in three places:
- Per route, in
functions/<name>.json:"tools": {"POST /orders/_sync": false}takes a route out, and an object gives it a name, a description and an input schema."tools": falsetakes none of the function's routes. - In a manifest (
mcp.json, named bymcp_fileinsubstrio.toml), whose tools come first. - One tool at a time, with
mcp.tool.setandmcp.tool.remove.
mcp.tool.list shows the exact set an agent gets, each tool with its route and
its call count. A route you keep off the tool list still answers HTTP; gate it
for who may call it, whichever way it is reached.
The manifest's auth decides who reaches the tools at all: "users" (the
default) takes your users' tokens and the app key and answers anything else
401, and
"bearer" hands every bearer credential to your functions, which check it
themselves.
Testing as one of your users
mcp.tool.test calls one tool with the arguments an agent sends, as the user
you name in user (an id from user.list), under the route's gate
and your rules. It answers the exact tools/call result that user's agent gets.
It runs on your app's /mcp, where tools/call runs, as
substrio_mcp_tool_test.
Revoking
| Who | How | What stops |
|---|---|---|
| The user | DELETE /v1/_auth/agent_tokens/{id} (auth.agent_token.revoke), with auth.agent_token.list to find the id |
That one token, or that one OAuth connection |
| The OAuth client | POST /oauth/revoke with either token |
The access and the refresh token together |
| You, for one user | user.update with disabled: true |
Every token and connection of that person, on the next call |
| You, for one tool | mcp.tool.remove, or the route's tools entry set to false |
The tool, for every agent |
Audit
- Who made a token: the audit trail records each
auth.agent_token.create(actiontoken.issue) and eachauth.agent_token.revoke(token.revoke) with the person who made it.audit.listreads them, andaudit.verifyproves the trail is intact. - What agents call: every
tools/callcounts one call of its tool, in your app's own database.mcp.tool.listanswers each tool'scalls, andtenant.usage.self(GET /v1/_usage) answers them all asmcp_tools. - What a call did: your function's own log lines (
function.logs), withrequest.authnaming the person.
Worked example
The app shop lets each customer's agent read their orders and, for customers
you trust, cancel one. Its function orders declares two routes and their
tools in functions/orders.json:
{
"routes": ["GET /orders/mine auth", "POST /orders/:id/cancel auth:ORDERS_CANCEL"],
"tools": {
"GET /orders/mine auth": {
"name": "my_orders",
"description": "The signed-in customer's orders, newest first",
"annotations": { "readOnlyHint": true }
},
"POST /orders/:id/cancel auth:ORDERS_CANCEL": {
"name": "cancel_order",
"description": "Cancel one of the signed-in customer's orders",
"inputSchema": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"] }
}
}
}
The handler of my_orders reads request.auth.user_id and answers that
person's orders alone.
1. You declare the permission, deploy and check the tools (the CLI runs as your profile's app key):
substrio op permissions.set --data '{"names":["ORDERS_CANCEL"]}'
substrio deploy
substrio op mcp.tool.list
# two tools, my_orders and cancel_order, each with its route, "calls":0 and "source":"route"
substrio op user.permissions.set 42 grant='["ORDERS_CANCEL"]'
2. You test as customer 42 on the app's /mcp, with the app key:
curl -s https://shop.substrio.com/mcp -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"substrio_mcp_tool_test",
"arguments":{"name":"my_orders","arguments":{},"user":42}}}'
# the result customer 42's agent gets: their orders as content and structuredContent
3. The customer connects an agent. With OAuth, the URL is the whole setup:
claude mcp add --transport http shop https://shop.substrio.com/mcp
The client opens the consent page, the customer signs in and allows it, and the two tools appear. With an agent token instead, the customer signs in and makes one, kept out of the shell history in a file only they read:
curl -s https://shop.substrio.com/v1/_auth/login -H 'Content-Type: application/json' \
-d @login.json -o session.json
curl -s https://shop.substrio.com/v1/_auth/agent_tokens \
-H "Authorization: Bearer $(jq -r .token session.json)" -H 'Content-Type: application/json' \
-d '{"name":"laptop agent","expires_in_days":30}' -o agent-token.json
# 201 {"id":"...","name":"laptop agent","token":"substrio_at_...","created_at":...,"expires_at":...}
claude mcp add --transport http shop https://shop.substrio.com/mcp \
--header "Authorization: Bearer $(jq -r .token agent-token.json)"
4. The agent calls a tool as the customer:
curl -s https://shop.substrio.com/mcp -H "Authorization: Bearer $AGENT_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"cancel_order","arguments":{"id":"o_91"}}}'
A customer who holds ORDERS_CANCEL gets the cancelled order. Any other
customer's agent gets a tool result with isError: true and the code
PERMISSION_DENIED, naming the permission, and the function never runs.
5. The customer or you revoke it:
curl -s -X DELETE https://shop.substrio.com/v1/_auth/agent_tokens/$TOKEN_ID \
-H "Authorization: Bearer $(jq -r .token session.json)"
substrio op user.update 42 disabled=true
substrio op audit.list action=token.revoke
Refusals
| Code | Status | When |
|---|---|---|
TOKEN_INVALID (0xA001) |
401 | An auth.agent_token.* call with no credential, or with a revoked, expired or forged one. |
INSUFFICIENT_PERMISSIONS (0xA007) |
403 | auth.agent_token.create from a key or an agent token; auth.agent_token.list or auth.agent_token.revoke from a key. |
KEY_EXISTS (0x4004) |
409 | auth.agent_token.create when the person holds 20 live tokens. |
INVALID_ARGUMENT (0x0102) |
400 | auth.agent_token.create with no name, or expires_in_days of 0 or past the server's longest lifetime; field names the member. |
KEY_NOT_FOUND (0x4000) |
404 | auth.agent_token.revoke of a token that is not the caller's. |
CREDENTIAL_REQUIRED (0xA008) |
401, a tool error over MCP | A gated route called with no person. |
PERMISSION_DENIED (0x1003) |
403, a tool error over MCP | An auth:<NAME> route called by a person without <NAME>, or an owner route by anything but your account's key. |
EMAIL_VERIFICATION_REQUIRED (0xA012) |
403, a tool error over MCP | A verified route called by a person who has not proven their address. |
MCP_TOOL_NOT_FOUND (0xC080) |
404 | mcp.tool.test of a tool the app's /mcp does not serve. |
MCP_TEST_USER_NOT_FOUND (0xC082) |
404 | mcp.tool.test names, in user, no person of the app. |
The OAuth endpoints answer the standard OAuth errors (invalid_grant,
invalid_client, access_denied), listed in mcp.md.