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

# The MCP login bridge

> One page you host. It authenticates your user and hands us back a signed subject — the only part of OAuth that is yours.

[MCP export](/guides/mcp) needs to know who the person at the other end is, and only you can answer that. The login bridge is where you answer it: a single page on your host that makes sure the user is signed in, then redirects back to us with a signed assertion naming them.

Everything else in the OAuth flow is ours — discovery, client registration, consent, tokens, grants, revocation. You run no authorization server, store no clients, and issue no tokens.

Set its URL as **MCP login URL** on the [connection](/guides/connections) the agent uses. Until it is set, that agent is not exportable.

## The exchange

<Steps>
  <Step title="We send the user to you">
    During authorization we resolve the agent to its connection and redirect the browser to your login URL with four query parameters:

    | Parameter | What it is |
    | - | - |
    | `state` | Our one-time nonce. Echo it back, and sign it as `nonce`. |
    | `client` | The MCP client's name, for you to display. |
    | `agent` | The agent's name, for you to display. |
    | `scope` | The scopes the client asked for, space-separated. |

    We send no `return_to`. The callback is a fixed URL, so there is no redirect target for you to validate.
  </Step>

  <Step title="You authenticate the user">
    Their existing session if they have one, your normal login if they do not. We never see credentials.
  </Step>

  <Step title="You redirect back with an assertion">
    ```
    302 https://api.usepatchwork.co/oauth/authorize/callback?state=<state>&assertion=<jwt>
    ```
  </Step>

  <Step title="We verify, ask for consent, and issue the token">
    We match `state` to the request we stashed, verify the assertion against your key, show our consent screen, record the grant, and hand the client a code.
  </Step>
</Steps>

The whole handshake expires in 10 minutes, and `state` is claimed exactly once.

## The assertion

A JWT signed with your private key — the same key you already sign [session tokens](/guides/session-tokens) and mint responses with.

<ParamField path="sub" type="string" required>
  The subject. Opaque to us: we carry it into the grant, the run, and the token we mint against your `mint_url`, and never parse it. It must be the same subject your own API authorizes — see [Subjects](/guides/subjects).
</ParamField>

<ParamField path="aud" type="string" required>
  Exactly `https://api.usepatchwork.co/oauth/authorize/callback`. Binds the assertion to this endpoint so it cannot be replayed elsewhere.
</ParamField>

<ParamField path="nonce" type="string" required>
  The `state` value we sent you. Binds this assertion to this one authorization request.
</ParamField>

<ParamField path="exp" type="integer" required>
  Expiry. Keep it short — a couple of minutes is plenty for a redirect. We allow 30 seconds of clock skew.
</ParamField>

<ParamField path="kid" type="string" required>
  JWT header, not a claim. Names the signing key so we can match it in your JWKS.
</ParamField>

Signing must be asymmetric: `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384` or `PS512`. `HS*` is refused — we hold only your public key, and accepting a symmetric algorithm against a public key is a forgery.

### Which key we check it against

The connection's own `jwks_uri` if it has one, otherwise your workspace's. Most integrations have one key and should leave the connection's blank; set it only when a connection has a genuinely separate signing key for a separate user pool. Either way it is [the key you already published](/guides/session-tokens#publishing-your-key), matched by `kid`.

## Minimal implementation

```ruby theme={null}
# GET /patchwork/mcp-login
def mcp_login
  user = current_user || return(redirect_to(login_path(return_to: request.fullpath)))

  assertion = JWT.encode(
    {
      sub: subject_for(user),
      aud: "https://api.usepatchwork.co/oauth/authorize/callback",
      nonce: params[:state],
      iat: Time.now.to_i,
      exp: 2.minutes.from_now.to_i
    },
    private_key,
    "RS256",
    kid: ENV["PATCHWORK_SIGNING_KID"]
  )

  redirect_to "https://api.usepatchwork.co/oauth/authorize/callback" \
    "?state=#{CGI.escape(params[:state])}&assertion=#{assertion}"
end
```

No client store, no token endpoint, no consent UI, no grant table. Those are ours.

## It is a page, not a redirect

The bridge is yours, so anything that decides **who the subject is** belongs here — and this is the only place it can live, because we do not model your tenants.

* **Tenant or workspace choice.** A user who belongs to several picks one, and you bake the choice into `sub` — `user_9f2:acme`. The MCP client then acts in that tenant for the life of the grant.
* **Step-up MFA**, seat and plan checks, blocking a user, an interstitial explaining what is about to be connected: your policy, before you mint the subject.

Whatever you decide, decide it before signing. The subject in the assertion is what the grant records and what every later call runs as.

## Why it is safe

| Guard | What it stops |
| - | - |
| `state` is one-time, short-lived, and stored on our side | replaying or forging a whole flow |
| `aud` is our fixed callback | replaying the assertion at another endpoint |
| `nonce` must equal the `state` we issued | reusing an assertion in a different authorization |
| `exp`, with 30 seconds of leeway | reusing a captured assertion later |
| Asymmetric signature against your published key | anyone but you naming a subject |
| A fixed callback with no `return_to` | an open redirect through your bridge |

## Checklist

* The URL is set on the connection the agent uses.
* Signed in, the bridge redirects back without the user doing anything.
* Signed out, it logs them in and then redirects back.
* `aud` is the callback URL exactly, including the scheme and host.
* `nonce` is the `state` you were given, unmodified.
* The signature is asymmetric and the `kid` is in your published JWKS.
* `sub` is the same subject string your own API authorizes, and your [mint endpoint](/guides/mint-endpoint) will accept it from the relay branch — that is how the agent gets a token for your backend on every MCP call.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.