Skip to content

feat(mcp): OAuth2 PKCE flow with dynamic client registration for MCP agents - #8264

Open
andypalmi wants to merge 7 commits into
feat/7431-mcp-wellknown-discoveryfrom
feat/7432-mcp-oauth-pkce
Open

feat(mcp): OAuth2 PKCE flow with dynamic client registration for MCP agents#8264
andypalmi wants to merge 7 commits into
feat/7431-mcp-wellknown-discoveryfrom
feat/7432-mcp-oauth-pkce

Conversation

@andypalmi

@andypalmi andypalmi commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the OAuth2 authorization code flow with PKCE for MCP clients, plus dynamic client registration so third-party agents can obtain a client_id without being provisioned ahead of time.

  • POST /account/client (RFC 7591): registers a public MCP client and returns a generated client_id with token_endpoint_auth_method: none and no client secret. Redirect URIs are validated (loopback with port flexibility per RFC 8252, otherwise HTTPS).
  • The authorize, consent, token and refresh handlers recognise MCP clients by looking up the registered client rather than a fixed id.
  • AuthClient gains type, name and redirectURIs (migration + model) with a createMCPClient controller.
  • The authorization code exchange mints a scoped access token for the user, honouring the access level and teams chosen at consent.
  • Adds a platform_get_active_user tool so an agent can resolve its own user id and, alongside the profile, read back what its token is allowed to do (token.readOnly, token.allTeams, token.teams). This is the introspection counterpart to the scoped token above, letting an agent check up front whether a requested action is within the session's access. With no scope (the first-party Expert path) it reports the user's own unrestricted access, since Expert acts with the same permissions the user has.

The consent UI is added in the next PR in the stack. See the issue for the note on why dynamic registration is used instead of a hardcoded client id.

Stacked on #7431. Part of #7423. Closes #7432.

Important

This PR introduces a database change. It includes a migration that runs on upgrade

@andypalmi
andypalmi marked this pull request as ready for review August 24, 2026 19:26
@andypalmi
andypalmi requested a review from cstns August 24, 2026 19:29
@andypalmi
andypalmi force-pushed the feat/7432-mcp-oauth-pkce branch from 7ab6c8c to 311e8ff Compare August 24, 2026 19:33
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.05031% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.23%. Comparing base (691aa4b) to head (7209333).

Files with missing lines Patch % Lines
forge/routes/auth/oauth.js 82.60% 16 Missing ⚠️
forge/db/controllers/AccessToken.js 97.29% 1 Missing ⚠️
...igrations/20260824-01-add-mcp-authclient-fields.js 83.33% 1 Missing ⚠️
...25-01-add-refreshTokenExpiresAt-to-AccessTokens.js 80.00% 1 Missing ⚠️
Additional details and impacted files
@@                          Coverage Diff                          @@
##           feat/7431-mcp-wellknown-discovery    #8264      +/-   ##
=====================================================================
+ Coverage                              76.11%   76.23%   +0.12%     
=====================================================================
  Files                                    447      450       +3     
  Lines                                  24011    24137     +126     
  Branches                                6405     6442      +37     
=====================================================================
+ Hits                                   18275    18400     +125     
- Misses                                  5736     5737       +1     
Flag Coverage Δ
backend 76.23% <88.05%> (+0.12%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…agents

Extend the OAuth2 flow so external MCP agents (Claude, Cursor, etc.) can
authenticate. Rather than a single hardcoded client id, agents register
dynamically per RFC 7591: POST /account/client persists a public AuthClient
(type 'mcp', no secret) with its approved redirect URIs and returns a generated
client id. The authorize, complete, and token endpoints recognise these clients
by looking them up, skip the project/device ownership checks (MCP is
user-scoped), and drive an MCP consent step that records the read-only and team
selection before issuing a scoped personal access token. Redirect URIs must be
loopback http (RFC 8252, port-flexible) or https for hosted clients; token
issuance and refresh require no client secret.

Also forward the caller scope through the platform automation handler and add a
platform_get_active_user tool that reports the calling token's scope.

Adds AuthClient.type/name/redirectURIs (migration + model), an
AuthClient.createMCPClient controller, and AccessToken.createMCPOAuthToken.

Ref #7432
@cstns
cstns force-pushed the feat/7432-mcp-oauth-pkce branch from 311e8ff to 6747cbd Compare August 25, 2026 07:52
@cstns
cstns deployed to staging August 25, 2026 07:55 — with GitHub Actions Active
andypalmi added 2 commits August 25, 2026 10:53
An AccessToken row holds both the access token and its refresh token,
and getOrExpire destroyed the row when the access token expired, taking
the refresh token with it so a client could never refresh (RFC 6749
1.5). Add a separate refreshTokenExpiresAt lifetime: reject an expired
access token but keep the row while its refresh token is still valid.

The MCP refresh token is stable rather than rotating, and concurrent
refreshes coalesce through a shared cache so they reuse the most
recently minted access token instead of overwriting the row.
@cstns cstns added the area:migration Involves a database migration label Aug 25, 2026
Capture the pre-refresh expiry after lowering it, so the assertion
compares against the shortened lifetime rather than the original one and
no longer ties when the refresh lands in the same millisecond.

@cstns cstns left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing this makes visible: with dynamic registration, third party agents become the normal case, and right now the audit log can't tell them apart from the Expert.

Every tool call, Expert or third party, goes through the same platform-automation:forge handler, and platformAutomation.js hardcodes source: 'mcp:expert' on the nonce. So a third party agent's actions show up as "via Expert" in the audit log. The frontend already has a source === 'mcp' branch ("via MCP")
but nothing ever emits it.

The client identity is also dropped on the way through. Registration captures client_name onto AuthClient.name, then createMCPOAuthToken hardcodes the token name to 'MCP Agent' and keeps no reference back to the client, so every agent looks identical in both the audit log and the user's token list. The
nonce carries no tokenId either, and inject() swaps in the Expert's platform token, so there's no indirect signal left. Only the direct REST path records a tokenId (as source: 'api').

Would be good to land this branch with the three cases clearly separated in the log:

  • mcp:expert for the first party Expert
  • mcp for a third party agent, ideally with the registered client name so you can see which agent
  • api for a plain token calling the REST API directly

server.js already threads scope down to platformAutomation, so that same seam might be the natural place to carry the caller identity, and storing the client on the token at issuance (or at least using client_name instead of 'MCP Agent') would give the log something to name. Happy to go a different route
if you have one in mind.

@andypalmi

Copy link
Copy Markdown
Contributor Author

Good catch, and I agree the three cases should be distinguishable. I have confirmed the gaps: the platform-automation handler hardcodes source 'mcp:expert' for every tool call, the issued token is named 'MCP Agent' with no link back to the AuthClient, and the frontend 'mcp' branch is never emitted.

To keep this PR focused on the OAuth mechanics I have opened a dedicated follow-up, #8271, to land the attribution end to end: link the issued token to its AuthClient and name it after the registered client, then separate the runtime source so a third party agent logs as 'mcp' with the client name, 'mcp:expert' stays for the Expert, and a direct token stays 'api'. The runtime part threads caller identity through the gateway and comms seam alongside scope and overlaps with the audit trail work, so it reads cleaner on its own.

Let me know if you would rather see any of it in this PR instead.

@andypalmi

Copy link
Copy Markdown
Contributor Author

Good call on this. #8272 now implements the full attribution, stacked on these PRs.

The MCP door records the calling client against its session, and the platform-automation handler stamps audit source mcp with the client name for a third-party agent, while the first-party Expert keeps mcp:expert. The issued token is also named after its registered client and linked to it, so the two are distinguishable in both the audit log and the user's token list.

#8271 tracks the whole thing and #8272 closes it.

@andypalmi

Copy link
Copy Markdown
Contributor Author

Heads up for reviewers: the refresh-token handling introduced here is superseded by #8273, which replaces the non-rotating refresh token with proper rotation (grace window plus replay detection, per RFC 9700 section 4.14.2). The final refresh behaviour to review lives in #8273 rather than this PR.

Comment thread forge/db/controllers/AccessToken.js
Comment thread forge/db/migrations/20260824-01-add-mcp-authclient-fields.js Outdated
Comment thread forge/db/migrations/20260825-01-add-refreshTokenExpiresAt-to-AccessTokens.js Outdated
Comment thread forge/ee/lib/mcp/tools/users.js
andypalmi added 2 commits August 25, 2026 15:30
The refresh_token grant returned access_token, expires_in and
refresh_token but omitted token_type, which RFC 6749 section 5.1
requires. A spec-compliant client rejects the response and falls back to
re-authorization even though the server rotated the token successfully.
Return token_type: bearer to match the authorization_code response.
@andypalmi
andypalmi requested a review from cstns August 25, 2026 13:46

@cstns cstns left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tested e2e with my local claude code, works as expected; we should sync with @knolleary to validate the ui for the oauth consent page

Comment thread forge/db/migrations/20260824-01-add-mcp-authclient-fields.js Outdated
Comment thread forge/db/controllers/AccessToken.js Outdated
@cstns

cstns commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

my bad, the consent page was on the next stack up

@andypalmi

Copy link
Copy Markdown
Contributor Author

Verified the refresh flow end to end against a real MCP client, on this PR's code (without the later rotation work).

Steps: authorized the client to obtain a token pair, expired the access token in the database while leaving the refresh token valid, then made another tool call. The door rejected the expired access token without destroying the row, the client refreshed via grant_type=refresh_token, and the call succeeded transparently. The row came back with a new access token, the same refresh token, and a fresh 30 minute expiry, which is the non-rotating behaviour this PR implements.

Also confirmed non-MCP OAuth is unaffected: the refresh handling is gated on refreshTokenExpiresAt, which only MCP tokens set, so editor, device and ff-plugin sessions take the same code path as before.

…ache margin

Reuse the AuthClients ownerType column with ownerType='mcp' for dynamically
registered MCP clients instead of a dedicated type column. Raise the refresh
re-mint margin to 5 minutes so the coalescing cache stays effective against
the 30 minute access-token lifetime.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:migration Involves a database migration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth2 PKCE Flow Extension for MCP Clients

2 participants