The person chooses which apps and capabilities it can use and whether it can write. Each agent call is logged for the account. Calls that cannot be undone require a person's approval. Any account member can end a session immediately.
The same session works with Claude, ChatGPT, Grok, Codex, a curl script, or
another MCP client. There is no per-vendor plugin or extension. Send the
credential as Authorization: Bearer <secret> over HTTP or /mcp.
Getting a session
There are four ways to obtain a session:
| How | Who uses it |
|---|---|
OAuth on the platform host. The client finds the server from the /mcp 401 and registers itself. The person signs in, picks what to grant, and the client gets a short access token with a refresh token. See mcp.md. |
Chat apps (ChatGPT, Claude) through the Substrio connector, and MCP clients that sign in through a browser. No code is needed. |
A connect code. A member makes a one-time code, substrio_ae_... (POST /v1/_accounts/{id}/agent/enrollments; the console makes no codes: its Agents page connects an agent over MCP with OAuth, the row above). The agent redeems it once: the MCP tool substrio_connect, POST /v1/_agent/exchange, or substrio connect. |
Coding agents and scripts (Claude Code, Codex, Cursor, curl) a person sets up by hand. |
Delegation. An agent with a session makes a narrower child session for a sub-agent (agent.session.delegate). |
Agents that start other agents. |
Signing in by email. The agent asks for a sign-in (agent.login.begin), the person approves it on a page with the code from their email, and the agent gets a refreshable session. See Signing in by email. substrio login --email does all of it. |
The CLI, and any agent that runs on the person's machine or can ask them for a code. |
Redeeming a connect code
The code works once within agent_enroll_ttl_seconds. Redeeming it creates a
bearer session unless the request specifies kind or sends a public_key
(see Kinds). The response contains the session (id, kind,
expires_at, the grant, and key or keys, shown once) and a next line
explaining how to send the credential and how long it lasts. Unknown, used,
and expired codes all return AGENT_CODE_INVALID.
A chat app (ChatGPT, Claude). Add the Substrio connector with
https://mcp.substrio.com/mcp and sign in. The connector is already a
session; a code given to it is not needed.
Over MCP, with no key. Every /mcp (the platform host's and each app's) runs one tool for a
request with no credential, or with one that proves nothing (an ended
session): substrio_connect. It is in no tools/list, and initialize and
tools/list without a credential stay the OAuth 401. A caller that is
already connected gets AGENT_ALREADY_CONNECTED, and its code stays unused.
POST https://mcp.substrio.com/mcp
Content-Type: application/json
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "substrio_connect", "arguments": {"code": "substrio_ae_..."}}}
The tool result holds the same answer as the HTTP call below.
Over HTTP.
curl -X POST https://mcp.substrio.com/v1/_agent/exchange \
-H 'Content-Type: application/json' -d '{"code": "substrio_ae_..."}'
{"id": "...", "kind": "bearer", "expires_at": 1793170000, "account_id": 7, "user_id": 3, "role": "owner",
"apps": [], "capabilities": [...], ..., "key": "substrio_ab_...",
"next": "Keep key, shown only once. Send Authorization: Bearer <key> on every request to this host over plain HTTPS (/v1/...; the guide is /llms.txt), or connect this host's /mcp with that header. ..."}
With the CLI. substrio connect redeems the code (read from stdin with
-, or as the argument), saves the session as profile agent (or
--profile NAME) in the owner-only profiles file, and prints the MCP setup.
It never prints the code or the secret, except with --show: stdout then
holds the secret alone, so one line redeems a code into Claude Code:
claude mcp add --transport http substrio-mcp https://mcp.substrio.com/mcp \
--header "Authorization: Bearer $(npx @substrio/cli connect substrio_ae_... --show)"
| Code | Status | When | What to do |
|---|---|---|---|
AGENT_CODE_INVALID |
401 | the code is unknown, already used or past its life | ask your owner for a new code |
AGENT_ALREADY_CONNECTED |
409 | substrio_connect was called on a connection that already acts for your owner |
keep using that connection; the code was not used |
An agent with no key at all signs its owner up or in by a mailed code
(agent.signup.start, agent.signup.finish; see agents.md).
The account it makes holds 500 MB until its owner signs in once with Google,
GitHub or another provider; the agent's console link never opens it
(authentication.md).
Signing in by email: agent.login
An agent without a credential requests sign-in, and the person approves it in their browser. They enter the code from their email once. The code signs the browser in to the dashboard and gives the agent a session. A new address receives an account and a free plan at the same time.
substrio login --email [email protected] runs the whole flow. When the browser does not finish, it exits with
code 75 and the agent asks the person for the code, then runs substrio login --code <code>.
The flow
- The agent begins.
agent.login.begin {email, agent_name?, loopback_port?}answersintent_id,intent_secret,match_code,url,dashboard_url, andexpires_at. Nothing is mailed yet. The agent keepsintent_secretto itself and shows the personmatch_codeandurl. A CLI on the person's own machine first listens on127.0.0.1and passes that port asloopback_port. - The person opens the page.
urlishttps://substrio.com/start?intent=<intent_id>. The page readsagent.login.show {intent_id}: the address masked, the agent's name, the match code, the site key, and whether the code was mailed. The person checks that the match code is the one the agent showed. - The bot check mails the code.
agent.login.challenge {intent_id, turnstile_token}checks the token with Cloudflare, then mails the code through the identity app's own code request. A request mails once. A second challenge answers as the first did and sends nothing. - The browser finishes.
agent.login.finish {intent_id, code}, with no secret, signs the browser in through the identity app'sauth.email.code_verify. A person with two-factor authentication completes it on the page (auth.mfa), then the page calls finish again with their new session. When the request names aloopback_port, the answer also carries a one-timegrantandgrant_expires_at. - The agent gets its session. There are two ways:
- Same machine. The page sends the browser to
http://127.0.0.1:<loopback_port>/done?intent=<intent_id>&grant=<grant>. The CLI answers with a redirect to the dashboard and callsagent.login.finish {intent_id, intent_secret, grant}. - Any other case. The page tells the person to give the agent the same code if it asks. The agent calls
agent.login.finish {intent_id, intent_secret, code}.
- Same machine. The page sends the browser to
Before the redirect, the page probes http://127.0.0.1:<loopback_port>/alive with a one-second timeout. The
CLI answers the browser's Private Network Access preflight for the platform origin alone
(Access-Control-Allow-Origin the exact origin and Access-Control-Allow-Private-Network: true). If the probe
fails, the page signs the browser in, opens the dashboard button, and tells the person to give the agent the
same code if it asks.
The dashboard the CLI sends the browser to is the platform setting agent_login_dashboard_url, read from the
request. It never comes from the query.
One code, two uses
The code can be used once by the browser and once by the agent, in either order. Each use is recorded, and a
third returns LOGIN_CODE_SPENT. A grant permits one exchange within 60 seconds and requires the request's
intent_secret. If the CLI never receives the grant, the agent can still use the code.
When the agent uses the code before the browser does, the browser's later finish answers agent_first: true.
The page then tells the person that their agent is signed in and can open the dashboard for them
(substrio open --dashboard).
The session
The agent's session is a bearer session of the client substrio-cli, in the person's personal account. Its
answer is agent.signup.finish's answer: account, app_defaults, next_steps, and session with key,
refresh_token, and secret_expires_at. The status is 201 when the address got its account just now, and
200 when it had one.
- The access secret lasts
agent_login_access_seconds(default 3600). agent.login.refresh {refresh_token}swaps the pair. It answersaccess_token,refresh_token,access_expires_at, andrefresh_expires_at. The old refresh token is spent. Foragent_refresh_retry_seconds(default 60) after the swap it answers the same pair again, so a client whose answer was lost can retry. After that it is refused, like every older token.- The session lasts
agent_login_refresh_days(default 30), counted again from each refresh. agent.session.revokeends it at once, like any session.
Operations
| Operation | Route | Credential |
|---|---|---|
agent.login.begin |
POST /v1/_agent/login/begin |
none |
agent.login.show |
POST /v1/_agent/login/show |
none |
agent.login.challenge |
POST /v1/_agent/login/challenge |
none, with a Turnstile token |
agent.login.finish |
POST /v1/_agent/login/finish |
none, or the person's session after two-factor authentication |
agent.login.refresh |
POST /v1/_agent/login/refresh |
the refresh token in the body |
auth.turnstile |
GET /v1/_auth/turnstile |
none: answers site_key and gate (on, closed, or off) |
Every one runs on the platform host. None names an app.
Limits
- One address may begin
agent_login_ip_per_hourrequests in an hour (default 10), and one network (an IPv4 /24 or an IPv6 /48) may beginagent_login_net_per_hour(default 30). Past either one, begin answers429RATE_LIMIT_EXCEEDEDwithRetry-After. - The platform host mails at most
auth_code_mails_per_hourcodes in an hour to people who are not signed in (default 1000), counting every caller. - A request lives
agent_login_seconds(default 900). - A request takes five wrong codes. After that, it is refused and the agent begins again.
- Every code mail on the platform host passes the bot check first. See The bot check on code mails.
Refusals
| Code | Status | When | What the agent does |
|---|---|---|---|
TURNSTILE_REQUIRED |
403 | the challenge carried no token, or the gate is closed | the page shows the check and sends its token |
TURNSTILE_FAILED |
403 | Cloudflare refused the token, or it was made on another page or host | the page resets the check and the person tries once more |
TURNSTILE_UNREACHABLE |
503 | Cloudflare could not be reached or refused the platform secret | try again in a moment |
LOGIN_INTENT_UNKNOWN |
404 | no such request, or the intent_secret is wrong |
begin again |
LOGIN_INTENT_EXPIRED |
410 | the request is older than agent_login_seconds |
begin again: substrio login --email |
LOGIN_INTENT_NOT_CHALLENGED |
409 | finish came before the code was mailed | the person opens url and passes the check |
LOGIN_CODE_SPENT |
409 | the code served the browser and the agent already, or the agent twice | nothing: the agent is signed in, or it begins again |
LOGIN_GRANT_INVALID |
404 | the grant is not the one this request minted, or it was used | ask the person for the code and finish with it |
LOGIN_GRANT_EXPIRED |
410 | the grant is older than 60 seconds | ask the person for the code and finish with it |
LOGIN_REFRESH_REFUSED |
401 | the refresh token is spent (replaced more than agent_refresh_retry_seconds ago), expired, or revoked |
sign in again |
A wrong code is 400 INVALID_ARGUMENT with reason WRONG and attempts_remaining, as auth.email.code_verify
answers it.
Example
# The agent begins, listening on 127.0.0.1:49152.
curl -s https://mcp.substrio.com/v1/_agent/login/begin -H 'content-type: application/json' \
-d '{"email":"[email protected]","agent_name":"laptop","loopback_port":49152}'
# {"intent_id":"5f0c...","intent_secret":"...","match_code":"K7Q-3MZ",
# "url":"https://substrio.com/start?intent=5f0c...","dashboard_url":"https://substrio.com/app/","expires_at":1793000900}
# The person opens url, checks K7Q-3MZ, passes the check, and types the code. Their browser is signed in,
# and it opens http://127.0.0.1:49152/done?intent=5f0c...&grant=... on the agent's machine.
# The agent exchanges the grant.
curl -s https://mcp.substrio.com/v1/_agent/login/finish -H 'content-type: application/json' \
-d '{"intent_id":"5f0c...","intent_secret":"...","grant":"..."}'
# {"account_id":7,...,"session":{"key":"substrio_ab_...","refresh_token":"...","secret_expires_at":1793004500}}
# If the browser never reached it, the agent asks the person for the code and finishes with that instead.
curl -s https://mcp.substrio.com/v1/_agent/login/finish -H 'content-type: application/json' \
-d '{"intent_id":"5f0c...","intent_secret":"...","code":"482913"}'
Kinds
| Kind | What the agent holds | When to use it |
|---|---|---|
bearer (default) |
One static secret, substrio_ab_... |
Any client. Short-lived and revocable at once. |
ratcheted |
A key that changes on every request. Each reply carries the next key in Substrio-Next-Keys. |
Hardening for an agent that can keep the latest key. |
key_bound |
A key pair. Each request carries a proof signed by the agent's private key (RFC 9449 style). | Hardening for an agent that can sign requests. |
link |
A link, https://<host>/v1/_agent/link/substrio_al_...: the secret is in the URL's path. |
An agent that can only open links (ChatGPT on the web). Made only by opening GET /v1/_agent/go/<code> (agents that can only open links); lives as a bearer session does. |
Ask for a kind with "kind" on exchange or delegate. If you send a
public_key and no kind, you get a key_bound session. Otherwise the
session is bearer. link is not asked for: an exchange or delegate that
names it is INVALID_ARGUMENT (field kind).
Bearer sessions
- The credential:
substrio_ab_followed by the session id and a 32-byte random secret, 76 characters in all. It is shown once. - What is stored: only the secret's SHA-256 digest.
- How long it lasts:
- At most
agent_bearer_max_seconds(default 3600) from when it was made. - It also ends after
agent_bearer_idle_seconds(default 900) with no request. - Both are platform settings.
- A session made by OAuth on the platform host keeps the OAuth lifetimes
instead, and its secret is replaced on each refresh (
substrio_ar_..., stored as a digest on the same record; see mcp.md).
- At most
- What it may do: the same grant and limits as any agent session:
- the account, and its apps (or all of them);
- its capabilities;
- the member's role, at most what it was granted;
- a request rate and a request budget.
- Revoking:
agent.session.revokeends the session at once. Its children end with it, and all of them leave the session list. - A wrong secret is refused like no key at all. It also spends one of the session's rate tokens, so guessing runs no faster than the session's own rate.
Using a session on /mcp
Both the platform host's /mcp and an app's /mcp accept an agent session.
- The credential is checked once per HTTP request. The tool's grant is
checked on each
tools/call. tools/listshows only the tools the grant allows.- A tool outside the grant is refused. So is an app the session does not reach.
- A ratcheted session's next key comes back in the HTTP header
Substrio-Next-Keysof the MCP reply, whatever the JSON-RPC message says. This includes refusals.
Approvals
A call that cannot be undone, such as collection.delete, account.app.delete,
or a domain attach, does not run immediately. A person in the account approves
or denies it. After approval, the agent repeats the call.
- The tool answers with the error
APPROVAL_REQUIRED. The answer carries:approval: the approval's id (also in theSubstrio-Approvalresponse header on HTTP);approve_url: a page where the person approves or denies it, at the configured Substrio origin (agent_approval_origin, below). With no origin configured, there is noapprove_url: the id alone, and a person approves over the API.
- The agent shows the person
approve_url. The person opens it, signs in, sees which agent asks, the tool, the app and a short form of the call, and approves or denies it. A person can also decide it over the API:POST /v1/_accounts/{id}/agent/approvals/{approval}/approve(or/deny). - The agent repeats the same call. It may name the approval, or not:
- nothing at all: the call finds its own approval, because the id is made
from the session and the exact call. This is what a chat client (the
ChatGPT connector) does, since it sends no header and no
_meta; - the
Substrio-Approvalheader; params._meta["substrio/approval"]of thetools/call.
- nothing at all: the call finds its own approval, because the id is made
from the session and the exact call. This is what a chat client (the
ChatGPT connector) does, since it sends no header and no
- An approval can authorize one call. It is marked spent before execution. A repeated request cannot use that approval to run the call again.
An approval is bound to one call by one session. It covers the operation, app,
route, query, and body identifying the target. Another call requires a separate
approval. An approval expires after agent_approval_ttl_seconds (600 by default).
Until then, a repeated spent or denied call is refused without asking the person
again. After expiry, the same call requests approval again.
A call whose target does not exist opens no approval. The target is resolved first, and the call returns the same missing-target response it would without approval. Nothing is added to pending approvals. Caller refusals, such as no standing in the account or a key where a person is required, also return before approval. A pending approval is found as before; the approved call resolves its target once when it runs.
| Operation | Target | Answer when it is not there |
|---|---|---|
account.app.delete |
the account's app | TENANT_NOT_FOUND (404), naming the slug |
account.member.remove |
the account's member | ACCOUNT_MEMBER_NOT_FOUND (404) |
account.key.revoke |
the account's key | ACCOUNT_KEY_NOT_FOUND (404) |
agent.session.revoke |
an agent session the caller sees | 404 "No such agent session" |
featured.disable |
the featured app | FEATURED_APP_NOT_FOUND (404) |
subscriptions.cancel |
the person's subscription | SUBSCRIPTION_NOT_FOUND (404) |
collection.delete |
the collection | 404 |
namespace.delete |
the collection's namespace | 404 |
secret.delete |
the app's secret | SECRET_NOT_FOUND (404, field key) |
user.delete |
the app's person | 404 (a person already erased softly can still be erased for good) |
file.delete |
the file | FILE_NOT_FOUND (404) |
tenant.domain.detach |
a domain attached to the app | 404 |
tenant.key.revoke |
an earlier key of the app | TENANT_KEY_NOT_FOUND (404); the current key is refused (400) |
backup.restore, backup.clone_into |
the app's backup (and the app it goes into) | FILE_NOT_FOUND (404); a backup still being made is not refused |
schema.delete is not in the table: deleting a schema that is not there
already answers success, so there is nothing to refuse.
Agent keys
An account key a person makes (at substrio.com/keys, or with
account.key.create) and hands to an agent meets the same approvals. A key's
kind is agent unless the person made it automation
(the API reference, "Account keys"); every key made before
kinds existed is an agent key. An unscoped agent key's Irreversible call, on
HTTP or on /mcp, in a batch or as a destructive SQL statement, answers
APPROVAL_REQUIRED with approval and approve_url, and runs once after a
person approves it, exactly as an agent session's call does. These differ:
- The key asks under its own id, made from the key itself: its approvals are
its own, apart from every other key's and every session's. On the approval
list its
sessionis that id and itsagentis empty. - The key acts as no member, so only an admin or owner of the account decides its approvals; a developer member cannot.
- A key presents nothing: it repeats the same call, and the call finds its own approval.
An automation key's calls run at once. Only a person signed in as themselves
makes one: a key, an agent session or the system key asking for it is refused
403 KEY_AUTOMATION_NEEDS_PERSON at once, before any approval opens (no person
could approve it). A key scoped to apps is a customer of each
and reaches no control operation, so it asks for nothing.
The approval origin
agent_approval_origin is a platform setting configured by the server operator. Its value is empty, or a
web origin: https:// or http://, a host, an optional :port, and no path,
such as https://mcp.substrio.com (255 bytes at most). Anything else is
refused: 400 INVALID_ARGUMENT. The link is always
built from this setting, never from the Host a request came in with, so a
forged Host cannot point a person at another site.
agent_approval_origin = https://mcp.substrio.com
The approval page
approve_url is <agent_approval_origin>/oauth/consent?approval=<id>&account=<account>.
The page is served by the platform beside the OAuth consent page, with the
same sign-in (password, then the authenticator code when the person has one)
and the same lockdown (its script by hash, no framing). The person signs in at
the identity app. Then the page reads the account's waiting approvals
(GET /v1/_accounts/{id}/agent/approvals) and decides the one in the link.
Only a person signed in as themselves decides: an admin or owner of the
account, or the member the agent acts as. An agent session, an agent token or
an account key never can.
agent.approval.list names, for each waiting approval:
| Field | What it is |
|---|---|
id |
the approval id |
session |
the agent session that asks |
agent |
that session's name (empty once the session ended) |
operation |
the operation, such as collection.delete |
app |
the app the call names (absent for an account call) |
summary |
a short form of the call (at most 240 bytes, ... when cut); the approval binds the whole call |
reason |
why it needs a person, such as container_destroyed |
state |
pending |
created_at, expires_at |
when it was asked for, and when it lapses (seconds) |
Refusals
Every refusal is a 403 with its own code. Each one but APPROVAL_INVALID
names the approval.
| Code | When | What the agent does |
|---|---|---|
APPROVAL_REQUIRED |
the call opened a new approval | show the person approve_url, then repeat the call once they approve |
APPROVAL_PENDING |
the call's approval is asked for and no one decided it yet | the same: approve_url and the id are in the answer again |
APPROVAL_DENIED |
a person denied the call | stop; the repeat stays refused until the approval lapses |
APPROVAL_SPENT |
the call already ran with its approval | nothing: it ran once; an identical call can ask again after the approval lapses |
APPROVAL_EXPIRED |
the presented approval lapsed before it was used | repeat the call without the id to ask again |
APPROVAL_MISMATCH |
the presented approval names another call or another session | repeat the exact call that asked for it, or the call without an id to ask for its own |
APPROVAL_INVALID |
the presented id is no approval | repeat the call without it |
KEY_AUTOMATION_NEEDS_PERSON |
account.key.create asked for kind automation and the caller is not a person signed in as themselves |
a person makes the automation key; an agent keeps its agent key |
Example: a chat client
The ChatGPT connector, signed in by OAuth on mcp.substrio.com, deletes a
collection. It sends only its bearer token.
{"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"substrio_collection_delete","arguments":{"name":"orders"}}}
The tool's result is an error:
{"error":"This call is irreversible and a person must approve it: show the person approve_url, and once they approve, repeat the same call",
"code":"APPROVAL_REQUIRED","status":403,"retryable":false,"next_action":"request_permission",
"approval":"q3Xv0bS1o2C9mW4k8Zt7Ya",
"approve_url":"https://mcp.substrio.com/oauth/consent?approval=q3Xv0bS1o2C9mW4k8Zt7Ya&account=12"}
The assistant says in the chat: "Deleting orders cannot be undone. Approve
it here: https://mcp.substrio.com/oauth/consent?approval=...". The person opens
the link, signs in and presses Approve. The page answers: "Approved. Go back
to the chat and ask the agent to make the same call again: it runs once." The
person says "done", and the assistant sends the identical tools/call again.
The collection is deleted. If the assistant sends it a third time, it gets
APPROVAL_SPENT, and nothing runs.
Listing sessions and approvals
agent.session.list (GET /v1/_accounts/{id}/agent/sessions, MCP
substrio_agent_session_list) and agent.approval.list
(GET /v1/_accounts/{id}/agent/approvals, MCP substrio_agent_approval_list)
return pages oldest first. A member sees their agents' sessions and approvals;
an admin or owner sees the account's. Both require a person signed in as
themselves. An account key cannot list either and receives 403 INSUFFICIENT_PERMISSIONS over HTTP and MCP.
| Argument | What it does |
|---|---|
limit |
the most rows on the page, 1 to 4294967295; absent: every row left |
cursor |
the next_cursor of the page before, sent back unchanged |
app |
only that app's rows: its slug, as account.app.list names it |
With app, a session is listed when its grant reaches the app: the grant
names it, or names no app at all (then it reaches every app). An approval is
listed when the call it waits for runs on that app; an account call (no app)
is listed only without app. A decided approval leaves the list at once; one
past its expires_at is not listed.
The session list holds live sessions only. A session leaves it the moment it ends, in the same commit as its end:
- revoked (
agent.session.revoke, oragent.session.revoke_all), with every session it delegated to; - idle past its limit, or past its absolute end (
expires_at): platform upkeep ends it asexpiredon its next pass, even when no request used it. An OAuth connection's session has neither: it lasts until you or its client revokes it (docs/mcp.md, "OAuth on the platform host").
Each session also carries stale_refreshes and stale_refresh_at: for an
OAuth connection, how many refresh tokens older than the one it replaced last
were presented and refused (each for its one request; the connection kept
working), and when the last came (seconds; 0 for none, and for every other
kind of session). Tokens you did not expect there are a reason to revoke it.
Every refused refresh is also a row on the session's log (operation
oauth.token, outcome refused) with oauth_error, oauth_reason and
oauth_client (docs/mcp.md, "OAuth on the platform host").
An ended session's call log is still read by its id
(agent.session.log, kept for agent_log_retention_seconds), and a second
agent.session.revoke of it answers 404.
Each answer carries next_cursor: a string while more rows follow, null on
the last page. Every page is read from the account's own list in key order,
so a page of N costs one seek and N steps, wherever the cursor is.
GET /v1/_accounts/7/agent/sessions?app=acme-web&limit=2
-> {"sessions":[{...},{...}],"next_cursor":"0000000069fd2c80a1b2..."}
GET /v1/_accounts/7/agent/sessions?app=acme-web&limit=2&cursor=0000000069fd2c80a1b2...
-> {"sessions":[{...}],"next_cursor":null}
Over MCP, substrio_agent_session_list and substrio_agent_approval_list take the same limit, cursor and app.
Refusals are 400 INVALID_ARGUMENT, with the argument in field:
field |
When |
|---|---|
limit |
0, or past 4294967295 |
cursor |
not a next_cursor of an agent list (another kind of list's cursor, any other spelling); a cursor is a place in the list (a row's time and id), so it resumes under any app |
app |
not an app of this account |
Without a credential
A call to /mcp with no credential, or with a credential that is not valid,
gets a 401. The reply carries three things:
- the OAuth discovery header (
WWW-Authenticatewithresource_metadata); error.data.sign_up: "/llms.txt", where an agent learns how to sign up;error.data.connect, one line on redeeming a connect code from your owner:POST /v1/_agent/exchange {"code":"..."}on that host, or the toolsubstrio_connecton that/mcpwith no credential.