Skip to main content
The MCP Connections API lets an agent run call a third-party (non-Narrative) Model Context Protocol server that requires user authorization. The platform performs the OAuth 2.1 handshake against the external authorization server — including RFC 7591 Dynamic Client Registration (DCR) and PKCE — persists the tokens KMS-encrypted, and attaches (and refreshes) the bearer server-side on every tool call. The token is never returned by the API, never appears in a request payload, and never lands in a run’s 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 no connection_id.
  • Fully public MCP servers that expose their tools without authentication. Same as above — omit connection_id.
If unsure, run the conversation first without a 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. During POST /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_servers entry 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 S256 in code_challenge_methods_supported. Servers that advertise PKCE methods but omit S256 are rejected up front — the platform never falls back to plain. 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.
The platform registers as a public PKCE client (no client secret to store) with a fixed redirect_uri pointing at GET /mcp-connections/callback.

Connection lifecycle

A connection moves through three statuses. Only connected 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.
Response (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.
Connections are DCR-only. This endpoint’s one job is to set up OAuth via Dynamic Client Registration. It either succeeds (returns the 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": [] }.
The status view is credential-free — the registered client secret and the encrypted access/refresh tokens are never returned by any endpoint.

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.
After deletion, agent runs that reference this connection_id can no longer resolve a token for that server — the next tool call fails auth. Re-connect via POST /mcp-connections to restore access. The new connection has a different connection_id; update your mcp_servers[] entries.

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:
  1. Looks up the pending flow by state.
  2. Stores the authorization code against the flow, server-side.
  3. Returns 303 to the Narrative app’s callback page, forwarding state (and error on failure) in the query string.
The app’s callback page then calls 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.
The redirect 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.
The platform:
  1. Looks up the authorization flow by state (400 if unknown or expired).
  2. 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.
  3. Exchanges the stored code at the server’s token endpoint with the flow’s PKCE code_verifier and the resource indicator.
  4. Validates the new access token by calling tools/list on the MCP server.
  5. Encrypts the access + refresh tokens under the dedicated KMS key (AAD bound to connection_id + user_id) and flips the connection to connected.
  6. Consumes the one-time state.
Returns 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.
A failed token exchange leaves the flow in place so the caller can retry; a failed 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 is connected, wire it into a conversation by adding connection_id to the matching entry in mcp_servers[]:
At 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.
The bearer never appears in the conversation payload, the run’s 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.
  • S256 only. The platform never sends plain PKCE challenges. Servers that don’t support S256 are rejected at POST /mcp-connections.
  • One-shot state. The state issued in authorization_url is 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