Skip to content

feat(039): role-scope MCP tools to the hub user role#119

Merged
studert merged 10 commits into
mainfrom
039-mcp-role-scoping
Jun 11, 2026
Merged

feat(039): role-scope MCP tools to the hub user role#119
studert merged 10 commits into
mainfrom
039-mcp-role-scoping

Conversation

@studert

@studert studert commented Jun 11, 2026

Copy link
Copy Markdown
Member

Closes the open question recorded in spec 038's implementation notes: MCP tool access now respects the hub user role bound to the credential, instead of every authenticated token seeing all org-wide read-only data. Follow-up to #118.

What changed

  • Live role resolution: verifyAccessToken projects users.role over its existing join → AuthInfo.extra.role. The role is re-read on every request, so demoting a user takes effect on their existing tokens immediately (no propagation delay, zero extra queries).
  • New pure module src/lib/mcp/access.ts: callerFromAuthInfo (anything but literal "admin" fails closed to viewer), adminOnly(...) wrapper, resolveSelfEmail(...) identity pinning (trim + case-insensitive).
  • 11 admin-only tools (all Claude/budget/Copilot/invoice/sync/user-directory tools): viewer credentials get a shared isError tool result naming the required role; the data function is never invoked; descriptions carry a "Requires an admin-role token." discovery hint.
  • 2 self-scoped tools: get_user_cost_profile (email now optional, defaults to the token owner) and list_license_assignments (viewers pinned to their own email) — a foreign email is refused, never silently substituted.
  • 1 viewer-safe catalog: list_ai_tools omits activeAssignments/maxLicenses/licenseUtilizationPct for viewers (mirrors the /tools page) and skips the count aggregate query.
  • Shared secret stays admin-equivalent (extra: { role: "admin" }, spec-034 judgment); its org-wide behaviour is unchanged.

No schema changes, no new packages, no route changes. Full spec/plan/research/contract/notes in specs/039-mcp-role-scoping/ (incl. HTML implementation plan + implementation notes).

Note on history: this branch was cut from the 038 branch before #118 squash-merged; main was merged back in (content-identical, conflict resolution = ours) rather than force-pushing a rebase. The PR diff vs main is exactly the 039 change.

Testing

  • tests/unit/mcp/access.test.ts (new, 23 tests) + tools.test.ts extended to a 55-test deny/serve matrix across viewer/admin/shared-secret; the expected-tool list is derived from the access-class partition, so an unclassified future tool fails the registration test.
  • Full suite: 615/615 passing; pnpm typecheck and pnpm lint clean.
  • Live probe against a local dev server: shared-secret admin data flow, profile-without-email validation error, catalog utilization present, bogus-OAuth-token 401. (Viewer-token live probe intentionally skipped — would require forging token rows in the shared preview DB; covered by the unit matrix over the same authInfo channel proven live.)

🤖 Generated with Claude Code

studert and others added 10 commits June 11, 2026 12:13
Embedded OAuth 2.1 authorization server so the Hub can be added as a
Claude Desktop / claude.ai custom connector and via `claude mcp add`:
RFC 7591 dynamic client registration, PKCE S256, RFC 8414/9728 discovery,
rotating refresh tokens with family-revocation reuse detection, consent
anchored on the existing NextAuth login, grants management under
Settings → Connections. Shared-secret auth retained for headless clients.

New tools: find_users, list_claude_users, get_claude_cost_dashboard,
get_budget_report, list_license_assignments, list_invoices,
get_copilot_analytics. list_ai_tools gains license utilization,
get_user_cost_profile suggests near-matches, all tools carry
readOnlyHint annotations.

Spec artifacts (brainstorm, plan, implementation notes) in specs/038-mcp-v2/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reuse: shared formatDate, clientIp() helper in rate-limit, date-fns month
math, Copilot connection/range helpers shared between the two Copilot
tools, token prefixes exported from the OAuth store. Simplification:
consent actions share validateConsentSubmission, authorize params built
from one AUTHORIZE_PARAM_KEYS list, boolean isValidScopeRequest, dropped
unused exports and the hiddenFields alias. Efficiency: explicit column
selects, single joined query in rotateRefreshToken, parallel insert+prune
in issueAuthCode, leaner active-budget lookup. Alignment: list_claude_users
now excludes the sync-lock sentinel user and matches the dashboard's
Default Workspace label; tools.test asserts readOnlyHint on every tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n notes

Control experiment: deploying main (6bd1dac) from the same machine fails
with identical 60s static-generation timeouts on /reports, /reports/budget
and /settings/license-templates, so the red Vercel check on PR #118 is a
preview-DB/build-environment issue, not introduced by this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- requestOrigin: forwarded headers can be comma-separated lists in
  multi-hop proxy chains; take the first (client-facing) entry so the
  OAuth issuer/discovery URLs stay valid. Unit test added.
- RevokeConnectionButton: router.refresh() after a successful revoke,
  matching the settings-page convention (scheduled-jobs-table,
  ingestion-filters-section), so the grants table updates immediately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two concurrent refresh requests presenting the same token could both
pass the pre-checks and both revoke the row unconditionally, leaving
two live successors in one family. The revoke UPDATE is now conditional
on revokedAt IS NULL with a row-count check; the race loser is treated
like a replay (family revoked, invalid_grant). Unit test added.

Addresses Copilot review pass 2 on PR #118.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Token endpoint rate-limit branch returns 429 (matching /register) so
  clients back off instead of treating throttling as a permanent error.
- isAllowedRedirectUri rejects URLs with an empty hostname.
- clientIp treats empty/whitespace forwarded headers as absent so the
  rate-limit key falls back to x-real-ip / "unknown".
- consumeAuthCode binds the client id into the atomic consume predicate,
  so a code presented by the wrong client is refused without being
  burned for its rightful owner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the open question from spec 038: every authenticated MCP
credential no longer sees all org-wide data. The live users.role rides
the existing verifyAccessToken join into AuthInfo.extra; a new pure
module (src/lib/mcp/access.ts) classifies the 14 tools into admin-only
(11, denied to viewers with a shared isError message), self-scoped
(cost profile + license assignments, pinned to the token owner), and
viewer-safe catalog (list_ai_tools without utilization). Shared secret
stays admin-equivalent; unknown roles fail closed to viewer. No schema
changes, no new packages.

Spec, plan (md + html), research, contracts, quickstart, tasks and
implementation notes in specs/039-mcp-role-scoping/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 11, 2026 15:59
@vercel

vercel Bot commented Jun 11, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
ai-developer-hub Ready Ready Preview, Comment Jun 11, 2026 4:00pm

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR updates the Hub’s MCP server authorization model so tool access is enforced by the live hub user role (admin vs viewer) bound to the credential, preventing viewer OAuth tokens from accessing org-wide read-only admin data while preserving shared-secret/admin behavior.

Changes:

  • Propagates users.role through verifyAccessTokenverifyMcpTokenAuthInfo.extra.role on every request (live enforcement).
  • Introduces src/lib/mcp/access.ts to centralize role parsing, admin gating, and viewer self-scoping (with consistent denial messages).
  • Reclassifies MCP tools into admin-only vs self-scoped vs viewer-safe catalog (with list_ai_tools stripping utilization for viewers and skipping the aggregate query).

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated no comments.

Show a summary per file
File Description
tests/unit/oauth/store.test.ts Updates token verification tests to include the live role field.
tests/unit/mcp/tools.test.ts Adds an exhaustive access-class partition and viewer/admin/shared-secret behavior matrix for all tools.
tests/unit/mcp/data.test.ts Updates listAiToolsData tests for the new { includeUtilization } option and viewer-safe omission behavior.
tests/unit/mcp/auth.test.ts Verifies verifyMcpToken emits extra.role (and shared-secret is explicitly admin-equivalent).
tests/unit/mcp/access.test.ts New unit tests for role parsing, admin gating, and self-scope email resolution semantics.
src/lib/oauth/store.ts Extends VerifiedAccessToken + verifyAccessToken projection to include live users.role.
src/lib/mcp/tools.ts Wires role-aware enforcement into tool handlers (adminOnly wrapper, self-scoping, viewer-safe catalog).
src/lib/mcp/data.ts Adds listAiToolsData({ includeUtilization }) and omits utilization fields when false.
src/lib/mcp/auth.ts Adds extra.role for OAuth tokens and explicit { role: "admin" } for shared-secret auth.
src/lib/mcp/access.ts New pure module: callerFromAuthInfo, adminOnly, resolveSelfEmail, and shared denial messages.
specs/039-mcp-role-scoping/tasks.md Documents the implementation task breakdown and verification checkpoints.
specs/039-mcp-role-scoping/spec.md Feature spec defining requirements, scenarios, and success criteria for role scoping.
specs/039-mcp-role-scoping/research.md Design decisions and alternatives (enforcement seam, denial shape, self-scope semantics).
specs/039-mcp-role-scoping/quickstart.md Local verification instructions (unit tests + manual probe guidance).
specs/039-mcp-role-scoping/plan.md Implementation plan tying code changes to requirements and test strategy.
specs/039-mcp-role-scoping/implementation-plan.html HTML rendition of the implementation plan for stakeholders.
specs/039-mcp-role-scoping/implementation-notes.html Running notes documenting decisions, tradeoffs, and verification gates.
specs/039-mcp-role-scoping/data-model.md Describes the in-memory caller model and access-class mapping.
specs/039-mcp-role-scoping/contracts/tool-authorization.md External behavior contract for tool authorization/denial result shapes.
specs/039-mcp-role-scoping/checklists/requirements.md Spec quality checklist confirming completeness and testability.
specs/038-mcp-v2/implementation-notes.html Records the open question as resolved and points to spec 039 artifacts.
CLAUDE.md Updates repo meta to include spec 039’s stack/context entry.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@studert studert merged commit 1189e43 into main Jun 11, 2026
8 checks passed
@studert studert deleted the 039-mcp-role-scoping branch June 11, 2026 16:28
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