feat(mcp): per-user OAuth for external MCP servers#124
Conversation
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ 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) | ||
| } |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 3aa47a9. Configure here.
| }} | ||
| > | ||
| {busy ? 'Adding…' : 'Add'} | ||
| </Button> |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 3aa47a9. Configure here.


Summary
oauthblock keep working with the existing static-header path; graphiti etc. are unchanged.Why
atlassian-broker-mcpthroughmcp-context-forgecan'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
ServerConfighas anoauthblock. Per-requestAuthorizationis swapped in by a round tripper that reads the user ID from the request context; no per-user MCP session is opened.WWW-Authenticatechallenge. For providers that advertiseregistration_endpoint, RFC 7591 dynamic client registration runs automatically (Atlassian, any MCP-spec-compliant server). For providers that don't (GitHub), the admin supplies aclient_id(and optionalclient_secret) in the UI./start,/callback,/status,/disconnect) are per-user; the provisioner routes (/api/mcp/provisioner/*) require the Admin role.What ships
Backend
pkg/mcp/types.go—OAuthConfigadded toServerConfig.pkg/mcp/oauth.go—PerUserTokenProvider,userTokenRoundTripper, ctx helpers.pkg/mcp/client.go— OAuth round tripper plugged into both connect paths; static Authorization skipped when OAuth is set;session.CallToolreceives the caller ctx.pkg/mcp/proxy.go—SetPerUserTokenProviderpropagates to everyClient; ctx onCallToolWithContext.pkg/agent/loop.go—LoopRequest.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 +postMessagelistener.src/services/mcpServerStatus.ts— response type carries anoauthmap.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:openapimcp.atlassian.comverified manually: DCR succeeds, authorize URL discovered, token exchange works, per-user bearer attached,tools/callreturns real content instead of "Atlassian rejected the user's token".Screens / flow
🤖 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
Authorizationheaders and request contexts are propagated through the MCP proxy and agent tool calls.Overview
Adds per-user OAuth authentication for MCP tool calls:
ServerConfiggains anoauthblock, MCP clients/proxy accept aPerUserTokenProvider, and outbound requests use a new round-tripper that injects a user-specificAuthorization: Bearer ...while skipping any staticAuthorizationheader 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.