> ## Documentation Index
> Fetch the complete documentation index at: https://docs.narrative.io/llms.txt
> Use this file to discover all available pages before exploring further.

# MCP Connections Reference

> Connect external OAuth-protected MCP servers to your agent runs — the platform handles Dynamic Client Registration, PKCE, token storage, and refresh.

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.

<Info>
  **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.
</Info>

## 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](/reference/integrations/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.

| Status      | Meaning                                                                                                                                                                                                            |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `pending`   | Row created by `POST /mcp-connections`. Discovery + DCR succeeded, the authorization URL was issued, and the platform is waiting for the user to consent in a browser and be redirected back. No tokens exist yet. |
| `connected` | The callback exchanged the authorization code for tokens, a `tools/list` probe validated the token, and the ciphertext is stored. `tokenFor` is ready to attach the bearer on tool calls.                          |
| `error`     | A terminal failure state for a connection that could not be established or maintained.                                                                                                                             |

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

<Steps>
  <Step title="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`.
  </Step>

  <Step title="User consents">
    Direct the user to the returned `authorization_url` in a browser. They log in to the
    external service and grant access.
  </Step>

  <Step title="Callback completes the connection">
    The external authorization server redirects the browser to
    `GET /mcp-connections/callback?code=...&state=...`. The platform exchanges the code
    (with the stored PKCE verifier), validates the token with a `tools/list` call, encrypts
    the access + refresh tokens under a dedicated KMS key, and flips the row to
    `connected`. The user sees a short plain-text confirmation.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## API endpoints

All endpoints live under `/mcp-connections`. Every endpoint except the callback requires a
Bearer token with `agent_conversations` read/write permission.

| Method   | Path                        | Auth   | Purpose                                                                                                               |
| -------- | --------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- |
| `POST`   | `/mcp-connections`          | Bearer | Begin a connection — discovery, DCR, and pending-row creation. Returns the `authorization_url` for the user to visit. |
| `GET`    | `/mcp-connections`          | Bearer | List the caller's connections. Not paginated.                                                                         |
| `GET`    | `/mcp-connections/{id}`     | Bearer | Fetch one of the caller's connections.                                                                                |
| `DELETE` | `/mcp-connections/{id}`     | Bearer | Delete a connection and its stored ciphertext. Any in-flight authorization flow state cascades.                       |
| `GET`    | `/mcp-connections/callback` | Public | OAuth redirect target. Authenticated by the one-time `state`, not a bearer. You do not call this directly.            |

### `POST /mcp-connections`

Starts the OAuth flow. Request body:

```json theme={null}
{
  "url": "https://huggingface.co/mcp",
  "alias": "huggingface"
}
```

<ParamField body="url" type="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).
</ParamField>

<ParamField body="alias" type="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.
</ParamField>

Response (`201 Created`):

```json theme={null}
{
  "connection_id": "b3f1c2a4-5d6e-47f8-9a0b-1c2d3e4f5a6b",
  "authorization_url": "https://huggingface.co/oauth/authorize?response_type=code&client_id=...&code_challenge_method=S256&state=..."
}
```

<ResponseField name="connection_id" type="string (uuid)">
  Id of the pending connection. You reference this from `mcp_servers[].connection_id` once
  the connection reaches `connected`.
</ResponseField>

<ResponseField name="authorization_url" type="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.
</ResponseField>

Common 400 causes:

| Cause                   | Detail                                                                        |
| ----------------------- | ----------------------------------------------------------------------------- |
| Invalid server URL      | `url` is not an absolute `http(s)` URL                                        |
| Invalid alias           | `alias` is empty, over 64 chars, or contains a dash / non-`[A-Za-z0-9_]` char |
| Unsupported PKCE method | server advertises PKCE methods but not `S256`                                 |
| DCR unsupported         | server advertises no registration endpoint                                    |
| External OAuth error    | discovery / registration against the external server failed                   |

### `GET /mcp-connections`

Returns the caller's connections. Not paginated; empty state is `{ "connections": [] }`.

```json theme={null}
{
  "connections": [
    {
      "connection_id": "b3f1c2a4-5d6e-47f8-9a0b-1c2d3e4f5a6b",
      "server_url": "https://huggingface.co/mcp",
      "alias": "huggingface",
      "authorization_server": "https://huggingface.co",
      "status": "connected",
      "expires_at": "2026-07-12T18:30:00Z",
      "created_at": "2026-07-12T17:00:00Z",
      "updated_at": "2026-07-12T17:02:11Z"
    }
  ]
}
```

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.

<Warning>
  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.
</Warning>

### `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. **No bearer token** — the
one-time, single-use `state` (bound to a stored PKCE `code_verifier`) is the credential.

On success the platform:

1. Looks up the pending flow by `state`.
2. Exchanges the `code` at the server's token endpoint with the PKCE verifier and resource
   indicator.
3. Validates the new access token by calling `tools/list` on the server.
4. Encrypts the access + refresh tokens under the dedicated KMS key (AAD bound to
   `connection_id` + `user_id` — a row cannot be decrypted against a different connection)
   and flips the connection to `connected`.
5. Consumes the one-time `state`.

The response is a short `text/plain` page — for example, `MCP connection established. You
can close this window.` — for the user to close in their browser. Failures return 400 (bad
or expired `state`, exchange error, validation error) or 404 (the connection the `state`
referred to no longer exists).

## 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[]`:

```json theme={null}
{
  "defaults": {
    "model": "anthropic.claude-opus-4.6",
    "data_plane_id": "...",
    "execution_cluster": "shared",
    "mcp_servers": [
      {
        "alias": "hf",
        "url": "https://huggingface.co/mcp",
        "connection_id": "b3f1c2a4-5d6e-47f8-9a0b-1c2d3e4f5a6b"
      }
    ]
  }
}
```

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](/reference/architecture/agent-conversations#tool-catalog).

## 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.
* **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](/reference/architecture/agent-conversations) — how
  `mcp_servers[]` and `connection_id` plug into a run.
* [Data Collaboration MCP Server](/reference/integrations/mcp-server) — the Narrative-owned
  MCP server, which is auto-authenticated and needs no `mcp-connections` flow.
* [MCP Discovery Failed](/reference/architecture/agent-conversations/errors/mcp-discovery-failed)
  — the run-time error surfaced when a `tools/list` against a registered server fails,
  including servers reached via an external OAuth connection.
