effective_config or history.
This page is a complete reference: when to use it, the full connection lifecycle, the four
endpoints, how a connection plugs into an agent run, and how connections behave with respect
to ownership and errors.
Scope: per-user, not per-company. A connection is bound to the
(company_id, user_id)
pair on the bearer token that created it. Peers in the same company cannot see, list,
fetch, delete, or reference each other’s connections — every not-owned access returns 404
identical to a nonexistent id, so ids cannot be probed across users.When to use this
You need this API only when the MCP server you want to use requires the end user’s own OAuth authorization — for example a third-party developer platform’s MCP that returns data scoped to the caller’s account. You do not need it for:- Narrative-owned MCP servers (e.g. the Data Collaboration MCP Server).
The platform auto-authenticates these — just list the server in
mcp_servers[]with noconnection_id. - Fully public MCP servers that expose their tools without authentication. Same as
above — omit
connection_id.
connection_id. If the server returns
401 Unauthorized on tools/list, you need a connection.
Requirements the external server must meet
The connection flow only works against a server that speaks the OAuth 2.1 dialect the platform implements. DuringPOST /mcp-connections the platform performs these checks and
returns 400 with the appropriate detail on any miss:
- The server must publish OAuth protected-resource metadata (RFC 9728), with an
authorization_serversentry pointing at its issuer. - The authorization server must publish metadata via RFC 8414 (
.well-known/oauth-authorization-server) or OIDC discovery (.well-known/openid-configuration) — either is accepted. - The authorization server must advertise
S256incode_challenge_methods_supported. Servers that advertise PKCE methods but omitS256are rejected up front — the platform never falls back toplain. Servers that omit the field entirely proceed on the mandated baseline (S256). - The authorization server must expose a registration endpoint (RFC 7591). Servers that only support pre-registered clients cannot be connected this way.
redirect_uri pointing at GET /mcp-connections/callback.
Connection lifecycle
A connection moves through three statuses. Onlyconnected connections are usable by an
agent run.
Reconnecting the same server URL as the same user replaces the previous row — there is
never more than one connection per
(company_id, user_id, server_url) triple.
End-to-end flow
1
Start the connection
POST /mcp-connections with the server URL and a routing alias. The platform discovers
the server, verifies PKCE support, registers a client, persists a pending row, and
returns an authorization_url.2
User consents
Direct the user to the returned
authorization_url in a browser. They log in to the
external service and grant access.3
Callback parks the authorization code
The external authorization server redirects the browser to
GET /mcp-connections/callback?code=...&state=.... This endpoint is public — the
browser carries no Narrative bearer — so it does not exchange the code itself. It
stores the code against the flow, then returns 303 to the Narrative app’s callback
page with the state (and any error) in the query string.4
Signed-in user completes the connection
The Narrative app’s callback page calls
POST /mcp-connections/complete with the
state, using the signed-in user’s own bearer. The platform verifies the flow belongs
to that user, exchanges the stored code (with the PKCE verifier and resource
indicator), validates the new token with a tools/list call, encrypts the access +
refresh tokens under a dedicated KMS key, and flips the row to connected.5
Use it in an agent run
Reference the
connection_id from the matching entry in mcp_servers[] when creating a
conversation. The platform resolves the bearer server-side at every tools/list and
tools/call, refreshing it if the access token has expired.API endpoints
All endpoints live under/mcp-connections. Every endpoint except the callback requires a
Bearer token with agent_conversations read/write permission.
POST /mcp-connections
Starts the OAuth flow. Request body:
string (uri)
required
Absolute
http(s) URL of the external MCP server. Must satisfy the requirements listed
above (RFC 9728 metadata, RFC 7591 registration, S256 PKCE).string
required
Short routing prefix for this server’s tools inside an agent run. 1–64 characters of
[A-Za-z0-9_]. No dashes — the dash is reserved as the {alias}-{tool} routing
separator at tool-call time.201 Created):
string (uuid)
Id of the pending connection. You reference this from
mcp_servers[].connection_id once
the connection reaches connected.string (uri)
The external authorization server’s consent URL, with
client_id, redirect_uri,
state, the PKCE code_challenge (S256), and the resource indicator already applied.
Open it in the user’s browser.authorization_url above) or fails — it never half-connects. Failures use
the same RFC 7807 shape as the rest of the Agent Conversations API: a type linking to the error page, a
short detail, and a log_id for support (the low-level cause — endpoint, status, response body — is
logged server-side under that id).
“Can’t connect” often means “use it open.” When a connection can’t be established, the platform probes
the server unauthenticated; if it serves tools with no auth, the error names them — e.g. “3 tool(s) are
available without authorization — add the URL to
mcp_servers[] with no connection_id.” Some servers
(Mintlify-hosted docs MCPs, for example) are open by default and only enable DCR for gated content, so a
connect failure could be a signal to use the server without auth.
GET /mcp-connections
Returns the caller’s connections. Not paginated; empty state is { "connections": [] }.
GET /mcp-connections/{id}
Returns one connection using the same status shape. Returns 404 for both nonexistent ids
and ids owned by a different user — the two are indistinguishable by design.
DELETE /mcp-connections/{id}
Removes the connection and its stored ciphertext, and cascades any in-flight authorization
flow state. Returns 204 on success and 404 for missing or not-owned ids.
GET /mcp-connections/callback
The OAuth redirect URI. Called by the user’s browser after they consent; carries
?code=...&state=... from the external authorization server. Public — the browser
carries no Narrative bearer, so this endpoint intentionally does not exchange the
code.
Instead, it:
- Looks up the pending flow by
state. - Stores the authorization code against the flow, server-side.
- Returns
303to the Narrative app’s callback page, forwardingstate(anderroron failure) in the query string.
POST /mcp-connections/complete with the signed-in
user’s own bearer to finish the connection.
Why the callback is split from the exchange. If the public callback also exchanged
the code, anyone who could reach it could start a flow of their own, then get a victim
to open the resulting consent URL — the victim’s access token would end up on the
attacker’s connection. Splitting the flow closes that gap: the
state only lets you
deposit a code, and exchanging one requires an authenticated request from the user the
flow belongs to.Location always points at the app’s callback page. On success only state
is present; on failure error is added and takes one of a fixed set of values:
POST /mcp-connections/complete
Finishes a connection after the callback has parked the authorization code. The Narrative
app calls this from its callback page, so normally you do not call it yourself; it is
part of the public API because an integration can drive the redirect itself.
Request body:
string
required
The one-time state issued by
POST /mcp-connections and forwarded to the app by
GET /mcp-connections/callback. Consumed once the connection completes.- Looks up the authorization flow by
state(400 if unknown or expired). - Verifies the connection behind that flow belongs to the calling
(company_id, user_id)— a connection owned by another user returns 404, identical to a nonexistent id. - Exchanges the stored
codeat the server’s token endpoint with the flow’s PKCEcode_verifierand the resource indicator. - Validates the new access token by calling
tools/liston the MCP server. - Encrypts the access + refresh tokens under the dedicated KMS key (AAD bound to
connection_id+user_id) and flips the connection toconnected. - Consumes the one-time
state.
200 with the same status shape as GET /mcp-connections/{id}.
Only the user who started the flow can complete it. The
state alone is not enough
— the bearer token must match the flow’s owner. That is what stops a state from being
used to capture another user’s access token, and it is why the callback only stores the
code.tools/list validation consumes it, since the access token has already been issued.
Common errors:
Using a connection in an agent run
Once a connection isconnected, wire it into a conversation by adding connection_id to
the matching entry in mcp_servers[]:
tools/list (per-run discovery) and every tools/call, the platform:
- Loads the connection by id.
- Verifies the connection belongs to the run’s
(company_id, user_id). A connection owned by another user is reported as if it does not exist (the tool call fails as if the connection is missing), so runs cannot reference or probe other users’ connections. - Decrypts the access token. If it is within its expiry window it is used as-is; if expired and a refresh token is available, the platform runs a refresh exchange, encrypts and stores the new tokens, and uses the fresh access token.
- Attaches the token as
Authorization: Bearer ...on the outbound request to the MCP server.
effective_config, the
run’s message history, or GET /agents/runs/{id} responses.
For the shape of the mcp_servers[] entry, see the
Agent Conversations reference.
Security model
- Ciphertext-only at rest. Access and refresh tokens are encrypted client-side
(envelope encryption) under a dedicated KMS key (
alias/external-mcp-tokens-<stage>) before being persisted. The database never sees plaintext. - AAD binding to
(connection_id, user_id). A ciphertext row cannot be decrypted against a different connection or user — KMS itself enforces the binding at decrypt time. - No client secret at rest. The platform registers as a public PKCE client — there is no client secret to store, only the ephemeral PKCE verifier that lives with the pending flow state and is consumed on the callback.
S256only. The platform never sendsplainPKCE challenges. Servers that don’t supportS256are rejected atPOST /mcp-connections.- One-shot
state. Thestateissued inauthorization_urlis unguessable, bound to the pending row, and consumed on first use. - Callback deposits, bearer exchanges. The public callback only stores the
authorization code against its flow — it never exchanges it. The exchange runs from
POST /mcp-connections/complete, which requires a bearer whose(company_id, user_id)matches the flow’s owner. That prevents an attacker who started their own flow from luring a victim through the consent screen and capturing the victim’s token. - Per-user isolation. Every connection is scoped to the creating user; cross-user access is indistinguishable from a missing id (404).
See also
- Agent Conversations reference — how
mcp_servers[]andconnection_idplug into a run. - Data Collaboration MCP Server — the Narrative-owned
MCP server, which is auto-authenticated and needs no
mcp-connectionsflow. - MCP Discovery Failed
— the run-time error surfaced when a
tools/listagainst a registered server fails, including servers reached via an external OAuth connection.

