Skip to content

Add MCP server endpoint at /api/mcp#1050

Merged
goldflag merged 15 commits into
masterfrom
mcp-server
Jul 15, 2026
Merged

Add MCP server endpoint at /api/mcp#1050
goldflag merged 15 commits into
masterfrom
mcp-server

Conversation

@goldflag

@goldflag goldflag commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Stateless Streamable HTTP MCP server mounted on the existing Fastify instance, so cloud and self-hosted deployments both expose it at /api/mcp. Authenticates with the existing user API keys (Authorization: Bearer); tool calls dispatch in-process to the documented REST routes via fastify.inject(), inheriting API key verification, site access checks, rate limits, and time validation.

Ships 16 read-only tools: list_sites, overview/time-series, dimension breakdowns, live stats, sessions, events, errors, web vitals, retention, journeys, goals, funnels (list + analyze), and a get_query_schema + run_query escape hatch over the scoped custom query endpoint.

Notes:

  • @modelcontextprotocol/sdk pinned to 1.17.5: the 1.18+ zod v3/v4 compat types blow up tsc memory (OOM at 8GB heap)
  • zod bumped 3.24.4 -> 3.25.76 (drop-in): the SDK's zod-to-json-schema requires the zod/v3 subpath introduced in 3.25

Summary by CodeRabbit

  • New Features

    • Added a hosted Model Context Protocol (MCP) endpoint for AI-assisted analytics access and management.
    • Added 39 tools covering analytics, sites, goals, funnels, users, teams, and raw data queries.
    • Added API key and OAuth authentication, permission enforcement, rate limiting, and OAuth discovery endpoints.
    • Added support for MCP-compatible CORS requests and secure tool response handling.
  • Documentation

    • Added MCP setup, authentication, tool catalog, permissions, security, and troubleshooting guidance.
  • Bug Fixes

    • Improved bearer-token authentication fallback and centralized event schema usage.

Stateless Streamable HTTP MCP server mounted on the existing Fastify
instance, so cloud and self-hosted deployments both expose it at
<instance>/api/mcp. Authenticates with the existing user API keys
(Authorization: Bearer); tool calls dispatch in-process to the
documented REST routes via fastify.inject(), inheriting API key
verification, site access checks, rate limits, and time validation.

Ships 16 read-only tools: list_sites, overview/time-series, dimension
breakdowns, live stats, sessions, events, errors, web vitals,
retention, journeys, goals, funnels (list + analyze), and a
get_query_schema + run_query escape hatch over the scoped custom
query endpoint.

Notes:
- @modelcontextprotocol/sdk pinned to 1.17.5: the 1.18+ zod v3/v4
  compat types blow up tsc memory (OOM at 8GB heap)
- zod bumped 3.24.4 -> 3.25.76 (drop-in): the SDK's zod-to-json-schema
  requires the zod/v3 subpath introduced in 3.25

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 14, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
rybbit Ready Ready Preview, Comment Jul 15, 2026 6:45am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a stateless, authenticated MCP endpoint with 39 REST-backed tools, API-key and OAuth support, discovery metadata, CORS handling, validation, sanitization, end-to-end tests, documentation, and a reusable analytics event schema.

Changes

MCP foundation

Layer / File(s) Summary
Contracts, query conversion, and REST client
server/package.json, server/src/mcp/apiClient.ts, server/src/mcp/inputs.ts, server/src/mcp/tools/shared.ts
Adds MCP SDK dependencies, validated shared inputs, time/filter query serialization, sanitized tool results, error guards, annotations, and an in-process Fastify REST client.

Authentication and transport

Layer / File(s) Summary
Bearer authentication and stateless MCP routing
server/src/mcp/auth.ts, server/src/mcp/index.ts, server/src/index.ts
Adds API-key/OAuth authentication, per-request MCP server and transport creation, JSON-RPC error handling, POST-only routing, and application registration under /api.

Tool surface

Layer / File(s) Summary
Analytics, entity, organization, and raw-data tools
server/src/mcp/tools/*
Registers 39 validated tools for analytics, sites, goals, funnels, users, organizations, teams, sessions, events, and site-scoped SQL, with REST mappings and operation annotations.

OAuth integration and discovery

Layer / File(s) Summary
OAuth persistence, fallback authentication, and metadata routes
server/src/db/postgres/schema.ts, server/src/lib/auth.ts, server/src/lib/auth-utils.ts, server/src/mcp/wellKnown.ts, server/src/mcp/wellKnown.test.ts
Adds MCP OAuth tables and Better Auth configuration, resolves OAuth bearer users in existing authorization helpers, and serves tested OAuth discovery documents.

CORS policy

Layer / File(s) Summary
MCP headers and origin handling
server/src/lib/cors.ts, server/src/lib/cors.test.ts
Allows MCP protocol headers, exposes OAuth discovery paths, rejects invalid or untrusted origins, and tests browser preflight plus server-side requests.

Validation and documentation

Layer / File(s) Summary
MCP integration tests and documentation
server/src/mcp/mcp.test.ts, docs/content/docs/(docs)/mcp.mdx, docs/content/docs/(docs)/meta.json
Tests authentication, transport, tool discovery, annotations, request mappings, sanitization, errors, and invalid inputs; documents connection, OAuth, tools, permissions, data boundaries, and troubleshooting.

Reusable analytics schema

Layer / File(s) Summary
Shared ClickHouse event schema
server/src/api/analytics/utils/eventSchema.ts, server/src/api/analytics/generateCustomQuery.ts
Extracts the scoped_events schema into an import-free exported constant and updates custom query generation to import it.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPClient
  participant MCPRoute
  participant Authenticator
  participant MCPTool
  participant RESTAPI
  MCPClient->>MCPRoute: POST /api/mcp
  MCPRoute->>Authenticator: Validate bearer credential
  Authenticator-->>MCPRoute: Authentication context
  MCPRoute->>MCPTool: Dispatch tools/call
  MCPTool->>RESTAPI: Forward authenticated REST request
  RESTAPI-->>MCPTool: Return REST data or error
  MCPTool-->>MCPRoute: Sanitize and format tool result
  MCPRoute-->>MCPClient: Return JSON-RPC response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the main change: adding an MCP server endpoint at /api/mcp.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mcp-server

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (1)
server/src/mcp/apiClient.ts (1)

56-59: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider including message from error responses.

Fastify's default error responses (e.g., for schema validation) often include a detailed message property alongside error (e.g., { "error": "Bad Request", "message": "querystring must have required property 'id'" }). Currently, this only extracts the error property, which might just be a generic HTTP status string like "Bad Request", masking the actual validation failure context from the MCP client.

♻️ Proposed improvement
-      const message =
-        parsed !== null && typeof parsed === "object" && "error" in parsed
-          ? String((parsed as { error: unknown }).error)
-          : `Request failed with status ${response.statusCode}`;
+      let message = `Request failed with status ${response.statusCode}`;
+      if (parsed !== null && typeof parsed === "object") {
+        const p = parsed as Record<string, unknown>;
+        if (typeof p.message === "string") {
+          message = p.message;
+        } else if (typeof p.error === "string") {
+          message = p.error;
+        }
+      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/mcp/apiClient.ts` around lines 56 - 59, Update the error-response
parsing in the apiClient request failure handling to include the response
object's message property when present, alongside or in preference to the
generic error property. Preserve the status-based fallback when neither useful
property exists, so detailed Fastify validation context reaches the MCP client.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@server/src/mcp/apiClient.ts`:
- Around line 56-59: Update the error-response parsing in the apiClient request
failure handling to include the response object's message property when present,
alongside or in preference to the generic error property. Preserve the
status-based fallback when neither useful property exists, so detailed Fastify
validation context reaches the MCP client.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 39c46ff7-3347-42e5-810d-f3f971a0b7f7

📥 Commits

Reviewing files that changed from the base of the PR and between 5177ba4 and 78e9a29.

⛔ Files ignored due to path filters (1)
  • server/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (8)
  • server/package.json
  • server/src/api/analytics/generateCustomQuery.ts
  • server/src/index.ts
  • server/src/mcp/apiClient.ts
  • server/src/mcp/index.ts
  • server/src/mcp/inputs.ts
  • server/src/mcp/mcp.test.ts
  • server/src/mcp/tools.ts

Ports the protocol polish from codex/hosted-mcp onto the proxy
architecture and tightens the data boundary:

- Verify the API key before processing any MCP message (401 +
  WWW-Authenticate, 429 + Retry-After, 503 on verifier failure);
  tool calls still inherit the REST routes' own access checks
- Bump @modelcontextprotocol/sdk to 1.29.0 and add outputSchema +
  structuredContent to every tool (top-level shapes pinned, tolerant
  below, since REST shapes are owned by the dashboard endpoints)
- Gate get_sessions/get_events/run_query/get_query_schema behind
  MCP_ENABLE_RAW_DATA_TOOLS (default off); redact visitor IPs from
  all MCP output even when enabled
- Strip control/bidi-override chars from proxied strings; return
  generic messages for unexpected errors and log details server-side
- MCP-aware CORS: MCP_ALLOWED_ORIGINS allowlist for /api/mcp, MCP
  protocol headers in preflight, reject malformed Origin values
- Cache-Control: no-store and a 1MB body limit on the endpoint
- Move EVENT_SCHEMA to a leaf module so MCP tests stop importing the
  better-auth chain (fixes samlify/camelcase ESM collection failures)
- Add docs page (docs/mcp.mdx) and env/docker-compose wiring

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

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/src/mcp/tools.ts (1)

608-635: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Block IP leakage from aliased run_query results

ok(..., { redactKeys: IP_KEYS }) only removes literal ip keys, so SELECT ip AS visitor_ip FROM scoped_events still returns the address under a different field name. Since run_query accepts arbitrary SQL, redaction needs to be structural, not key-name based.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/mcp/tools.ts` around lines 608 - 635, Update the run_query handler
around the api.call result and ok invocation to apply structural redaction to
returned query rows, so IP addresses remain removed even when aliased (for
example, visitor_ip) or nested under other fields. Do not rely solely on
redactKeys: IP_KEYS; reuse the existing recursive redaction utility or add the
smallest equivalent transformation before returning the result.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@server/src/mcp/tools.ts`:
- Around line 608-635: Update the run_query handler around the api.call result
and ok invocation to apply structural redaction to returned query rows, so IP
addresses remain removed even when aliased (for example, visitor_ip) or nested
under other fields. Do not rely solely on redactKeys: IP_KEYS; reuse the
existing recursive redaction utility or add the smallest equivalent
transformation before returning the result.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 83feb54c-be3f-4ba0-8855-7e49485fcfc8

📥 Commits

Reviewing files that changed from the base of the PR and between 78e9a29 and 080162e.

⛔ Files ignored due to path filters (1)
  • server/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (15)
  • .env.example
  • docker-compose.cloud.yml
  • docker-compose.yml
  • docs/content/docs/(docs)/mcp.mdx
  • docs/content/docs/(docs)/meta.json
  • server/.env.example
  • server/package.json
  • server/src/api/analytics/generateCustomQuery.ts
  • server/src/api/analytics/utils/eventSchema.ts
  • server/src/lib/cors.test.ts
  • server/src/lib/cors.ts
  • server/src/mcp/auth.ts
  • server/src/mcp/index.ts
  • server/src/mcp/mcp.test.ts
  • server/src/mcp/tools.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/package.json

Grows the MCP surface PostHog-style while keeping the in-process proxy
architecture (tools dispatch to existing REST routes via fastify.inject,
inheriting their auth, role checks, and rate limits):

- Add 22 tools: reads (get_site, get_session, get_users, get_user,
  list_members, list_teams) and writes for goals (create/update/delete),
  funnels (save/delete), sites (create/update config/delete), org
  members and teams (add member, site access, team CRUD), and persons
  (identify, update traits, GDPR delete)
- Remove the MCP_ENABLE_RAW_DATA_TOOLS gate: raw-data tools and
  run_query are always registered
- Remove IP redaction for full REST parity; keep control/bidi-char
  sanitization of tracked-traffic text
- Split tools.ts into tools/ with one file per domain and shared
  helpers; widen RybbitApiClient to PUT/DELETE
- Annotate every tool explicitly: the five delete_* tools carry
  destructiveHint, updates are idempotent, reads are read-only
- Extend the 403 hint to explain admin/owner key-role requirements
- Rewrite the docs page for the read-write surface with permissions
  and destructive-tools sections; drop the gate from env/compose files

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

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/src/mcp/tools/users.ts`:
- Around line 92-95: Update the identify request in the guard callback to map
the input properties anonymous_id and user_id to camelCase JSON keys anonymousId
and userId, while preserving their values and the existing traits payload. Keep
the POST endpoint and surrounding api.call flow unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fc76f811-70e3-4434-8a96-22596ff55bc5

📥 Commits

Reviewing files that changed from the base of the PR and between 080162e and 83d9dcf.

📒 Files selected for processing (18)
  • .env.example
  • docker-compose.cloud.yml
  • docker-compose.yml
  • docs/content/docs/(docs)/mcp.mdx
  • server/.env.example
  • server/src/mcp/apiClient.ts
  • server/src/mcp/index.ts
  • server/src/mcp/inputs.ts
  • server/src/mcp/mcp.test.ts
  • server/src/mcp/tools/analytics.ts
  • server/src/mcp/tools/funnels.ts
  • server/src/mcp/tools/goals.ts
  • server/src/mcp/tools/index.ts
  • server/src/mcp/tools/organizations.ts
  • server/src/mcp/tools/rawData.ts
  • server/src/mcp/tools/shared.ts
  • server/src/mcp/tools/sites.ts
  • server/src/mcp/tools/users.ts
💤 Files with no reviewable changes (4)
  • server/.env.example
  • docker-compose.cloud.yml
  • docker-compose.yml
  • .env.example
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/src/mcp/apiClient.ts

Comment on lines +92 to +95
guard(async ({ site_id, anonymous_id, user_id, traits }) =>
ok(await api.call("POST", `/sites/${site_id}/users/identify`, { body: { anonymous_id, user_id, traits } }))
)
);

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Map JSON body payload properties to camelCase.

The backend REST API expects JSON body payloads to use camelCase (as evidenced by your usage of siteFeatureInputs, goalType, etc. in other tools). Passing anonymous_id and user_id directly in the POST body will likely cause the backend to drop or ignore these fields, resulting in a failed identity merge.

🐛 Proposed fix
-    guard(async ({ site_id, anonymous_id, user_id, traits }) =>
-      ok(await api.call("POST", `/sites/${site_id}/users/identify`, { body: { anonymous_id, user_id, traits } }))
+    guard(async ({ site_id, anonymous_id, user_id, traits }) =>
+      ok(await api.call("POST", `/sites/${site_id}/users/identify`, { body: { anonymousId: anonymous_id, userId: user_id, traits } }))
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
guard(async ({ site_id, anonymous_id, user_id, traits }) =>
ok(await api.call("POST", `/sites/${site_id}/users/identify`, { body: { anonymous_id, user_id, traits } }))
)
);
guard(async ({ site_id, anonymous_id, user_id, traits }) =>
ok(await api.call("POST", `/sites/${site_id}/users/identify`, { body: { anonymousId: anonymous_id, userId: user_id, traits } }))
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/src/mcp/tools/users.ts` around lines 92 - 95, Update the identify
request in the guard callback to map the input properties anonymous_id and
user_id to camelCase JSON keys anonymousId and userId, while preserving their
values and the existing traits payload. Keep the POST endpoint and surrounding
api.call flow unchanged.

Implements the MCP authorization flow so OAuth-capable clients can
connect without manually configured API keys:

- Enable the better-auth mcp plugin (authorization-code + PKCE, dynamic
  client registration, built-in consent page, /login as the login page)
- Serve RFC 8414/9728 discovery documents at the domain root:
  /.well-known/oauth-authorization-server and
  /.well-known/oauth-protected-resource (plus the /api/mcp-suffixed
  variant), backed by the plugin's metadata builders
- Advertise resource_metadata in the 401 WWW-Authenticate challenge
  from /api/mcp and expose the header for browser clients
- Accept OAuth access tokens wherever API keys work: the MCP
  authenticator and the REST layer (checkApiKey/getUserIdFromRequest)
  fall back to auth.api.getMcpSession with expiry checks, so proxied
  tool calls inherit the same org-role authorization
- Add oauthApplication/oauthAccessToken/oauthConsent tables to the
  drizzle schema (requires a one-time `npm run db:push`, not run here)
- CORS: discovery documents are public; /api/auth/mcp/* honors
  MCP_ALLOWED_ORIGINS like /api/mcp
- Docs: OAuth connect section and self-host migration notes

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

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/src/lib/auth-utils.ts`:
- Around line 352-356: Restrict OAuth token validation to Authorization-header
credentials by passing bearerToken, not apiKey, to getOAuthTokenUserId at both
affected sites: server/src/lib/auth-utils.ts lines 352-356 and 392-397. Keep the
existing resolveBearerUserOrgRole flow unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3b85f626-8b1a-4fb3-ac56-193a282144e9

📥 Commits

Reviewing files that changed from the base of the PR and between 83d9dcf and 669cefb.

📒 Files selected for processing (11)
  • docs/content/docs/(docs)/mcp.mdx
  • server/src/db/postgres/schema.ts
  • server/src/index.ts
  • server/src/lib/auth-utils.ts
  • server/src/lib/auth.ts
  • server/src/lib/cors.ts
  • server/src/mcp/auth.ts
  • server/src/mcp/index.ts
  • server/src/mcp/mcp.test.ts
  • server/src/mcp/wellKnown.test.ts
  • server/src/mcp/wellKnown.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • server/src/index.ts
  • server/src/mcp/index.ts
  • server/src/lib/cors.ts
  • server/src/mcp/mcp.test.ts
  • docs/content/docs/(docs)/mcp.mdx

Comment thread server/src/lib/auth-utils.ts Outdated
/api/mcp and the OAuth endpoints under /api/auth/mcp/* now follow the
standard CORS policy: trusted origins (BASE_URL + dev origins) or no
Origin header at all, which is what server-side MCP clients send. The
OAuth discovery documents stay publicly fetchable and the MCP protocol
headers stay in the shared preflight allowlist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MCP clients fetch /.well-known/oauth-* at the domain root; without this
handle those requests land on the client app and OAuth discovery fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This update introduces a new entry with index 10, version 7, and associated timestamp, enhancing the journal's tracking capabilities.
MCP clients try authorization-server discovery via RFC 8414 AND OIDC
discovery (/.well-known/openid-configuration), including path-inserted
variants for the /api/mcp resource. Only the base RFC 8414 path was
served, so clients like Claude Code choked on the client app's HTML 404
("JSON Parse error: Unrecognized token '<'"). Serve the same metadata
at all four variants, mark them CORS-public, and route the OIDC path to
the backend in Caddy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OAuth token exchanges are application/x-www-form-urlencoded per RFC
6749, but the better-auth mount only registered a passthrough parser
for application/json, so Fastify replied 415 before the auth handler
ran. Dynamic client registration (JSON) worked while every token
exchange failed, leaving MCP OAuth logins permanently unauthenticated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bearer credentials were all-or-nothing (full user org role); this adds a
scope layer so an agent can be handed a read-only or narrowly-scoped
credential. Scopes constrain bearer credentials only — cookie sessions
bypass by construction, and scopes never elevate (role checks still apply).

- lib/scopes.ts: resource:action taxonomy (15 resources), parsers for
  API-key permissions (Record) and OAuth token scopes (space string),
  hasScope reusing better-auth's role().authorize matcher (write⊇read),
  and the createApiKey zod schema. null statements = unrestricted (legacy
  keys / standard-only OAuth grants); {} = deny-all.
- auth-utils: checkApiKey returns BearerAuthResult carrying statements;
  OAuth fallback carries token scopes; getUserHasAccessToSitePublic
  threads the scope into its own bearer fallback.
- auth-middleware: the 6 bearer-capable guards become scope factories;
  the check sits inside the bearer branch only, replies 403
  { error: "Insufficient scope", required } distinct from role 403s.
- index.ts: per-route scope assignment via scoped guard helpers; account
  and billing routes are "deny-scoped".
- createApiKey: optional permissions body param, validated + forwarded.
- auth.ts: register the custom scopes in the mcp plugin's oidcConfig and
  advertise them in scopes_supported (AS + protected-resource metadata).
- MCP: authenticator carries scopes into tool registration; tools/list is
  filtered to the credential's scopes (list_sites always shown); tool
  calls still enforce authoritatively through the REST guards.
- trackEvent: trusted server-side ingestion requires ingest:write.

Backward compatible: existing keys and OAuth tokens read as unrestricted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Lets a user mint a least-privilege API key from Settings → Account: an
optional "Restrict permissions" toggle reveals a resource:action grid
(read/write per resource), and the selected permissions are sent to
POST /api/user/api-keys. Off by default = full-access key, unchanged.

- Promote the scope taxonomy to @rybbit/shared (SCOPE_MATRIX, types,
  ALL_SCOPE_STRINGS, plus SCOPE_DESCRIPTORS with UI labels) as pure data;
  server/src/lib/scopes.ts now imports it and keeps only the zod +
  better-auth matcher logic. Single source of truth, no client/server drift.
- ApiKeyScopePicker: checkbox grid over SCOPE_DESCRIPTORS; selecting write
  keeps read on and locked (mirrors the server's write⊇read rule); resources
  with a single action show one checkbox.
- ApiKeyManager: restrict toggle + picker; client-side guard against an
  empty restricted set (which the server rejects); reset on success.
- useCreateApiKey: accept optional permissions in the request body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the top verified findings from the branch review:

- #2 Member PII past scopes: GET /organizations returned every member's
  name and email to any bearer credential (even a read-only or deny-all
  key), while the sibling /members route is org:read-gated. Cookie-session
  dashboard requests still get the roster; bearer credentials get sites
  only (what list_sites needs) and use the org:read /members route for
  member data.
- #3 Bearer parsing fidelity: the MCP gate accepted a looser header form
  (lowercase scheme, extra spaces) than the REST routes it proxies via
  startsWith("Bearer ")+substring(7), so initialize/tools-list would
  authenticate and then every tools/call 401'd. Align extractBearerToken
  to REST exactly.
- #6 Silent wrong data: an LLM filling both past_minutes and a date range
  got the trailing window returned as the requested range. Reject the
  combination with a message the model can act on.
- #10 Discovery gap: custom scopes were set on oidcConfig.metadata, which
  only feeds the RFC 9728 doc; better-auth builds the RFC 8414 doc from a
  top-level option it doesn't expose. Inject scopes_supported in
  wellKnown.ts where we re-serve the root document; fix the wrong comment.
- #7 get_retention advertised range min 1 but the endpoint clamps to a
  7-day floor; reject <7 instead of returning a wider window than asked.
- #11 list_sites output schema is a strict boolean while site.public is
  nullable; coalesce so the entry-point tool can't throw on a null row.
- #4 Scope picker: write auto-added read even for write-only resources
  (ingest), producing a body the server 400s; only add read where valid.

Tests: extractBearerToken parity, past_minutes/date conflict, and a
getMyOrganizations PII test (session sees roster, bearer does not).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The verify -> rate-limit -> OAuth-fallback -> expiry ladder was
implemented three times (checkApiKey, getUserIdFromRequest, the MCP
gate), which is what let the gate and the REST routes drift apart on
Bearer parsing. Extract one resolveBearerIdentity into a leaf module
(lib/bearerAuth.ts) that takes injected deps, so the MCP layer and its
tests can use it without loading the better-auth chain. All three call
sites now share it, including the single canonical extractBearerToken.

#9 (2x rate-limit burn): a tool call verified the key at the MCP gate
and again inside the proxied REST route, and better-auth's verifyApiKey
has no non-counting mode, so a rate-limited key exhausted twice as fast.
The gate now registers an in-process handoff (a crypto-random,
credential-bound, request-scoped nonce passed on the inject header); the
REST guards consume it and skip the second verify. It is a pure
optimization that fails safe — a missing, stale, forged, or mismatched
nonce simply falls through to normal verification, so it can never
authenticate as another user. Org-role resolution still runs per route
(it's a DB query, not rate-limited), so access checks are unchanged.

Tests: resolveBearerIdentity ladder (key/oauth/expiry/rate-limit/error),
handoff safety (match, token-mismatch, missing, release), and a
checkApiKey test asserting verifyApiKey is NOT called on a valid handoff
but IS on a mismatched one.

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

The scope picker imported SCOPE_DESCRIPTORS/SCOPE_MATRIX as runtime values
from @rybbit/shared. Every other client import of that package is a type
(erased at compile), so the picker was the first to force Turbopack to
bundle the `file:../shared` workspace package at runtime — which it can't
resolve, failing the production build with "Can't resolve '@rybbit/shared'".

Inline the display taxonomy (matrix + labels) into the picker, matching how
the client has always used shared (types only). No runtime import from
shared remains, so no next.config transpilePackages/alias workaround is
needed. Move the UI-only SCOPE_DESCRIPTORS out of shared; the server keeps
SCOPE_MATRIX/ALL_SCOPE_STRINGS as its source of truth.

Verified: client production build compiles successfully with zero
module-resolution errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Introduced a new PermissionsBadge component to display API key permissions with tooltips for better user experience.
- Updated ApiKeyManager to handle API key creation and deletion more effectively, including improved clipboard functionality for copying keys.
- Enhanced ApiKeyScopePicker to include a clear selection option and improved UI for better accessibility and usability.
- Adjusted styles for better visual consistency across components.

These changes aim to improve the user interface and experience when managing API keys and their associated permissions.
@goldflag
goldflag merged commit 3bfde00 into master Jul 15, 2026
5 of 7 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Jul 16, 2026
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.

1 participant