Let your users' agents use your app

Your users connect their own AI agents to your app's MCP endpoint with their own credentials, and each agent calls your tools as that person.

Docs menu

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:

  1. The route's gate word, judged before the function runs, exactly as for the same route over HTTP:
    • auth takes any signed-in person;
    • verified takes a person with a proven address;
    • auth:<NAME> takes a person who holds the declared permission <NAME>;
    • owner takes only a key of your account, so no user's agent passes it.
  2. Your collections' security rules and the person's role and permissions (permissions.md).
  3. Your function, which reads request.auth and 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": false takes none of the function's routes.
  • In a manifest (mcp.json, named by mcp_file in substrio.toml), whose tools come first.
  • One tool at a time, with mcp.tool.set and mcp.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 (action token.issue) and each auth.agent_token.revoke (token.revoke) with the person who made it. audit.list reads them, and audit.verify proves the trail is intact.
  • What agents call: every tools/call counts one call of its tool, in your app's own database. mcp.tool.list answers each tool's calls, and tenant.usage.self (GET /v1/_usage) answers them all as mcp_tools.
  • What a call did: your function's own log lines (function.logs), with request.auth naming 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.