Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions examples/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions scripts/validate-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(", ")}`);
}
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions tests/smoke/validator.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down