From ede4fcbc3f5e2889a0b40670eb77e04803a087a4 Mon Sep 17 00:00:00 2001 From: Allora Date: Mon, 24 Aug 2026 12:32:28 +0100 Subject: [PATCH] fix(auth): pin Claude OAuth scopes --- .mcp.json | 10 +++++++++- CHANGELOG.md | 2 ++ docs/authentication.md | 6 +++++- docs/troubleshooting.md | 5 +++++ examples/claude-code/README.md | 6 ++++-- scripts/validate-package.mjs | 15 +++++++++++++++ tests/smoke/validator.test.mjs | 20 ++++++++++++++++++++ 7 files changed, 60 insertions(+), 4 deletions(-) diff --git a/.mcp.json b/.mcp.json index c299408..b46ec94 100644 --- a/.mcp.json +++ b/.mcp.json @@ -4,7 +4,15 @@ "type": "http", "url": "https://usable.dev/api/mcp", "oauth": { - "clientId": "mcp_oauth_client" + "clientId": "mcp_oauth_client", + "scopes": [ + "openid", + "profile", + "email", + "offline_access", + "fragments.read", + "workspace.read" + ] } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index aa0b317..c618ad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed - `.claude-plugin/` and `.mcp.json` added to the release-archive allowlist. Without this the published artifact would have shipped a package Claude Code could not install. +- Claude Code OAuth no longer requests every advertised Usable permission. `.mcp.json` now pins + the live-supported read-only scopes, preventing Keycloak's `invalid_scope` response. ### Verified - Claude Code 2.1.227 on macOS: `claude plugin marketplace add ./` registers the marketplace diff --git a/docs/authentication.md b/docs/authentication.md index 5d32cfd..9cf550c 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -77,7 +77,11 @@ codex mcp add usable --url https://usable.dev/api/mcp --oauth-client-id mcp_oaut An OAuth `client_id` is a public identifier, not a secret — it is safe in a shared config and in this repository. This package declares it for Claude Code in `.mcp.json` under -`oauth.clientId`, which is the field Claude Code reads. +`oauth.clientId`, which is the field Claude Code reads. It also pins `oauth.scopes` to +`openid`, `profile`, `email`, `offline_access`, `fragments.read`, and `workspace.read`. +Without that explicit list Claude Code requests every scope advertised by the authorization +metadata, including administrative scopes the public client cannot request, and Keycloak rejects +the authorization request with `invalid_scope`. A client **secret** is a different thing entirely and must never be packaged. CI enforces the distinction: the validator permits only `clientId`, `callbackPort`, and `scopes` inside an diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 394f20a..ca835cb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -30,6 +30,11 @@ Then check: interactive OAuth 2.1 for MCP; some clients only support static headers, which this plugin deliberately does not provide. +**Claude Code shows `invalid_scope` with a long list of scopes.** Upgrade the plugin to a +version whose `.mcp.json` explicitly declares the read-only OAuth scopes. Then remove the old +OAuth grant or cached MCP credentials and authenticate again; existing sessions do not gain the +correct scope set automatically. + **Discovery fails.** Verify the chain by hand: ```bash diff --git a/examples/claude-code/README.md b/examples/claude-code/README.md index 3b8aac4..06aec3f 100644 --- a/examples/claude-code/README.md +++ b/examples/claude-code/README.md @@ -68,8 +68,10 @@ Plugin skills are namespaced, so expect `usable:usable-knowledge-workflow` and ## MCP server The plugin declares the Usable MCP server in `.mcp.json` with the public OAuth client ID -`mcp_oauth_client`. Claude Code performs the OAuth flow and stores tokens itself; no credential -ships in this package. +`mcp_oauth_client` and an explicit read-only scope list. The explicit list prevents Claude Code +from requesting every scope advertised by the server, which Keycloak rejects with +`invalid_scope`. Claude Code performs the OAuth flow and stores tokens itself; no credential ships +in this package. **A user-level server named `usable` will shadow it.** If you already have one, the plugin's declaration is silently ignored. Plugin-provided servers appear as `plugin:usable:usable` in diff --git a/scripts/validate-package.mjs b/scripts/validate-package.mjs index d624bff..62c37a7 100644 --- a/scripts/validate-package.mjs +++ b/scripts/validate-package.mjs @@ -34,6 +34,14 @@ const CLAUDE_TRANSPORTS = ["stdio", "http", "sse"]; // An OAuth client_id is a public identifier, not a credential, so it may be // packaged. Anything that could carry a secret may not. const CLAUDE_OAUTH_PUBLIC_KEYS = new Set(["clientId", "callbackPort", "scopes"]); +const CLAUDE_REQUIRED_OAUTH_SCOPES = [ + "openid", + "profile", + "email", + "offline_access", + "fragments.read", + "workspace.read", +]; const ALLOWED_MANIFEST_KEYS = new Set([ "$schema", "name", "version", "description", "author", "homepage", "repository", "license", "keywords", "extensions", @@ -332,6 +340,13 @@ function validateClaudeMcp() { if (server.oauth.clientId !== undefined && typeof server.oauth.clientId !== "string") { fail(CHECK, `server "${name}" oauth.clientId must be a string`); } + if (!Array.isArray(server.oauth.scopes) || + server.oauth.scopes.some((scope) => typeof scope !== "string" || !scope)) { + fail(CHECK, `server "${name}" oauth.scopes must be an array of non-empty strings`); + } else if (server.oauth.scopes.length !== CLAUDE_REQUIRED_OAUTH_SCOPES.length || + CLAUDE_REQUIRED_OAUTH_SCOPES.some((scope) => !server.oauth.scopes.includes(scope))) { + fail(CHECK, `server "${name}" oauth.scopes must contain exactly the supported read-only scopes: ${CLAUDE_REQUIRED_OAUTH_SCOPES.join(", ")}`); + } } } } diff --git a/tests/smoke/validator.test.mjs b/tests/smoke/validator.test.mjs index f9107f0..2eb03a4 100644 --- a/tests/smoke/validator.test.mjs +++ b/tests/smoke/validator.test.mjs @@ -221,6 +221,26 @@ const cases = [ expect: (r) => r.code === 1 && /not a recognised public field/.test(r.output), describe: "should fail closed on unrecognised oauth fields", }, + { + name: "rejects missing Claude OAuth scopes", + mutate: (dir) => { + const mcp = JSON.parse(readFileSync(join(dir, ".mcp.json"), "utf8")); + delete mcp.mcpServers.usable.oauth.scopes; + writeFileSync(join(dir, ".mcp.json"), JSON.stringify(mcp, null, 2)); + }, + expect: (r) => r.code === 1 && /oauth\.scopes must be an array/.test(r.output), + describe: "should prevent Claude from requesting every advertised server scope", + }, + { + name: "rejects unsupported Claude OAuth scopes", + mutate: (dir) => { + const mcp = JSON.parse(readFileSync(join(dir, ".mcp.json"), "utf8")); + mcp.mcpServers.usable.oauth.scopes.push("workspace.delete"); + writeFileSync(join(dir, ".mcp.json"), JSON.stringify(mcp, null, 2)); + }, + expect: (r) => r.code === 1 && /exactly the supported read-only scopes/.test(r.output), + describe: "should reject scopes outside the live-supported read-only set", + }, { name: "rejects headers in .mcp.json", mutate: (dir) => {