Skip to content

feat(mcp): per-user OAuth for external MCP servers#124

Open
alexinthesky wants to merge 1 commit into
mainfrom
feat/mcp-per-user-oauth
Open

feat(mcp): per-user OAuth for external MCP servers#124
alexinthesky wants to merge 1 commit into
mainfrom
feat/mcp-per-user-oauth

Conversation

@alexinthesky

@alexinthesky alexinthesky commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Each Grafana user can now authorise external MCP servers with their own identity via OAuth 2.0 authorization-code + PKCE; the agent attaches that user's token on every tool call.
  • Admins can attach new OAuth-gated MCP servers from the plugin config page — three presets (GitHub read-only, GitHub read/write, Atlassian) plus a Generic form with optional Discover + DCR.
  • Servers without an oauth block keep working with the existing static-header path; graphiti etc. are unchanged.

Why

atlassian-broker-mcp through mcp-context-forge can't be reached with a static bearer — the broker requires a per-user OAuth grant. That same problem applies to GitHub's hosted MCP and to any future provider. Rather than bolt a forge-specific workaround, this change makes per-user OAuth a first-class capability of the MCP proxy: any provider that speaks RFC 6749 authorization-code (+ PKCE where supported) can be wired in.

Key design choices

  • Generic and opt-in: OAuth kicks in only when a ServerConfig has an oauth block. Per-request Authorization is swapped in by a round tripper that reads the user ID from the request context; no per-user MCP session is opened.
  • Discovery stack: RFC 8414 well-known probe first, fall back to the RFC 9728 resource-metadata advertised in the WWW-Authenticate challenge. For providers that advertise registration_endpoint, RFC 7591 dynamic client registration runs automatically (Atlassian, any MCP-spec-compliant server). For providers that don't (GitHub), the admin supplies a client_id (and optional client_secret) in the UI.
  • Storage: Redis when available, in-memory fallback — same selection pattern as the existing rate limiter. Tokens, PKCE state, and dynamic server records all follow the same pattern.
  • RBAC: the four OAuth routes (/start, /callback, /status, /disconnect) are per-user; the provisioner routes (/api/mcp/provisioner/*) require the Admin role.

What ships

Backend

  • pkg/mcp/types.goOAuthConfig added to ServerConfig.
  • pkg/mcp/oauth.goPerUserTokenProvider, userTokenRoundTripper, ctx helpers.
  • pkg/mcp/client.go — OAuth round tripper plugged into both connect paths; static Authorization skipped when OAuth is set; session.CallTool receives the caller ctx.
  • pkg/mcp/proxy.goSetPerUserTokenProvider propagates to every Client; ctx on CallToolWithContext.
  • pkg/agent/loop.goLoopRequest.UserID; set on ctx before each MCP call.
  • pkg/plugin/oauth/ (new package) — token/state/dynamic-server stores (memory + Redis), OAuth handlers, discovery + DCR helpers, preset catalogue.
  • pkg/plugin/mcp_provisioner.go — admin-only provisioner HTTP endpoints.
  • pkg/plugin/plugin.go — construction, route wiring, restore of dynamic servers on boot, per-user OAuth state in /api/mcp/servers.

Frontend

  • src/services/oauthClient.ts, mcpProvisionerClient.ts — typed wrappers, popup + postMessage listener.
  • src/services/mcpServerStatus.ts — response type carries an oauth map.
  • src/components/MCPStatus/MCPStatus.tsx — Connect / Disconnect row per OAuth-enabled server.
  • src/components/AppConfig/ExternalMCPs.tsx (new) — three preset cards + Generic form.

Spec

  • pkg/plugin/openapi/openapi.json — four OAuth routes and five provisioner routes documented.

Test plan

  • go build ./..., go test ./pkg/... (all 6 packages pass)
  • npm run typecheck, npm run lint (0 errors), npm run test:ci (466/466)
  • npm run validate:openapi
  • Atlassian end-to-end against mcp.atlassian.com verified manually: DCR succeeds, authorize URL discovered, token exchange works, per-user bearer attached, tools/call returns real content instead of "Atlassian rejected the user's token".
  • GitHub preset (requires an OAuth App in the GitHub developer console; left for the reviewer to dogfood).
  • Mimic flow from a second Grafana user profile: confirm each user's tokens are isolated.

Screens / flow

  1. Admin opens plugin config → External MCP servers (OAuth) → clicks Add on Atlassian. Backend discovers + DCRs, persists, attaches to the proxy.
  2. End user opens the MCP Status panel → clicks Connect on the Atlassian row. Popup to auth.atlassian.com, accept, popup closes, status flips to Connected.
  3. Agent tool calls that route to Atlassian succeed.

🤖 Generated with Claude Code


Note

High Risk
High risk because it introduces new OAuth authorization-code/PKCE flows, per-user token storage/refresh, and changes how Authorization headers and request contexts are propagated through the MCP proxy and agent tool calls.

Overview
Adds per-user OAuth authentication for MCP tool calls: ServerConfig gains an oauth block, MCP clients/proxy accept a PerUserTokenProvider, and outbound requests use a new round-tripper that injects a user-specific Authorization: Bearer ... while skipping any static Authorization header for OAuth-enabled servers.

Introduces a plugin OAuth manager + HTTP API to start/callback/status/disconnect OAuth flows, persist tokens/state (Redis or in-memory), refresh tokens when near expiry, and expose per-server OAuth connection status to the UI.

Adds admin-only dynamic MCP server provisioning (preset + generic) with optional OAuth metadata discovery and dynamic client registration, persists these servers, restores them on startup, and updates the frontend AppConfig and MCP status panel to provision servers and let end users Connect/Disconnect. OpenAPI spec and tests are updated accordingly.

Reviewed by Cursor Bugbot for commit 3aa47a9. Bugbot is set up for automated code reviews on this repo. Configure here.

Each Grafana user now authorises external MCP servers with their own
identity via an OAuth2 authorization-code + PKCE flow. The agent attaches
that user's token on every tool call. Non-OAuth MCP servers keep the
existing static-header path.

Backend

- pkg/mcp/types.go: ServerConfig gains an OAuth block (authorizationURL,
  tokenURL, clientID, scopes, PKCE, redirectURI).
- pkg/mcp/oauth.go: PerUserTokenProvider interface, userTokenRoundTripper
  that injects the current user's bearer on every outbound MCP request,
  and ctx helpers (WithUserID / UserIDFromContext).
- pkg/mcp/client.go: OAuth round tripper plugged into both connectMCP
  paths; static Authorization from config.Headers is skipped when OAuth
  is configured. session.CallTool now receives the caller ctx so the
  user ID propagates.
- pkg/mcp/proxy.go: SetPerUserTokenProvider propagates to every Client;
  CallToolWithContext takes a context.Context.
- pkg/agent/loop.go: LoopRequest carries UserID; the value is set on the
  context before each MCP call.
- pkg/plugin/oauth/: new package with
  * UserTokenStore (memory + Redis), StateStore (memory + Redis),
    PKCE helpers, errors, Manager (bridges ctx -> bearer lookup).
  * HTTP handlers for /start, /callback, /status, /disconnect.
  * Discovery: RFC 8414 authorization-server metadata and RFC 9728
    protected-resource metadata (via WWW-Authenticate challenge).
  * Dynamic client registration per RFC 7591.
  * Preset catalogue (GitHub read-only, GitHub read/write, Atlassian).
  * DynamicServerStore for runtime-added servers (memory + Redis).
- pkg/plugin/mcp_provisioner.go: admin-only endpoints to add a preset,
  add a generic OAuth MCP, list or remove provisioned servers.
- pkg/plugin/plugin.go: builds token / state / dynamic-server stores,
  wires the OAuth manager into the MCP proxy, registers OAuth and
  provisioner routes, restores dynamic servers on boot, surfaces per
  user OAuth state in /api/mcp/servers.

Frontend

- src/services/oauthClient.ts and mcpProvisionerClient.ts: typed wrappers
  over the new backend routes, plus a postMessage listener so the popup
  callback can trigger a status refresh.
- src/services/mcpServerStatus.ts: response type carries an `oauth` map.
- src/components/MCPStatus/MCPStatus.tsx: Connect / Disconnect row per
  OAuth-enabled server with a same-tab fallback when popups are blocked.
- src/components/AppConfig/ExternalMCPs.tsx (new): admin section with
  the three preset cards plus a generic form (manual URLs or Discover +
  DCR).

Spec + tests

- pkg/plugin/openapi/openapi.json: four OAuth routes and five
  provisioner routes documented.
- New Go tests cover token store, state store, PKCE, context key,
  callback handler happy path + replay rejection, discovery
  (well-known and challenge) and dynamic client registration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3aa47a9. Configure here.

setTimeout(function() { window.close(); }, 500);
</script>
</body></html>`, statusWord(success), statusWord(success), serverID, success, reason)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reflected XSS via OAuth callback error parameters

High Severity

writeCallbackHTML injects the reason string into a <script> block using Go's %q format verb, which does not escape HTML-significant sequences like </script>. When handleCallback receives a provider error, it passes user-controlled query parameters (error and error_description) as the reason. An attacker can craft a callback URL with error_description=</script><script>alert(document.cookie)</script> to break out of the script block and execute arbitrary JavaScript on the Grafana origin.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3aa47a9. Configure here.

}}
>
{busy ? 'Adding…' : 'Add'}
</Button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Top "Add" button omits clientId for non-DCR presets

Low Severity

For non-DCR presets (e.g. GitHub), the top "Add" button becomes enabled once the user types a clientId, but onProvision({ ...preset }) spreads a fresh copy without the __clientId property. In handleProvision, clientId resolves to undefined, so addPreset is called without credentials and the backend returns a 400. The "Add with Client ID" button works correctly via mutation, but both buttons are simultaneously visible and enabled, leading to a confusing failure path.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3aa47a9. Configure here.

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