feat(api): keyless hosted MCP via OAuth (Gram)#2955
Conversation
Enable better-auth's mcp plugin (wraps oidcProvider) so the Gram-hosted MCP server authenticates users via OAuth ("Sign in with Google") instead of a pasted API key.
- add OauthApplication/OauthAccessToken/OauthConsent tables (+ migration)
- register Gram as an env-driven trusted OAuth client (inert when env unset)
- HybridAuthGuard: validate MCP OAuth tokens via getMcpSession and bind org
explicitly from active memberships (device-agent pattern) — single org binds,
multiple orgs fail closed to avoid wrong-tenant access
- add 6 tests for the MCP OAuth path
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…scovery Add Rule 11 to the api-endpoint-contract skill + cursor rule: every endpoint needs a meaningful @apioperation summary/description (already enforced by openapi-docs.spec.ts), now correctness-critical because Gram dynamic toolsets discover tools via semantic search over descriptions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The offboarding-checklist endpoints were missing @apioperation summaries/descriptions, failing the OpenAPI quality contract (openapi-docs.spec.ts: missingSummaries + invalidSeo). Added a meaningful summary + ~120-155 char description to all 15 endpoints (the SEO check requires the generated description to be >= 80 chars) and regenerated packages/docs/openapi.json. Descriptions also power the hosted MCP's dynamic-toolset semantic search, so these tools are now discoverable by agents. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Gram is Comp AI's own hosted MCP client, so the user's login is the authorization — no consent screen needed. Avoids building a consent page UI for v1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Multi-org users can now use the MCP instead of being blocked. A per-user McpOrgBinding (set at connect time) records which org their MCP/OAuth token acts on; HybridAuthGuard uses it: single-org binds automatically, multi-org uses the saved choice (if still a member), otherwise returns a helpful 'choose your org' error instead of a dead 401. Adds GET/PUT /v1/mcp/organization (web-app management endpoints, deny-listed from the public spec + MCP tools so the agent can't switch tenants). Tests: guard binding cases + service. Migration is additive (1 table). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mcp plugin registers /api/auth/mcp/{authorize,token,register}, not /oauth2/* (those aren't registered). Gram's OAuth proxy targets /mcp/*.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 13 files (changes from recent commits).
Tip: cubic used a learning from your PR history. Let your coding agent read cubic learnings directly with the cubic MCP.
Fix all with cubic | Re-trigger cubic
Adds an AI / MCP organization section to User Settings so multi-org users can choose which org their MCP connection acts on, and switch it anytime (applies on the next request, no reconnect). Renders only for users in more than one org; calls GET/PUT /v1/mcp/organization via a useSWR hook with optimistic update + rollback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A 401 makes MCP clients re-run sign-in in a loop; the token is valid and the user just needs to pick an org, so 403 is correct and surfaces the message to the agent. Also re-throw HttpExceptions from the session-auth catch so the 403 propagates instead of collapsing to 401. Updates the message to 'try again' (no reconnect needed). Adds a proactive 'pick an organization' warning callout in the User Settings AI/MCP section when nothing is selected yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An MCP token is only usable by a member of at least one organization. The membership check now runs before the skipOrgCheck shortcut, so a user with zero memberships (a stranger who completed Google sign-in, or someone removed from all orgs) is rejected with 403 on EVERY MCP tool — including org-agnostic ones. They can never reach any org's data, and a user can only ever act on orgs they belong to. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MCP now follows the same access rule as the web app: a user can only use it if their role grants app access (app:read) in the operative org. Owner/admin/auditor and custom roles with the App Access toggle qualify; Portal-only roles (employee/contractor) are rejected with 403. Combined with the existing 'must be a member of an org' check, this means: only customers, and only app-access roles, can use the MCP. Adds a reusable hasAppAccess(orgId, roleString) helper (built-in + custom roles, comma-separated union). The settings picker now only offers app-access orgs, and setOrganization validates app access before saving. 24 unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cursor rule now auto-attaches on apps/api controllers + DTOs via globs (not just description-triggered), so it applies reliably when editing endpoints. AGENTS.md (Codex/agents) gains the rule that every endpoint needs a meaningful @apioperation summary/description — CI-enforced and required for dynamic-toolset discovery — keeping it in sync with the Claude skill + Cursor rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses cubic P1: the MCP controller had HybridAuthGuard only, so the PUT bypassed API-key scope enforcement and the mutation audit hook (AuditLogInterceptor only logs when @RequirePermission is present). Now matches the rest of the API (e.g. people controller): class-level HybridAuthGuard + PermissionGuard, with @RequirePermission('app','read') on both endpoints — gating on app access (consistent with the MCP access rule) and logging the PUT mutation. Dropped @SkipOrgCheck (these are web-only, deny-listed from MCP; an active org is present for web sessions, which PermissionGuard + the audit log need).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@cubic-dev-ai ultrareview |
@tofikwest Starting ultrareview - a deeper analysis than a regular review. I'll post findings when complete. |
There was a problem hiding this comment.
Ultrareview completed in 13m 26s
5 issues found across 25 files
Confidence score: 2/5
- There is a concrete functional risk in
apps/api/src/mcp/mcp.controller.ts: using@UserId()without a session-only guard can produce 500s for valid API-key requests, which is user-facing and likely to surface quickly. - Authorization flow looks inconsistent in
apps/api/src/auth/hybrid-auth.guard.ts, where MCP OAuth can authenticate but still fail RBAC via session-basedhasPermission, so legitimate OAuth calls may be rejected. apps/api/src/auth/app-access.tshas robustness gaps (prototype-chain role lookup and unguardedJSON.parse) that can misclassify custom roles or throw during access checks, increasing rejection/error risk beyond edge cases.- Pay close attention to
apps/api/src/auth/hybrid-auth.guard.ts,apps/api/src/mcp/mcp.controller.ts, andapps/api/src/auth/app-access.ts- auth/RBAC path mismatches and unhandled parsing/role-resolution cases can cause incorrect denies and runtime failures.
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
P1: PermissionGuard rejected MCP OAuth calls — it authorizes via session-based auth.api.hasPermission, but OAuth tokens aren't sessions, so every permission-gated MCP call would 403. Added an isMcpOAuth marker + a guard branch that authorizes from the roles already resolved by HybridAuthGuard (via resolveRolePermissions). This was the critical one — the whole OAuth flow was broken without it. P1: MCP controller used @userid() with no session-only guard, so a scoped API key would pass the guards then crash at @userid() with a 500. Added SessionOnlyGuard (clean 403 for API keys/service tokens). P2: app-access.ts hardened — guarded JSON.parse (malformed custom-role perms no longer throw) and own-property role lookup (a custom role named e.g. 'constructor' is no longer misclassified as built-in). P2: mcp.service resolves app-access concurrently (Promise.all) instead of a serial N+1 loop. Adds tests for the MCP OAuth authorization path + the robustness cases (46 auth/mcp tests green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@cubic-dev-ai review it |
@tofikwest I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
2 issues found across 28 files
Confidence score: 3/5
- There is a concrete regression risk in
apps/api/src/auth/hybrid-auth.guard.ts: platform admins can be blocked byhasAppAccessbeforePermissionGuardapplies itsisPlatformAdminbypass, which can incorrectly deny access for privileged users. apps/app/src/app/(app)/[orgId]/settings/user/hooks/useMcpOrganization.tshas a moderate state-consistency risk: snapshot rollback on error can restore a stale org selection, so revalidating from the server is safer.- Given a medium-severity backend auth path issue plus a frontend stale-state issue (both with high confidence), this looks like some merge risk rather than a merge blocker.
- Pay close attention to
apps/api/src/auth/hybrid-auth.guard.tsandapps/app/src/app/(app)/[orgId]/settings/user/hooks/useMcpOrganization.ts- admin access gating order and stale rollback behavior should be corrected/validated.
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
P2: platform admins were blocked by the MCP app-access gate in HybridAuthGuard before PermissionGuard's isPlatformAdmin bypass could apply, incorrectly denying privileged users whose member role lacks app access. Now the gate is skipped for platform admins, consistent with the normal session path. P2: useMcpOrganization rolled back to a snapshot on save error, which can restore a stale selection. Now revalidates from the server instead (matches the team's SWR norm). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@cubic-dev-ai review it |
@tofikwest I have started the AI code review. It will take a few minutes to complete. |
|
🎉 This PR is included in version 3.66.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
What & why
Lets the hosted MCP server (Speakeasy Gram) authenticate users via OAuth — "Sign in with Google" instead of pasting an API key. Turns the API's better-auth instance into an OAuth provider so Gram gets a per-user access token.
Goal UX: Connect → Sign in with Google → done. No API key. Works for single- and multi-org users.
How it works
mcpplugin (wrapsoidcProvider) → exposes/api/auth/mcp/authorize,/api/auth/mcp/token,/api/auth/mcp/register+/api/auth/.well-known/*discovery, andauth.api.getMcpSession(). (Gram's OAuth proxy targets/mcp/*; it can also auto-discover via.well-known.)GRAM_OAUTH_*is unset → safe to merge/deploy before Gram is set up).skipConsent: true(first-party).HybridAuthGuardgains an MCP-OAuth fallback that runs only when normal session auth returns null → existing login is untouched. It validates the Bearer token viagetMcpSession, then resolves user → org → member roles. RBAC enforced by the existingPermissionGuard, unchanged.Org resolution — single AND multi-org (security-sensitive)
An OAuth token identifies a person, not an org. Resolution follows the device-agent pattern (explicit, never a "most-recent" guess):
McpOrgBinding, set/changed at any time) if they're still a member; otherwise a helpful "choose your org" error, never a silent wrong-tenant pick.GET/PUT /v1/mcp/organization— web-app endpoints deny-listed from the public spec + MCP tools so the agent can't switch tenants.Also in this PR
api-endpoint-contractskill + cursor rule: endpoints need a meaningful@ApiOperationsummary/description (enforced byopenapi-docs.spec.ts) — now correctness-critical because Gram dynamic toolsets discover tools via semantic search over descriptions.openapi-docs.spec.ts) — added to all 15 + regeneratedopenapi.json.Verification
prisma validate✅ · API typecheck = 0 new errors ✅ · app typecheck (new files) ✅hybrid-auth.guard.spec.ts(single-org, read-only, invalid token, no-org, multi-org: no-choice / valid-choice / stale-choice, platform-admin) +mcp.service.spec.ts— all greenopenapi-docs.spec.tsgreen;/v1/mcpconfirmed excluded from the public specadd_oauth_provider_tables,add_mcp_org_binding(applied locally)NOT in this PR (follow-up — non-code)
/api/auth/mcp/*; setGRAM_OAUTH_*to match) + deploy +prisma migrate deploy🤖 Generated with Claude Code