Skip to content

feat(api): keyless hosted MCP via OAuth (Gram)#2955

Merged
tofikwest merged 14 commits into
mainfrom
feat/mcp-hosted-oauth
May 29, 2026
Merged

feat(api): keyless hosted MCP via OAuth (Gram)#2955
tofikwest merged 14 commits into
mainfrom
feat/mcp-hosted-oauth

Conversation

@tofikwest
Copy link
Copy Markdown
Contributor

@tofikwest tofikwest commented May 28, 2026

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

  • Enables better-auth's mcp plugin (wraps oidcProvider) → exposes /api/auth/mcp/authorize, /api/auth/mcp/token, /api/auth/mcp/register + /api/auth/.well-known/* discovery, and auth.api.getMcpSession(). (Gram's OAuth proxy targets /mcp/*; it can also auto-discover via .well-known.)
  • Gram is registered as a single trusted OAuth client (env-driven; inert when GRAM_OAUTH_* is unset → safe to merge/deploy before Gram is set up). skipConsent: true (first-party).
  • HybridAuthGuard gains an MCP-OAuth fallback that runs only when normal session auth returns null → existing login is untouched. It validates the Bearer token via getMcpSession, then resolves user → org → member roles. RBAC enforced by the existing PermissionGuard, 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):

  • 1 org → bind it automatically.
  • Multiple orgs → use the org the user chose for MCP (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.
  • Users choose/switch via an "AI / MCP organization" section in User Settings (frontend included). Switching applies on the next request — no reconnect (the token isn't tied to an org). Backed by 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

  • Rule 11 added to the api-endpoint-contract skill + cursor rule: endpoints need a meaningful @ApiOperation summary/description (enforced by openapi-docs.spec.ts) — now correctness-critical because Gram dynamic toolsets discover tools via semantic search over descriptions.
  • Fix: offboarding-checklist endpoints were missing descriptions (failing openapi-docs.spec.ts) — added to all 15 + regenerated openapi.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 green
  • openapi-docs.spec.ts green; /v1/mcp confirmed excluded from the public spec
  • Migrations additive: add_oauth_provider_tables, add_mcp_org_binding (applied locally)
  • Not yet runtime-verified: the live OAuth round-trip + the settings UI rendered in a running app (need full env / Gram) — code + types check out; expect a small redirect/env tweak on first Gram wiring.

NOT in this PR (follow-up — non-code)

  1. Gram dashboard config (Dynamic Toolsets on; OAuth Proxy → /api/auth/mcp/*; set GRAM_OAUTH_* to match) + deploy + prisma migrate deploy
  2. Compliance: Speakeasy subprocessor / DPA / BAA before real customers

🤖 Generated with Claude Code

tofikwest and others added 4 commits May 28, 2026 17:42
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>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 2026

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

Project Deployment Actions Updated (UTC)
app Ready Ready Preview, Comment May 29, 2026 12:22am
comp-framework-editor Ready Ready Preview, Comment May 29, 2026 12:22am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
portal Skipped Skipped May 29, 2026 12:22am

Request Review

@mintlify
Copy link
Copy Markdown
Contributor

mintlify Bot commented May 28, 2026

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
CompAI 🟢 Ready View Preview May 28, 2026, 10:10 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

No issues found across 10 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

tofikwest and others added 2 commits May 28, 2026 18:27
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>
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

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

Comment thread apps/api/src/mcp/mcp.controller.ts Outdated
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>
@tofikwest
Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai ultrareview

@cubic-dev-ai
Copy link
Copy Markdown
Contributor

cubic-dev-ai Bot commented May 28, 2026

@cubic-dev-ai ultrareview

@tofikwest Starting ultrareview - a deeper analysis than a regular review. I'll post findings when complete.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

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-based hasPermission, so legitimate OAuth calls may be rejected.
  • apps/api/src/auth/app-access.ts has robustness gaps (prototype-chain role lookup and unguarded JSON.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, and apps/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

Comment thread apps/api/src/mcp/mcp.controller.ts Outdated
Comment thread apps/api/src/auth/hybrid-auth.guard.ts
Comment thread apps/api/src/mcp/mcp.service.ts Outdated
Comment thread apps/api/src/auth/app-access.ts Outdated
Comment thread apps/api/src/auth/app-access.ts Outdated
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>
@vercel vercel Bot temporarily deployed to Preview – portal May 29, 2026 00:09 Inactive
@vercel vercel Bot temporarily deployed to Preview – app May 29, 2026 00:09 Inactive
@tofikwest
Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai
Copy link
Copy Markdown
Contributor

cubic-dev-ai Bot commented May 29, 2026

@cubic-dev-ai review it

@tofikwest I have started the AI code review. It will take a few minutes to complete.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

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 by hasAppAccess before PermissionGuard applies its isPlatformAdmin bypass, which can incorrectly deny access for privileged users.
  • apps/app/src/app/(app)/[orgId]/settings/user/hooks/useMcpOrganization.ts has 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.ts and apps/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

Comment thread apps/app/src/app/(app)/[orgId]/settings/user/hooks/useMcpOrganization.ts Outdated
Comment thread apps/api/src/auth/hybrid-auth.guard.ts Outdated
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>
@vercel vercel Bot temporarily deployed to Preview – portal May 29, 2026 00:17 Inactive
@tofikwest
Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai
Copy link
Copy Markdown
Contributor

cubic-dev-ai Bot commented May 29, 2026

@cubic-dev-ai review it

@tofikwest I have started the AI code review. It will take a few minutes to complete.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

No issues found across 28 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Re-trigger cubic

@tofikwest tofikwest merged commit c148506 into main May 29, 2026
15 checks passed
@tofikwest tofikwest deleted the feat/mcp-hosted-oauth branch May 29, 2026 00:24
@claudfuen
Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.66.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants