Repository navigation
Outbound MCP connector needs OAuth/browser sign-in for authenticated MCP servers #258
Description
Context
We created a PAGS agent template called MCP client that exposes the generic outbound MCP tools:
mcp_list_toolsmcp_call_tool
This works for public/no-auth MCP servers. For example, an instance configured with:
mcp_url:https://mcp.freeagentstore.online/mcpauth_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/mcpauth_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: nonefor 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:
- User enters an MCP server URL in agent/instance settings.
- PAGS discovers or initiates the MCP auth flow for that endpoint.
- UI opens the browser/provider authorization page when required.
- OAuth/session credentials are stored securely for the user and MCP endpoint, ideally scoped to the instance/server rather than one global
mcpbearer token. mcp_list_toolsandmcp_call_tooluse the stored credential automatically.- Existing write-consent gates still apply before mutating calls through
mcp_call_tool. - 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_toolssucceeds againsthttps://mcp.proagentstore.online/mcpafter authorization.mcp_call_toolcan call a read-only ProAgentStore MCP tool after authorization.- Mutating calls through
mcp_call_toolcontinue 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-08af8629635cmcp_list_toolswithauth: nonesucceeded forhttps://mcp.freeagentstore.online/mcp
- ProAgentStore instance:
a9ee6079-d00f-4014-9c4c-9c03618c056d- bearer/default auth failed because no
mcpcredential was stored - no-auth did not successfully connect
- bearer/default auth failed because no
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
serge-ivo commentedon Aug 6, 2026
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_keysat(user_id, "mcp"), i.e. one token for every MCP server. #286 addedmcp_credentialskeyed(user_id, normalized endpoint)carryingauth_mode,issuer,scopes,expires_atprecisely so an OAuth flow can write there without a second store.resolveMcpCredentialalready dispatches onauth_modeand 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.mddefers DCR+PKCE on the grounds that "against the only live target, discovery returnsinteractive-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:unattendedFromGrantTypesclassifies that asrefresh, notinteractive-only. PAGS's own MCP isworkers-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
(user, resource origin), envelope-encrypted like every other credential — one registration per server per user, reused across instances.lib/connector-oauth.tsalready 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 theproviderpin 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.resolveMcpCredential— it currently reportsexpiredand 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 theclient_idit was issued to.McpConnections.tsxnext 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.tsalready does this); the credential lands inmcp_credentialswithauth_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.feat(mcp): connect an OAuth MCP server instead of pasting a token it …
serge-ivo commentedon Aug 6, 2026
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
POST /v1/mcp/oauth/startruns 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 (aplainchallenge equals its verifier — there is deliberately no fallback), or metadata whoseissueris not the server that published it.dynamicRegistration && pkceS256), because a button that always reportsauth_requiredis worse than no button.mcp_credentials(MCP bearer/OAuth credentials should be scoped by endpoint, not one global mcp token #286) withauth_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.mcp_list_tools/mcp_call_tooluse it automatically — the connector now callsensureMcpAccessToken, which also renews before each call, so a cron-fired chain survives with nobody present.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
statein 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.tsrather 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 sameSESSION_SIGNING_KEYsigns 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 namedmcp_oauthso 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 butcodeis 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.tsdrives 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 athttps://mcp.proagentstore.online/mcpno 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 /registerwere probed live against the real server.docs/connector-auth.mdnow 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.fix(mcp): the "no credential" refusal asks the server how to get one,…