Skip to content

feat(nitro): gate definitions on oauth scopes - #334

Open
HugoRCD wants to merge 1 commit into
feat/nitro-plugins-conventionfrom
feat/nitro-definition-scopes
Open

feat(nitro): gate definitions on oauth scopes#334
HugoRCD wants to merge 1 commit into
feat/nitro-plugins-conventionfrom
feat/nitro-definition-scopes

Conversation

@HugoRCD

@HugoRCD HugoRCD commented Sep 3, 2026

Copy link
Copy Markdown
Member

📚 Description

Top of the stack, on #333. Depends on #330 for the verified claims it reads.

defineMcpTool, defineMcpResource and defineMcpPrompt take scopes. requireScopes wraps the handler each build() pushes, reads the claims off event.context.oauth, and throws a JSON-RPC -32003 naming the scopes that were missing. Scopes come from scope (space-delimited, RFC 6749) and scp (a string or an array, as Okta and Entra ID send it); permissions is Auth0-specific and deliberately left out.

export default defineMcpTool({
  scopes: ['todos:write'],
  inputSchema: z.object({ id: z.string() }),
  handler: ({ id }) => remove(id),
})

It fails closed: a definition that declares scopes on an endpoint with no OAuth has nothing to satisfy them, so every call is refused.

The gate is on the call, not the listing, and that is the engine's order rather than a preference. h3-mcp resolves a handler's options — where the X-MCP-Tools filter lives — before checkAuth populates event.context.oauth, and that resolution is synchronous, so no JWT check fits there. The decorate plugin hook does run after auth, but only from handleModernPost, so filtering there would leave legacy requests ungated while era is dual. A scoped definition therefore stays visible in tools/list, with its scopes in _meta and on handler.definitions so a client can say why a call would fail. When a definition's existence is itself sensitive, it belongs on a second endpoint. Hiding listings needs a post-auth hook covering both eras upstream.

One wrinkle worth knowing when reading the tests: the 403 only reaches the wire on the modern revision. A legacy request gets the same JSON-RPC error inside a 200 stream, as it does for every error — so the tests assert on the code, not the status, unless they pin an era.

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

All three helpers take `scopes`. `requireScopes` wraps the handler each
`build()` pushes, reads the verified claims off `event.context.oauth`,
and throws a JSON-RPC `-32003` naming the scopes that were missing.
Scopes come from `scope` (space-delimited, RFC 6749) and `scp` (a string
or an array, as Okta and Entra ID send it). It fails closed: a definition
that declares scopes on an endpoint with no OAuth has nothing to satisfy
them, so every call is refused.

The gate is on the call, not the listing, and that is the engine's order
rather than a preference — a handler's options resolve before the request
is authenticated, so nothing that builds a listing has seen the token
yet. A scoped definition therefore stays visible in `tools/list`, with
its scopes in `_meta` and on `handler.definitions` so a client can say
why a call would fail. When a definition's existence is itself
sensitive, it belongs on a second endpoint.
@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
nuxt-mcp-toolkit-docs Ready Ready Preview Sep 3, 2026 3:41pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Thank you for following the naming conventions! 🙏

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.

1 participant