Let your users' agents use your app

Your users can connect AI agents to your app's MCP endpoint using their credentials. Each agent calls your tools with the permissions of the user who connected it.

Docs menu

Every Substrio app is an MCP server at https://<app>.substrio.com/mcp (mcp.md). Your agent can connect with the app key. Users connect to the same endpoint through OAuth or an agent token. Either credential acts as the user, with that user's permissions. You do not need to write authentication code for the connection.

How a user's agent connects

A user can connect in two ways. Both produce an agent token (substrio_at_ and 43 characters) that belongs to that person in your app.

Method What the user does Suitable clients
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 uses your app's sign-in and identifies the app and client. An app that provides a consent page specifies 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 creates a token with a name and an optional lifetime in days. Only the person's session can create one. App keys, account keys, and other agent tokens are refused. A person can have at most 20 live tokens (agent_tokens_max_per_user), including OAuth connections.

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 sees only the app's tools, without the built-in tools. Each call runs your function with that person as request.auth. Three layers control what the agent may do:

  1. The route's gate word, judged before the function runs, exactly as for the same route over HTTP:
    • auth allows any signed-in person.
    • verified allows a person with a verified address.
    • auth:<NAME> allows a person with the declared permission <NAME>.
    • owner allows 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 returns that person's data.

The person's current permissions apply to the token. A revoked permission, lowered role, or disabled account 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} excludes a route, and an object gives it a name, a description, and an input schema. "tools": false excludes all 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 tools an agent receives, including each tool's route, area, and call count. A route omitted from the tool list remains available over HTTP. Set its gate for the callers allowed to use it.

An app with many routes does not have to fit them in one list. Every tool has an area, by default its route's first path word, and an agent opens an area with <slug>_tools {"area"} when it needs those tools (mcp.md). Three members of a tool's entry shape what the agent sees first:

  • "lead": true puts the tool in the first list returned by tools/list.
  • "area": "<word>" moves the tool to another area.
  • "approval": true means an agent session's call needs a person's approval first, as a call to an irreversible platform operation does.

An agent only sees tools whose gates the connected user can pass. For example, a person without HOST does not see your auth:HOST tools.

The manifest's auth controls access to tools. "users", the default, accepts your users' tokens and the app key, and returns 401 for other credentials. "bearer" passes bearer credentials to your functions for validation.

What your users' agents see

Your app can configure the content its users' agents receive through three declarations. See mcp.md for all members and errors.

Where What When it shows
A function's answer: _mcp {content?, structuredContent?, _meta?, isError?} That call's own result: rich content items (text, images, links, resources), your _meta, an error of your choosing. request.mcp {tool, first} says the call is an agent's. That one result.
A tool: next [{tool, why, pass?, as?}] and stage The steps that come after it, as Next: lines and _meta substrio/next. Every success of that tool.
The manifest: welcome or welcome_function, line_function, line_ttl_seconds A welcome that opens a person's connection, and a line that ends every result. The welcome once per connection; the line on every result.

The welcome_function and line_function are ordinary app functions. They run alongside the tool as the person in request.auth, and their _mcp content is added to the result. A failed function adds nothing and does not fail the call. The connection's saved mark keeps its welcome across OAuth refreshes. A retried call may repeat the welcome. A new connection receives another welcome. The app key receives neither because it is not a user.

Worked example: MailJet's agents start by saving a draft, then sending it, and always see what is waiting.

{
  "welcome_function": "mcp-welcome",
  "line_function": "mcp-line",
  "tools": [
    {"name": "save_draft", "description": "Save a draft", "route": "POST /drafts", "stage": "compose",
     "next": [{"tool": "send_draft", "why": "sends it to the list", "pass": "id", "as": "draft"}]},
    {"name": "send_draft", "description": "Send a draft", "route": "POST /drafts/:draft/send"}
  ]
}
// functions/mcp-welcome.js: what a person's agent reads first.
module.exports = async function (request) {
  const plan = await planOf(request.auth.user_id);  // your own read of the person's plan
  return new Response(JSON.stringify({ _mcp: {
    content: [{ type: "text", text: `Welcome to MailJet: save a draft, then send it (${plan} plan)` }],
    _meta: { "mailjet/plan": plan },
  } }), { headers: { "content-type": "application/json" } });
};

// functions/mcp-line.js: what ends every result, kept 60 seconds a person.
module.exports = async function (request) {
  const waiting = await unsentDrafts(request.auth.user_id);  // your own count
  return new Response(`MailJet drafts waiting: ${waiting}`);
};

The person's agent connects with https://mailjet.substrio.com/mcp and signs in on MailJet's consent page. Its first save_draft opens with MailJet's welcome, then the function's own words, Next: send_draft: sends it to the list; pass this result's id as draft, and MailJet drafts waiting: 1 last. A person who signs in to Substrio's own MCP instead gets Substrio's welcome and lines, which Substrio's own app declares the same way.

Substrio's welcome and line

Substrio's own welcome and owner line (mcp.md, "The welcome" and "The owner line") come from two functions of the identity app, the app that is Substrio. Its mcp.json names them as any app does: "welcome_function": "mcp-welcome" and "line_function": "mcp-line". The source is sites/build/functions/mcp-welcome.js and mcp-line.js. They differ from an app's in what they are handed: the platform host's callers are accounts, not the identity app's people. So each function gets the account's facts as its call's input, written once from what the platform already holds. It reads nothing back: no lookup and no host call. A function shapes the words from the facts.

The call. Both run as nobody (request.auth signed out, request.caller null), on POST /mcp, with no headers, params or body. request.mcp.first is true on a session's first result. The facts are request.mcp.welcome for welcome_function and request.mcp.line for line_function. The answer is an app function's answer: text, or _mcp {content, _meta} ("What your users' agents see" above).

welcome_function runs once per agent session, beside its first result, which waits for it. Its content leads that result and its _meta members join the result's _meta. Substrio's sets substrio/welcome. request.mcp.welcome is a SubstrioMcpWelcomeFacts (the runtime types, substrio_runtime.d.ts):

Member Type What it is
account number The account the session builds for.
new_account boolean The session's own sign-up made the account (mcp.md, "The welcome").
plan {id, title, free} or null The account's plan: its id in the plans file, its title, and free when it has no price. Null when the platform has no plans.
figures array What the account uses, one per limit: {name, period, unit, used, allowed}. name is apps, storage, a cap's noun (members, custom domains, ...) or a counter's name. period is day or month for a counter, else null. unit is count or bytes. allowed is null for no limit (0 is a cap of zero).
starter object or null The Starter plan, when the plans file has one and the account is not on it: {id, title, price_minor, currency, interval, price, adds}. price is written as the site writes it ($9/mo). adds is what it adds over the account's plan, {name, period, unit, from, to}, unit one of count, bytes, interval (seconds) or feature (the name alone), to null for no limit.
apps array The account's apps: {slug, url, live}, url its first host or null, live its live version or null.
tools {start, create, checkout} The names of the tools a welcome points to: the build loop, a new app, a plan's checkout link.

line_function gives the last content item of every result. Its first content item is the line. request.mcp.line is a SubstrioMcpLineFacts:

Member Type What it is
account number The account.
plan string or null The plan's title; null with no plans.
free boolean The plan has no price.
apps {used, allowed} Apps made, and the cap (null: none).
storage {used, allowed} Bytes stored, and the account's space (null: none).
counters array {name, period, used, allowed}, period day or month.
pressed boolean A finite limit is used past 80% (the whole percent, rounded down).
billing_url string or null The account's billing page on the console; null with no console origin.
checkout_tool string The checkout tool's name.

The line runs only when the facts move, or when the identity app's declaration or one of its functions changes (every account's line runs once more). Once a second at most per account, the platform gathers the facts as it would for its own words and hashes them. A reply kept for that hash is the line, with no run. When they moved, the tools/call that found it waits for one run and shows its reply. Until that reply is kept, every other result (that second's calls, agent HTTP answers, links) shows the line before it. No second run starts while one is under way, however slow it is: an account has at most one run at a time. A line is always whole: the function decides from free and pressed when to name the upgrade.

A run that fails, or answers no content, never fails the call: the welcome is then the default welcome, and the line uses the platform wording for those facts until they move. The same words are what the platform says when the identity app declares no function.

Cost: the facts are the structs the platform fills for its own words, written as JSON once (0 extra lookups, 0 host calls). A result whose line is unchanged adds nothing to what it paid before: 1 cached find of the account's line. The welcome's run is once a session. A line's run is once per change of the facts, plus 1 hash of them a second per calling account.

Testing as one of your users

mcp.tool.test calls a tool with the arguments an agent sends, as the user specified in user (an id from user.list), under the route's gate and your rules. It returns the tools/call result that user's agent would receive. 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.
MCP_RESULT_INVALID (0xC084) 502, a tool error over MCP A function's _mcp that is no tools/call result; field names the member (_mcp.content[1]).
MCP_TOOL_INVALID (0xC081) 400 mcp.tool.set of a tool whose next or stage is malformed; field names it (next[0].why).
INVALID_ARGUMENT (0x0102) 400 mcp.tools.set of a manifest whose welcome, welcome_function, line_function or line_ttl_seconds is malformed, or that gives both welcome and welcome_function; field names it.
MCP_OPERATIONS_IDENTITY_ONLY (0xC085) 403 mcp.tools.set or a version whose manifest declares operations: that member provides Substrio guidance for platform operations and is limited to the identity app and substrio, with a sandbox counted as its app (mcp.md, "Substrio is an app"). field is operations.
MCP_LINK_NOT_LINKED (0xC086) 403 A used app's tool (<app>_<tool>) called by someone with no user there linked to their Substrio sign-in; detail is the app (mcp.md, "Connected apps").
MCP_LINK_NOT_OFFERED (0xC087) 404 <app>_<tool> of an app this app uses, which does not offer it that tool; detail is the app, field is name.
MCP_ROOT_OVERFLOW (0xC08B) 200, a warning A deploy, or mcp.tool.list, of an app whose lead tools do not fit in mcp_max_tools with the two tree tools; tools names each one left out of the first list. Every one stays in its area, and the deploy goes ahead (mcp.md).
MCP_TOOL_MEMBER_UNKNOWN (0xC08C) 200, a warning A deploy or a version whose functions/<name>.json tools entry holds a member outside the tool format. field names the file, route the entry's key and member the member. The tool is served without it, and the deploy goes ahead (mcp.md).
INVALID_ARGUMENT (0x0102) 400, a tool error over MCP <slug>_tools naming an area the app does not have; field is area. A tool entry whose area is no lowercase word, or whose lead or approval is not true or false, is refused too: mcp.tool.set answers MCP_TOOL_INVALID and a deploy answers its invalid version, each naming the member.
MCP_IDENTITY_FUNCTION_NOT_DEPLOYED (0xC091) 500, at start only A function the release ships for the identity app (its welcome_function or line_function) was not deployed into an identity app that lacks it; the start warns, stores nothing and tries again at the next start (mcp.md, "Substrio is an app").

The OAuth endpoints answer the standard OAuth errors (invalid_grant, invalid_client, access_denied), listed in mcp.md.