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:
- The route's gate word, judged before the function runs, exactly as for
the same route over HTTP:
authallows any signed-in person.verifiedallows a person with a verified address.auth:<NAME>allows a person with the declared permission<NAME>.ownerallows 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 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": falseexcludes all 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 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": trueputs the tool in the first list returned bytools/list."area": "<word>"moves the tool to another area."approval": truemeans 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(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. |
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.