Skip to content

Discover MCP OAuth scopes from server metadata at connect#1032

Merged
RhysSullivan merged 6 commits into
UsefulSoftwareCo:mainfrom
gjermundgaraba:gjermund/oauth-scopes
Jun 27, 2026
Merged

Discover MCP OAuth scopes from server metadata at connect#1032
RhysSullivan merged 6 commits into
UsefulSoftwareCo:mainfrom
gjermundgaraba:gjermund/oauth-scopes

Conversation

@gjermundgaraba

@gjermundgaraba gjermundgaraba commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

I noticed while trying to add an MCP server that required OAuth scopes that they were not being discovered automatically (Slack in this case). You typically don't tell the MCP server up-front which scopes you want to use (although I believe that is possible, but I decided not to open that can of worms).

Edit: added a video of how the problem first showed up:
https://github.com/user-attachments/assets/25930f4e-e9d9-4361-839f-2a79e9f08734

After the changes:
https://github.com/user-attachments/assets/0ac9fd4c-b314-4177-a462-869aad69a728

The main idea was to add change from a static list of scopes to a resolvable "OAuth Scope Policy". I could tell you more myself, but I'll hand it over to the clanker for a more detailed explanation. But before I do, feel free to ask me for any changes if there are any concerns, cleanups or changes you’d like to see :)

Take it away, clanker!
🤖:
MCP servers tell you their OAuth scopes at runtime (via RFC 9728/8414 metadata) instead of declaring them in a static auth template up front. Our old code could only pull declared scopes, so MCP integrations always came back with [] — which meant we'd request no scopes at connect and end up with under-scoped tokens.

This branch teaches oauth.start to discover the scopes from the server's metadata when an integration doesn't declare any of its own.

The main pieces:

  • Scope resolution is now a policy instead of a flat scope list. It's either "use these declared scopes" (the old behavior, untouched) or "go discover them" (what MCP uses).
  • Discovery reads the resource's RFC 9728 scopes_supported, and if that's empty, falls back to the scopes advertised by the authorization servers it points at. It only ever talks to servers the metadata names — no probing random URLs.
  • Guardrails so a slow or hostile server can't wreck us: max 3 auth servers, max 100 scopes, and a 30s overall timeout.
  • DCR connect UI cleanup. When auto-setup can't finish, we now hand the probe results over to the manual "bring your own app" form so it comes up pre-filled instead of blank. And MCP integrations (which have no endpoints until you've probed) no longer auto-grab some unrelated provider's OAuth app — you get a "register an app" prompt instead.

Anything to worry about?

Not really. No migrations, no schema changes. Anything that declares its own scopes behaves exactly as before — this only kicks in for integrations that declare none. The upshot is MCP connections finally request the right scopes, and the connect flow is less likely to silently do the wrong thing.

Tests

Unit tests for the scope caps and the discover paths, an MCP test confirming the discovered scopes actually show up on the authorize URL, and a selfhost e2e that runs the whole thing over loopback against a real MCP server. One gap I'll call out: the 30s timeout doesn't have an automated test — testing it cleanly would need a deliberately-hanging server.

MCP-style integrations declare no OAuth scopes of their own; the scopes live on the server. Replace the declared/client scope union with an integration-driven scope policy: resolveOAuthScopePolicy returns either the template's declared scopes or a { kind: "discover" } marker. For the discover case, oauth.start reads the request scopes from the client's RFC 9728 protected-resource metadata, falling back to the RFC 8414 authorization-server metadata it names.

Harden the transparent-DCR connect flow: carry the probe result through the bring-your-own-app fallback, match registered apps against discovered endpoints (requireEndpointMatch), and seed the register form with discovered scopes and token-endpoint auth methods via a shared registrationScopes helper.

Add a self-host e2e scenario covering MCP OAuth scope discovery.

Entire-Checkpoint: dc839672b8b0
discoverScopesForResource takes its input entirely from a remote MCP server (the protected-resource metadata and the authorization servers it names). Add three bounds, each guarding a distinct failure: cap the authorization-server probe list at 3 (outbound request fan-out to server-named hosts), cap the discovered scope count at 100 (authorize-URL length), and wrap the whole sequence in a 30s timeout (total wall-clock; larger than the per-request timeout so it bounds the sequence, not a single slow-but-valid request).

Tests: a 200-scope advertisement capped to 100, a four-AS list where the 3-server cap stops before the fourth (its scope never appears), and client_credentials reaching the discover path with the discovered scopes recorded on the connection.
Entire-Checkpoint: f23d8ac3c4e2
@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds RFC 9728/8414 OAuth scope discovery for MCP integrations. Instead of a static declared-scope list, the scope source is now a policy: either "use these declared scopes" (existing behavior) or "discover them at connect from the server's protected-resource and authorization-server metadata" (new MCP path).

  • Core policy switch (oauth-service.ts, executor.ts): resolveOAuthScopePolicy returns {kind:"scopes",scopes} or {kind:"discover"}; for the discover case discoverScopesForResource follows RFC 9728 → RFC 8414 with a 3-server cap, 100-scope cap, and a 30 s overall timeout.
  • DCR connect UI cleanup (add-account-modal.tsx): failed DCR attempts now pass probe data to the BYO form so it pre-fills instead of opening blank; requireEndpointMatch prevents auto-selecting an unrelated provider's OAuth app for server-targeting MCP integrations that have no static endpoints.
  • Test coverage: unit tests for each discovery path and cap, an MCP plugin test that exercises the full oauth.start → discover → authorize-URL chain, and two selfhost e2e tests (in-process stub + deployed emulate server).

Confidence Score: 5/5

Safe to merge — no regressions to existing declared-scope paths, discovery is guarded by caps and a timeout, and previous SSRF/resource-indicator concerns raised in earlier review threads are addressed by this PR.

The scope policy is a clean discriminated union with no shared mutable state; integrations that declare their own scopes are completely unaffected. Discovery only fires when a method explicitly has a discoveryUrl and no declared scopes, so MCP is the sole opt-in path. The guardrails are unit-tested and the fast/happy paths are covered by both unit and selfhost e2e tests.

No files require special attention.

Important Files Changed

Filename Overview
packages/core/sdk/src/oauth-service.ts Adds OAuthScopePolicy discriminated union, discoverScopesForResource with 3-AS cap / 100-scope cap / 30s timeout, and wires the discover path into oauth.start. Logic matches RFC 9728 §7.2 priority. No issues found.
packages/core/sdk/src/executor.ts Renames resolveDeclaredOAuthScopes to resolveOAuthScopePolicy; returns {kind:discover} when method has discoveryUrl but no declared scopes. Behavioral change for multi-method integrations is intentional and documented.
packages/react/src/components/add-account-modal.tsx DCR connect overhaul: DcrOutcome now carries probe data on non-probe-fail fallbacks; oauthFallbackProbe state pre-fills the BYO form on DCR failure; requireEndpointMatch prevents spurious app auto-selection for MCP integrations.
packages/react/src/components/oauth-client-form.tsx Adds discoveredScopes state and registrationScopes helper (declared wins, discovered fallback); tokenEndpointAuthMethodsSupported prefill seeds the DCR auth method.
packages/react/src/plugins/use-effective-oauth-client.tsx Adds requireEndpointMatch flag that returns no matches when no endpoints are declared, preventing MCP from auto-selecting an unrelated provider app.
packages/core/sdk/src/oauth-scope-union.test.ts Comprehensive unit tests for all discovery paths: PRM silent, PRM authoritative, cross-origin AS, explicit empty set, 500 error, no-resource error, 100-scope cap, 3-AS cap, and client_credentials recorded-scope fallback.

Reviews (5): Last reviewed commit: "Never seed RFC 8707 resource indicator f..." | Re-trigger Greptile

Comment on lines +376 to +386
for (const issuer of (protectedResource?.metadata.authorization_servers ?? []).slice(
0,
MAX_DISCOVERY_AUTH_SERVERS,
)) {
const authServer = yield* discoverAuthorizationServerMetadata(
issuer,
discoveryOptions,
).pipe(Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null)));
const scopes = authServer?.metadata.scopes_supported;
if (scopes !== undefined) return capScopes(scopes);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 security SSRF via server-controlled authorization_servers URLs

discoverScopesForResource follows authorization_servers entries from PRM metadata that is fetched from a server-controlled URL. A hostile MCP server can publish PRM whose authorization_servers list points at internal addresses (e.g., http://169.254.169.254/, http://10.0.0.1/) — the executor will then issue GET requests to /.well-known/oauth-authorization-server at those hosts. This is a new SSRF surface: previous discovery calls were triggered by explicit user-entered URLs; this one fires automatically at every oauth.start for any MCP integration.

The impact is limited (read-only GET to a predictable path), and endpointUrlPolicy can block private ranges, but that policy is optional and not enforced by this code. Worth verifying that all deployment targets inject an endpointUrlPolicy that rejects private/link-local addresses before this ships.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

claude - Reviewed this with the maintainer. The scope-only endpointUrlPolicy (https:/loopback) isn't the SSRF defense here, so this is mitigated on hosted/cloud deployments.

The actual guard is the network layer: hosted hosts wire makeHostedHttpClientLayer({ allowLocalNetwork }) as the executor's httpClientLayer (packages/core/api/src/server/scoped-executor.ts), and discovery fetches flow through it via discoveryOptions.httpClientLayer. That guard (packages/core/sdk/src/hosted-http-client.ts):

  • blocks RFC 1918, link-local (169.254.x), cloud metadata (169.254.169.254 / metadata.google.internal), loopback, and IPv6 ULA;
  • resolves the hostname via DNS and checks the resolved addresses (defeats DNS rebinding), not just the literal;
  • re-validates every redirect hop and strips credentials on cross-origin redirects;
  • allowLocalNetwork defaults to false in prod (cloud / cloudflare / selfhost); only local dev enables it.

So a hostile PRM naming authorization_servers: ["https://10.0.0.1"] is blocked at fetch time, and the 3-AS fan-out is bounded by the same guard. No change needed on this PR. (Longer term it'd be nice to fold private-range checks into endpointUrlPolicy itself as defense-in-depth, but that's a separate, pre-existing concern, not introduced here.)

authorizationUrl: probe.authorizationUrl,
tokenUrl: probe.tokenUrl,
resource: probe.resource ?? null,
resource: probe.resource ?? input.discoveryUrl,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Token-endpoint URL stored as RFC 8707 resource indicator for non-MCP DCR integrations

input.discoveryUrl is method?.oauth?.discoveryUrl ?? method?.oauth?.tokenUrl (see handleDcrConnect). For non-MCP DCR integrations that have no discoveryUrl (so the fallback is tokenUrl) and whose server's PRM does not explicitly name a resource, this line stores the token-endpoint URL as the RFC 8707 resource indicator. RFC 8707 resource is supposed to identify the protected resource server, not the token endpoint. A resource indicator equal to the token URL could cause authorization servers that validate it (e.g., Azure AD, Keycloak) to reject the token request.

The new behaviour (vs. the old probe.resource ?? null) means these clients now always have a non-null resource, so discoverScopesForResource will attempt to probe that URL as a protected resource — even though it is a token endpoint. For MCP integrations the discoveryUrl is always set and correct, so only non-MCP DCR flows are affected.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

claude - Good catch, this is real (though latent), and I pushed a fix in 1d39a22.

Confirmed the only DCR users today are MCP integrations, which always set oauth.discoveryUrl, so it never bites in practice yet. But resource: probe.resource ?? input.discoveryUrl with discoveryUrl = method.oauth.discoveryUrl ?? method.oauth.tokenUrl means a future non-MCP DCR integration would register its token endpoint as the RFC 8707 resource indicator and have oauth.start probe it as a protected resource.

Fix threads the genuine discovery URL through as a distinct resourceFallback (undefined for non-MCP), so the indicator collapses to null as before instead of the token URL. MCP flows are unchanged. Added a regression test asserting the token endpoint never becomes the resource for a no-discovery-URL DCR client.

RhysSullivan and others added 4 commits June 15, 2026 13:10
Add an emulate-backed counterpart to the in-process scope-discovery selfhost
test. It points at the @executor-js/emulate GitHub MCP `scope-discovery`
instance, whose protected-resource metadata is silent on scopes so oauth.start
must fall back to the authorization-server metadata it names, and asserts the
authorize URL carries exactly the discovered scopes. Keeps the hermetic
in-process test; adds real-deployment fidelity.
Entire-Checkpoint: a92ee78e0524
Entire-Checkpoint: d3ea2960c9ec
DCR's resource fallback used the probe discovery URL, which collapses to
the token endpoint for integrations without a real discovery URL. The
token endpoint is not a resource identifier, so a non-MCP DCR client
could register the token URL as its RFC 8707 resource indicator and have
oauth.start probe it as a protected resource.

Thread the genuine discovery URL (the MCP server URL) as a distinct
resourceFallback; when absent, the indicator stays null as before. MCP
flows are unchanged. Adds a regression test for the non-MCP case.
@RhysSullivan RhysSullivan merged commit 5ecb95f into UsefulSoftwareCo:main Jun 27, 2026
10 checks passed
@gjermundgaraba gjermundgaraba deleted the gjermund/oauth-scopes branch June 28, 2026 08:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants