Conversation
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>
GitLab restricts dynamically registered (unverified) applications to the 'mcp' scope, which is insufficient for API calls (need 'api' or 'read_api'). Every tool call returned 403 insufficient_scope. Replace the ProxyOAuthServerProvider (which proxied DCR to GitLab) with a custom OAuthServerProvider that: - handles DCR locally (virtual client_id per MCP client) - substitutes the real GITLAB_OAUTH_APP_ID for authorize/token calls - injects required scopes when the client omits them Requires a new GITLAB_OAUTH_APP_ID env var pointing to a GitLab OAuth application created in Admin > Applications with scopes: api, read_api, read_user. Co-authored-by: Claude <claude@anthropic.com>
|
Hi @titouanmathis! Great work on this branch, I see it bringing great value for me as well. I've been testing it with my Gitlab instance, by deploying the MCP on AWS (behind AWS ALB on ECS Fargate) and it's working well! A couple of suggestions from my deployment tests:
// oauth-proxy.ts — constructor
this._requiredScopes = readOnly ? ["read_api"] : ["api"];
// index.ts — call site
const oauthProvider = createGitLabOAuthProvider(
gitlabBaseUrl, GITLAB_OAUTH_APP_ID!, "GitLab MCP Server", GITLAB_READ_ONLY_MODE
);
When running behind a load balancer (ALB, nginx, etc.), express-rate-limit sees all requests from the proxy's IP and rate-limits everyone together. // Configure Express middleware
app.set("trust proxy", 1) // new instruction
app.use(express.json())Happy to open a PR against your branch with either or both changes if you're interested. |
Use read_api scope instead of api when GITLAB_READ_ONLY_MODE=true to follow least-privilege principle.
When running behind a load balancer (ALB, nginx, etc.), express-rate-limit sees all requests from the proxy's IP. Setting trust proxy makes Express read X-Forwarded-For for the real client IP.
|
Hey @PedroFCM, thanks for the feedback, I pushed 2 commits that should address both of your suggestions. Let me know if it works well for you! |
|
It works as expected, thanks! |
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=truemode that enables the MCP spec OAuth flow: users authenticate directly with GitLab through their MCP client's browser flow, and tokens are managed per session on the server.Prerequisites
A pre-registered GitLab OAuth application is required. GitLab restricts dynamically registered (unverified) applications to the
mcpscope, which is insufficient for API calls (needapiorread_api).api,read_api,read_userGITLAB_OAUTH_APP_IDHow it works
/.well-known/oauth-authorization-serverPOST /register) — handled locally by the MCP server (each client gets a virtual client ID)Authorization: Bearer <token>on every MCP request/oauth/token/infoand stores it per sessionThe server enforces at least the
apiscope during authorization, even if the MCP client requests fewer scopes (e.g. Claude.ai sendsai_workflowswhich is insufficient).All existing auth modes (
REMOTE_AUTHORIZATION,GITLAB_USE_OAUTH, PAT, cookie) are completely unchanged.Configuration
Claude.ai config — no
headersneeded:{ "mcpServers": { "GitLab": { "url": "https://your-mcp-server.example.com/mcp" } } }GITLAB_MCP_OAUTHtrueto enableGITLAB_OAUTH_APP_IDMCP_SERVER_URLGITLAB_API_URLSTREAMABLE_HTTPtrue(SSE not supported)MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URLtruefor local HTTP dev only