Skip to content

feat: add MCP OAuth mode (GITLAB_MCP_OAUTH=true)#359

Open
titouanmathis wants to merge 7 commits intozereight:mainfrom
studiometa:feature/mcp-oauth
Open

feat: add MCP OAuth mode (GITLAB_MCP_OAUTH=true)#359
titouanmathis wants to merge 7 commits intozereight:mainfrom
studiometa:feature/mcp-oauth

Conversation

@titouanmathis
Copy link

Problem

When deploying this MCP server for a team using a self-hosted GitLab instance, there is no way for multiple users to authenticate with their own GitLab accounts without each one manually generating a Personal Access Token and configuring it in their MCP client. This is friction-heavy, hard to manage at scale, and breaks when tokens expire.

This PR adds a GITLAB_MCP_OAUTH=true mode that enables the MCP spec OAuth flow: users authenticate directly with GitLab through their MCP client's browser flow, tokens are managed per session on the server, and no pre-registered GitLab OAuth application or manual token management is required.

How it works

  1. User adds the MCP server URL in their MCP client (e.g. Claude.ai)
  2. Client discovers OAuth endpoints via /.well-known/oauth-authorization-server
  3. Client registers itself via Dynamic Client Registration (POST /register) — proxied to GitLab's open DCR endpoint; no pre-registration needed
  4. Client redirects the user's browser to the GitLab login page
  5. User authenticates; GitLab redirects back to the client's callback URL
  6. Client sends Authorization: Bearer <token> on every MCP request
  7. Server validates the token against GitLab's /oauth/token/info and stores it per session

All existing auth modes (REMOTE_AUTHORIZATION, GITLAB_USE_OAUTH, PAT, cookie) are completely unchanged.

Configuration

docker run -d \
  -e STREAMABLE_HTTP=true \
  -e GITLAB_MCP_OAUTH=true \
  -e GITLAB_API_URL="https://gitlab.example.com/api/v4" \
  -e MCP_SERVER_URL="https://your-mcp-server.example.com" \
  -p 3002:3002 \
  zereight050/gitlab-mcp

Claude.ai config — no headers needed:

{
  "mcpServers": {
    "GitLab": {
      "url": "https://your-mcp-server.example.com/mcp"
    }
  }
}
Variable Required Description
GITLAB_MCP_OAUTH Yes Set to true to enable
MCP_SERVER_URL Yes Public HTTPS URL of your MCP server
GITLAB_API_URL Yes GitLab instance API URL
STREAMABLE_HTTP Yes Must be true (SSE not supported)
MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL No true for local HTTP dev only

titouanmathis and others added 7 commits March 5, 2026 15:05
Adds server-side GitLab OAuth proxy support via the MCP spec OAuth flow.
When GITLAB_MCP_OAUTH=true, Claude.ai (and any MCP-spec client) can
authenticate users against any GitLab instance via browser-based OAuth
with per-session token isolation — no manual PAT management required.

How it works:
- ProxyOAuthServerProvider (oauth-proxy.ts) delegates all OAuth operations
  (authorize, token exchange, refresh, revocation, DCR) to GitLab
- GitLab's open Dynamic Client Registration (/oauth/register) means no
  pre-registered OAuth app is needed on the GitLab side
- mcpAuthRouter mounts discovery + DCR endpoints on the MCP server
- requireBearerAuth validates each /mcp request; token stored per session
  in authBySession for reuse by buildAuthHeaders() via AsyncLocalStorage
- All existing auth modes (PAT, cookie, REMOTE_AUTHORIZATION, USE_OAUTH)
  are completely unchanged

New files:
- oauth-proxy.ts: createGitLabOAuthProvider() factory
- test/mcp-oauth-tests.ts: unit + integration tests (9 tests, all passing)

Changed files:
- index.ts: imports, GITLAB_MCP_OAUTH/MCP_SERVER_URL constants, auth router
  mount, requireBearerAuth middleware on /mcp, validateConfiguration()
  extension, startup guard, session lifecycle (onclose, shutdown, DELETE)
- test/utils/server-launcher.ts: skip GITLAB_TOKEN check for MCP OAuth mode
- test/utils/mock-gitlab-server.ts: add addRootHandler() + rootRouter for
  OAuth endpoints mounted outside /api/v4
- package.json: add test:mcp-oauth script; include mcp-oauth-tests in test:mock
- .env.example, README.md: document new env vars and setup

New env vars:
- GITLAB_MCP_OAUTH=true         enable this mode
- MCP_SERVER_URL                public HTTPS URL of the MCP server (required)
- MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL=true  local HTTP dev only
The SDK's authorize handler calls clientsStore.getClient(clientId) to
validate redirect_uri before calling provider.authorize(). With the
original stub (redirect_uris: []), Claude.ai's redirect_uri was always
rejected as 'Unregistered redirect_uri' before the proxy could forward
the authorization request to GitLab.

Fix: subclass ProxyOAuthServerProvider and override the clientsStore
getter to wrap registerClient with an in-memory cache. After DCR, the
full GitLab response (including redirect_uris) is cached per client_id.
Subsequent getClient() calls return the cached entry with real
redirect_uris, allowing the authorize handler to proceed.

Added test: 'clientsStore caches DCR response so getClient returns
real redirect_uris after registration' (10/10 tests passing).
The unbounded Map could grow without limit if POST /register is called
repeatedly (e.g. by a misconfigured or abusive client). Each entry is
~500 bytes so 1000 entries = ~500 KB max — negligible for legitimate
use (typically < 10 distinct MCP client apps), but capped against abuse.

Introduced BoundedClientCache: a Map-backed LRU cache using JS insertion-
order semantics. get() refreshes an entry to the tail; set() evicts the
least-recently-used head when the cap is reached. O(1) for both ops, no
external dependencies.

Tests added:
- LRU: most-recently-used client survives when oldest is evicted
- cache: re-registration updates the stored entry
(12/12 tests passing)
Append ' via <resourceName>' to the client_name forwarded to GitLab
during DCR so the OAuth consent screen reads:

  [Unverified Dynamic Application] Claude via GitLab MCP Server
  is requesting access to your account

instead of just 'Claude', giving users context about which server
is requesting access on their behalf.

The resourceName defaults to 'GitLab MCP Server' and is passed
through createGitLabOAuthProvider(gitlabBaseUrl, resourceName).
GitLabProxyOAuthServerProvider now takes resourceName as a second
constructor argument so the clientsStore getter can reference it.
buildAuthHeaders() only checked REMOTE_AUTHORIZATION to read from
AsyncLocalStorage, causing all GitLab API calls to be sent without
auth headers when GITLAB_MCP_OAUTH was enabled instead.

Also update the stored token on every request so that refreshed
OAuth tokens are picked up instead of reusing the expired one.

Co-authored-by: Claude <claude@anthropic.com>
Some MCP clients (e.g. Claude.ai) send an empty or insufficient scope
(like ai_workflows) when initiating the OAuth flow. Without at least
the 'api' scope, every GitLab API call returns 403 insufficient_scope.

Override authorize() to inject the required scopes when the client does
not request them, ensuring the resulting token can actually call the
GitLab API.

Co-authored-by: Claude <claude@anthropic.com>
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