Skip to content

feat(oauth): advertise scopes via OAUTH_SCOPES_SUPPORTED - #33

Merged
marselsel merged 3 commits into
marselsel:mainfrom
gutencoder:feat/scopes-supported
Aug 7, 2026
Merged

feat(oauth): advertise scopes via OAUTH_SCOPES_SUPPORTED#33
marselsel merged 3 commits into
marselsel:mainfrom
gutencoder:feat/scopes-supported

Conversation

@gutencoder

Copy link
Copy Markdown
Contributor

Problem

mcpAuthMetadataRouter accepts a scopesSupported option and publishes it as
scopes_supported in the protected-resource metadata. From
@modelcontextprotocol/sdk@1.29.0, dist/esm/server/auth/router.d.ts:

export type AuthMetadataOptions = {
    oauthMetadata: OAuthMetadata;
    resourceServerUrl: URL;
    serviceDocumentationUrl?: URL;
    /**
     * An optional list of scopes supported by this MCP server
     */
    scopesSupported?: string[];
    resourceName?: string;
};

and its implementation puts the value straight into the document
(dist/esm/server/auth/router.js):

const protectedResourceMetadata = {
    resource: options.resourceServerUrl.href,
    authorization_servers: [options.oauthMetadata.issuer],
    scopes_supported: options.scopesSupported,
    ...
};

src/server.ts:93-96 on main never passes it, and there is no env var to configure it:

mcpAuthMetadataRouter({
  oauthMetadata: buildOAuthMetadata(oauth),
  resourceServerUrl: new URL(oauth.resource),
}),

So /.well-known/oauth-protected-resource currently serves:

{"resource":"https://mcp.example.com/","authorization_servers":["https://auth.example.com"]}

with no scopes_supported at all. A client that discovers the server through that
document (per RFC 9728, which is the discovery path the README documents for custom
connectors) is told nothing about what to request, and may omit scope from the
authorization request entirely.

Some IdPs reject that outright. Microsoft Entra fails the request with:

AADSTS900144: The request body must contain the following parameter: 'scope'

which breaks sign-in before the user ever sees a consent screen.

Note this is specifically the protected-resource document.
buildOAuthMetadata already sets scopes_supported on the authorization-server
metadata, which is a different document and not what a client reads to build its
authorization request here.

Solution

A new OAUTH_SCOPES_SUPPORTED env var: comma-separated, parsed in src/config.ts
alongside the other OAUTH_* variables, and passed through to mcpAuthMetadataRouter.

The empty case is handled by a small pure helper in src/oauth.ts:

export function advertisedScopes(oauth: OAuthSettings): string[] | undefined {
  return oauth.scopesSupported?.length ? oauth.scopesSupported : undefined;
}

Returning undefined rather than [] is the load-bearing detail: the SDK copies the
value into the metadata object and JSON.stringify drops undefined properties, so with
nothing configured the key is absent and the document is unchanged. An empty array would
instead advertise "scopes_supported": [], which is a different — and misleading —
statement to make.

Only the protected-resource document is affected; the authorization-server metadata is
untouched.

What changes for existing users

Nothing, and this is verified rather than asserted. With the variable unset, both
well-known documents are byte-for-byte identical to what main serves:

/.well-known/oauth-protected-resource
  main          : {"resource":"https://mcp.example.com/","authorization_servers":["https://auth.example.com"]}
  branch (unset): {"resource":"https://mcp.example.com/","authorization_servers":["https://auth.example.com"]}
  IDENTICAL     : true

/.well-known/oauth-authorization-server
  IDENTICAL     : true

With OAUTH_SCOPES_SUPPORTED=openid,email the resource document becomes:

{"resource":"https://mcp.example.com/","authorization_servers":["https://auth.example.com"],"scopes_supported":["openid","email"]}

Tests

npm test passes (141 tests, up from 135). New coverage:

  • tests/config.test.ts — parsing (comma-separated, trimmed, blanks dropped) and the
    default empty list.
  • tests/oauth.test.tsadvertisedScopes returns undefined for both unset and
    empty, and the configured list otherwise.
  • tests/oauth.test.ts — an end-to-end check that mounts mcpAuthMetadataRouter the way
    server.ts does, serves it over a real HTTP listener, fetches the document and asserts
    that the scopes_supported key is absent (not merely falsy) when nothing is
    configured, and present with the right value when it is.

npm run build and docker build pass. npm audit --omit=dev --audit-level=high is
unchanged from main (no dependency changes).

Docs

README.md config table and .env.example document the variable, including when you
would need it.

@gutencoder
gutencoder requested a review from marselsel as a code owner August 6, 2026 16:50
Copilot AI lite review requested due to automatic review settings August 6, 2026 16:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@marselsel

Copy link
Copy Markdown
Owner

Thanks — same quality as #32, and the same care in the writeup.

I verified the claims against the vendored SDK rather than the docs: scopesSupported?: string[] is in AuthMetadataOptions, router.js:91 copies it straight into the metadata object, and server.ts on main never passes it. Locally: typecheck clean, 141/141 tests pass.

The undefined-rather-than-[] detail is right, and worth noting it's the same mechanism the document already relies on — resource_name and resource_documentation are undefined today for exactly that reason. Asserting key absence via Object.keys instead of falsiness is the only way to actually test it, and mounting the real router on a real listener rather than mocking it makes this the strongest test in either PR.

Two things I'd like before merging, plus a rebase:

1. The fix is half-applied. buildOAuthMetadata still hardcodes scopes_supported: ["openid","email","profile"] on the authorization-server document (src/oauth.ts:55), which this PR doesn't touch. So after it, the two documents can contradict each other — I booted the merged branch under an Entra-shaped config and got:

// /.well-known/oauth-protected-resource   ← configurable after this PR
"scopes_supported": ["api://<guid>/mcp.access"]

// /.well-known/oauth-authorization-server ← still hardcoded
"scopes_supported": ["openid", "email", "profile"]

You're right that the AS document isn't what a client reads to build its authorization request under RFC 9728. But this server does serve it, at a well-known path clients do check, and oauth.ts:39-41 calls it "a convenience proxy" — a proxy that contradicts what it proxies is worse than none. Could OAUTH_SCOPES_SUPPORTED, when set, drive both documents, falling back to the current hardcoded list when unset? Backward compatible, and it keeps the two from drifting.

2. Comma-only parsing is a trap. OAUTH_SCOPES_SUPPORTED="openid email" — space-separated, which is how scopes appear essentially everywhere else in OAuth — yields a single scope "openid email", invalid per RFC 6749 §3.3. Splitting on /[,\s]+/ would remove the footgun.

3. Rebase. I'm merging #32 first, which will conflict with this in six files. Most are additive, but three need real attention rather than take-both: the AuthConfig fields share a /** opener, the two parsers share a trailing .filter(Boolean);, and the two config tests share a loadConfig({…}) preamble. Naive marker-stripping produces code that doesn't compile.

Happy to take the two changes in the same round-trip as the rebase so it's one pass, not three.

One unrelated observation while I was in here: express isn't in dependencies (only @types/express in devDeps), and your test imports it directly. That's pre-existing — src/server.ts:1 already does it and npm's flat tree makes it work — so not something to fix here. Just noting it since this adds another import site.

Contributor and others added 3 commits August 7, 2026 13:20
mcpAuthMetadataRouter accepts a `scopesSupported` option and publishes it as
`scopes_supported` in the protected-resource metadata (RFC 9728), but the
server never passed it and there was no env var to configure it. The
document therefore named no scopes at all, so a client discovering the server
through it has nothing to put in the authorization request's `scope`
parameter and may omit it — which some IdPs reject outright (Microsoft Entra:
AADSTS900144), breaking sign-in before it starts.

OAUTH_SCOPES_SUPPORTED is comma-separated. When unset, `advertisedScopes`
returns undefined rather than [], so the key is dropped from the serialized
document and both well-known responses stay byte-for-byte as before.
…RTED

buildOAuthMetadata hardcoded scopes_supported: [openid, email, profile] on the
RFC 8414 document. With OAUTH_SCOPES_SUPPORTED driving only the RFC 9728
protected-resource document, an operator configuring scopes for a non-WorkOS IdP
would end up with the two well-known documents contradicting each other.

Both now come from the same source. When unset the authorization-server document
keeps the historic default, so existing deployments are unchanged.
…RTED

A scope value can never contain a space (RFC 6749 3.3), so `openid email profile`
- the form scopes take everywhere else in OAuth - is unambiguous. Splitting on
commas alone turned it into one invalid scope named "openid email profile".
Now splits on commas or whitespace.
@marselsel
marselsel force-pushed the feat/scopes-supported branch from dfd10a6 to 4b44ea1 Compare August 7, 2026 13:24
@marselsel

Copy link
Copy Markdown
Owner

Heads-up: I've pushed to this branch rather than making you do another round-trip. Please git fetch and hard-reset your local copy before doing any further work on it — I rebased, so the history changed and your local branch has diverged.

What I did:

  1. Rebased onto main, which now has feat(oauth): accept additional token audiences via OAUTH_AUDIENCE #32 plus a lockfile fix (7ba671e) for the audit-gate failures you were seeing. Six files conflicted; three of them shared context in ways that break under a naive take-both (the AuthConfig fields share a /** opener, the two parsers share a trailing .filter(Boolean);, and the two config tests share a loadConfig({…}) preamble), so it needed a careful pass.

  2. fix(oauth): the authorization-server document now uses the same scope source. This was my main ask. buildOAuthMetadata hardcoded ["openid","email","profile"], so configuring scopes for a non-WorkOS IdP left the two well-known documents contradicting each other. Verified across three cases:

    OAUTH_SCOPES_SUPPORTED protected-resource authorization-server
    unset key absent openid email profile
    api://abc/mcp.access ["api://abc/mcp.access"] ["api://abc/mcp.access"]
    openid email profile 3 scopes 3 scopes

    The unset row is the important one — it's byte-identical to what the server did before the option existed, so existing deployments are unaffected. That property was worth preserving and I kept your advertisedScopes seam to do it.

  3. fix(config): scopes now split on commas or whitespace. "openid email profile" previously became one invalid scope.

Plus README, .env.example and CHANGELOG updated to match, and four new tests. 149 passing locally.

I hope pushing directly is alright — it seemed friendlier than asking you to do a conflict resolution I'd already worked through. Tell me if you'd rather I hadn't and I'll leave your branches alone in future.

For the record, the undefined-vs-[] reasoning in your original PR is what made the backward-compatibility property easy to preserve here. That detail did real work.

@marselsel
marselsel merged commit b1b1b47 into marselsel:main Aug 7, 2026
2 checks passed
marselsel added a commit that referenced this pull request Aug 7, 2026
A `node_modules` symlink pointing at an absolute local path leaked into the #33
merge. Anyone cloning the repo would get a dangling symlink where node_modules
belongs, breaking `npm install` / `npm ci` until they deleted it by hand.

It slipped past `.gitignore` because the rule was `node_modules/` — a trailing
slash matches directories only, never a symlink of the same name. Changed to
`node_modules`, which matches both. Verified with `git check-ignore`.

`npm ci` in CI happened to mask this: it clears and recreates the directory, so
the workflow still passed on a tree that was broken for humans.
marselsel added a commit that referenced this pull request Aug 7, 2026
OAuth interoperability for IdPs that do not honour the Resource Indicator
(OAUTH_AUDIENCE, #32) and scope advertisement in both well-known documents
(OAUTH_SCOPES_SUPPORTED, #33), both contributed by @gutencoder. Both are unset
by default and change nothing for existing deployments.

Also records the dependency security fix already on main (9 advisories, 4 high).

Version bumped in all three places: package.json, package-lock.json root, and
the McpServer literal in src/server.ts.
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.

3 participants