Skip to content

Outbound MCP connector needs OAuth/browser sign-in for authenticated MCP servers #258

Closed

Description

@serge-ivo

Context

We created a PAGS agent template called MCP client that exposes the generic outbound MCP tools:

  • mcp_list_tools
  • mcp_call_tool

This works for public/no-auth MCP servers. For example, an instance configured with:

  • mcp_url: https://mcp.freeagentstore.online/mcp
  • auth_mode: none

successfully listed FreeAgentStore MCP tools.

However, authenticated MCP servers cannot currently be connected cleanly from PAGS. A second instance configured for:

  • mcp_url: https://mcp.proagentstore.online/mcp
  • auth_mode: bearer

failed because PAGS had no stored mcp credential. Trying without bearer auth failed as unauthenticated/unparseable. This means an agent can be configured with a URL, but cannot perform the required sign-in/authorization flow for authenticated MCPs.

Problem

The generic outbound MCP connector currently appears to support only:

  • auth: none for public Streamable HTTP MCP endpoints
  • bearer auth via an already-stored PAGS vault/API key credential for provider mcp

There is no first-class OAuth/browser authorization flow from an agent instance settings page, chat flow, or connector UI. As a result, users cannot point an MCP client agent at authenticated MCP apps such as ProAgentStore, Figma, or other OAuth-protected MCP servers and authorize access naturally.

Codex-style MCP login can open a browser and complete OAuth, but PAGS outbound MCP does not currently provide the equivalent flow for agent instances.

Expected behavior

PAGS should support a connect flow for authenticated outbound MCP servers:

  1. User enters an MCP server URL in agent/instance settings.
  2. PAGS discovers or initiates the MCP auth flow for that endpoint.
  3. UI opens the browser/provider authorization page when required.
  4. OAuth/session credentials are stored securely for the user and MCP endpoint, ideally scoped to the instance/server rather than one global mcp bearer token.
  5. mcp_list_tools and mcp_call_tool use the stored credential automatically.
  6. Existing write-consent gates still apply before mutating calls through mcp_call_tool.
  7. Settings/connections UI shows auth state and allows reconnect/disconnect.

Acceptance criteria

  • Given an MCP-client instance with an OAuth-protected mcp_url, clicking Connect opens the correct browser authorization flow and returns to PAGS.
  • User does not need to manually paste a bearer token for normal OAuth MCP servers.
  • Stored credentials are bound to the current user and MCP endpoint, and preferably to the instance as well.
  • mcp_list_tools succeeds against https://mcp.proagentstore.online/mcp after authorization.
  • mcp_call_tool can call a read-only ProAgentStore MCP tool after authorization.
  • Mutating calls through mcp_call_tool continue to require connector write consent.
  • Expired, missing, or unsupported-auth states produce clear user-facing errors and a reconnect path.

Repro from current testing

  • Agent template: MCP client (mcp-client)
  • Agent id: 2c2f6747-1e67-4a73-9250-e99535466569
  • FAS instance: dfc1cf92-8721-46e9-a357-08af8629635c
    • mcp_list_tools with auth: none succeeded for https://mcp.freeagentstore.online/mcp
  • ProAgentStore instance: a9ee6079-d00f-4014-9c4c-9c03618c056d
    • bearer/default auth failed because no mcp credential was stored
    • no-auth did not successfully connect

Without this, the configurable MCP-client agent is only useful for public MCP endpoints or manually pre-provisioned bearer tokens, which breaks the expected “give it a URL and connect to an app” workflow.

Activity

  1. serge-ivo commented on Aug 6, 2026

    @serge-ivo
    ContributorAuthor

    Not done. Leaving open — but the framing needs correcting, because the reason this was deferred no longer holds, and the storage blocker is gone.

    The storage blocker is removed (#286, shipped)

    Before today an OAuth flow had nowhere to put its result: the only credential slot was user_api_keys at (user_id, "mcp"), i.e. one token for every MCP server. #286 added mcp_credentials keyed (user_id, normalized endpoint) carrying auth_mode, issuer, scopes, expires_at precisely so an OAuth flow can write there without a second store. resolveMcpCredential already dispatches on auth_mode and already fails closed on expiry with a reconnectable state. So step 4 of this ticket's expected behaviour ("credentials stored securely per user and MCP endpoint") is built; the flow that fills it is not.

    The "#180 trap" does not apply to this target — verified live

    docs/connector-auth.md defers DCR+PKCE on the grounds that "against the only live target, discovery returns interactive-only" — a 24h credential with no refresh grant, useless for cron-driven chains. That was true of FreeWebStore's MCP. It is not true of the target this ticket actually names. Fetched just now:

    GET https://mcp.proagentstore.online/.well-known/oauth-authorization-server
      grant_types_supported:            ["authorization_code","refresh_token"]   ← refresh IS issued
      code_challenge_methods_supported: ["S256"]
      registration_endpoint:            https://mcp.proagentstore.online/register  ← DCR available
      token_endpoint_auth_methods:      [... ,"none"]                            ← public client
    

    unattendedFromGrantTypes classifies that as refresh, not interactive-only. PAGS's own MCP is workers-oauth-provider (workers/mcp/src/index.ts) with DCR, S256-only PKCE and a 24h access token backed by a refresh grant. So a completed DCR+PKCE flow against it yields a credential that survives unattended — the exact property #181 said to require before wiring.

    Conclusion: this is no longer blocked on a server-side gap. It is unbuilt work. docs/connector-auth.md's "waits on a target that issues refresh tokens" clause is satisfied by our own MCP server, and it should be updated when this is picked up.

    What remains

    1. DCR client registration, cached per (user, resource origin), envelope-encrypted like every other credential — one registration per server per user, reused across instances.
    2. PKCE authorize/callback. lib/connector-oauth.ts already has HMAC-signed, nonce-bound, provider-pinned state and callback handling; the state must additionally pin the resource origin, or a state minted for server A is replayable at server B — the same class of bug the provider pin closed, and the same class MCP bearer/OAuth credentials should be scoped by endpoint, not one global mcp token #286 just closed for the token itself.
    3. Refresh-on-expiry inside resolveMcpCredential — it currently reports expired and stops, which is correct-but-manual. This is the piece that makes cron-driven chains actually survive, and it is why (1) must cache the registration: a refresh needs the client_id it was issued to.
    4. Console Connect button in McpConnections.tsx next to the existing per-server token field.

    Constraints that must hold when this is built: every hop through safeFetch (the resource URL is user config — discovery.ts already does this); the credential lands in mcp_credentials with auth_mode:"oauth", never in the legacy slot; and #262 per-(instance, endpoint, tool) consent is unchanged — authorizing a server is not consenting to its tools.

  2. serge-ivo commented on Aug 6, 2026

    @serge-ivo
    ContributorAuthor

    Shipped in 55d1769 (main, CI + Deploy API green). #180 carries the mechanism; this comment covers the connect flow and the one thing that still needs a human.

    Expected behaviour, point by point

    1. User enters a URL — unchanged, plus a first-party chip (Add a first-party ProAgentStore MCP preset with live list_agents smoke test #287).
    2. PAGS discovers the auth flow — POST /v1/mcp/oauth/start runs RFC 9728 → RFC 8414, validates the issuer, and refuses with a specific reason for: no metadata (store a token), no dynamic registration (nobody can pre-register for a URL the operator has never seen), no PKCE S256 (a plain challenge equals its verifier — there is deliberately no fallback), or metadata whose issuer is not the server that published it.
    3. UI opens the authorization page — the console's Connect button, shown only where start would succeed (dynamicRegistration && pkceS256), because a button that always reports auth_required is worse than no button.
    4. Credentials stored per user and MCP endpoint — into mcp_credentials (MCP bearer/OAuth credentials should be scoped by endpoint, not one global mcp token #286) with auth_mode:"oauth", issuer, scopes, expires_at, the token endpoint, and the refresh token in its own envelope. No parallel store, no second encryption scheme, no reveal route.
    5. mcp_list_tools / mcp_call_tool use it automatically — the connector now calls ensureMcpAccessToken, which also renews before each call, so a cron-fired chain survives with nobody present.
    6. Write consent still applies — untouched. Outbound MCP consent should be per server and per remote tool, not one global write grant #262's per-(instance, endpoint, tool) grants are unchanged, and authorizing a server grants nothing: a freshly connected server can still call nothing until tools are ticked.
    7. Auth state + reconnect/disconnect — the panel shows Authorized / Token expired / No token per server, offers Reauthorize on an OAuth server (rather than "replace token", which sends the user hunting for something that does not exist), and keeps the token field for servers issuing genuine machine credentials.

    The trap worth recording: the state must pin the resource

    This is the first flow where the remote server is chosen by the user at runtime and is not necessarily trustworthy. The operator of any server a subscriber connects sees our state in their own authorize request. A state proving only "this user started an MCP flow" is therefore replayable across servers — drive our callback with it and have a code exchanged, or a token filed, against a different endpoint.

    So, following lib/origins.ts / lib/oauth-nonce.ts rather than inventing:

    • n — browser-binding nonce (HttpOnly cookie), the existing defence against an attacker's link completed by a victim.
    • p:"mcp_oauth" — flow-type pin. The same SESSION_SIGNING_KEY signs account sessions and connector states; a payload without a type pin is one some other verifier accepts. A connector state does not verify here, and a test asserts no connector is named mcp_oauth so the reverse stays impossible.
    • f — flow id. Token endpoint, client id, redirect uri, PKCE verifier and resource all come from that ONE row, written at start time from metadata we discovered ourselves. Nothing but code is read from the callback's query string.
    • r — the resource, signed. The callback requires it to equal the claimed row's endpoint.

    The row is claimed with DELETE … RETURNING, so a replay loses the race here rather than reaching the token endpoint twice. All of these fail closed. routes/mcp.test.ts drives the real signer against the real callback for each: cross-server state, missing cookie, replay, provider error, refused exchange.

    Repro from the ticket

    Instance a9ee6079-… pointed at https://mcp.proagentstore.online/mcp no longer needs a bearer that does not exist: Test → Connect → authorize → tick tools.

    What I could NOT verify, and what to do about it

    The interactive leg — clicking through the consent screen at mcp.proagentstore.online — cannot be exercised headlessly (no session token on this machine, and the point of the step is a human). Everything else is verified: both routes answer in production (/v1/mcp/presets → 401 unauthenticated; /v1/mcp/oauth/callback → 400 with no code), the migration applied on deploy, and the metadata + POST /register were probed live against the real server.

    docs/connector-auth.md now carries a 6-step manual QA script for that one leg, including the negative checks (revoke a tool, reauthorize). Closing on the build; please run it once and reopen if the round trip disagrees.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions