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! |
There was a problem hiding this comment.
Pull request overview
This PR adds a new GITLAB_MCP_OAUTH=true mode that enables MCP-spec-compliant OAuth authentication for the GitLab MCP server. It allows users (e.g., on Claude.ai) to authenticate via a browser-based GitLab OAuth flow instead of manually managing Personal Access Tokens. The server acts as an OAuth proxy: it handles Dynamic Client Registration locally (issuing virtual client IDs mapped to a pre-registered GitLab OAuth application), redirects authorization to GitLab, and validates tokens per session.
Changes:
- New
oauth-proxy.tsimplementingOAuthServerProviderwith LRU client cache, token exchange, verification, and revocation against GitLab - Integration into
index.tswith auth router mounting, bearer-auth middleware on/mcp, and per-session token storage mirroring the existingREMOTE_AUTHORIZATIONpattern - New test suite, documentation in README/.env.example, and test utilities updated for the new auth mode
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| oauth-proxy.ts | New OAuth proxy provider: LRU client cache, DCR, authorize redirect, token exchange/refresh/revoke, token verification |
| index.ts | Integration: config validation, auth router + bearer middleware mounting, per-session token storage, cleanup hooks |
| test/mcp-oauth-tests.ts | New test suite for discovery endpoints, auth enforcement, cache behavior, and provider unit tests |
| test/utils/server-launcher.ts | Updated to support GITLAB_MCP_OAUTH as a server-managed auth mode (+ formatting) |
| test/utils/mock-gitlab-server.ts | Added root-level handler support for OAuth endpoints outside /api/v4 (+ formatting) |
| README.md | Documentation for MCP OAuth setup |
| .env.example | Example environment variables for MCP OAuth |
| package.json | Added test:mcp-oauth script and included in test:mock |
| package-lock.json | Removed peer: true flags from several transitive dependencies |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
index.ts
Outdated
| // Trust first proxy so express-rate-limit uses X-Forwarded-For for real client IP | ||
| app.set("trust proxy", 1); | ||
| app.use(express.json()); | ||
|
|
||
| // MCP OAuth — mount auth router and prepare bearer-auth middleware | ||
| if (GITLAB_MCP_OAUTH) { |
README.md
Outdated
| No GitLab OAuth application needs to be pre-created — GitLab's open DCR handles | ||
| client registration automatically. | ||
|
|
||
| **Server setup:** | ||
|
|
||
| ```bash | ||
| 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 | ||
| ``` | ||
|
|
||
| For local development (HTTP allowed): | ||
|
|
||
| ```bash | ||
| MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL=true \ | ||
| STREAMABLE_HTTP=true \ | ||
| GITLAB_MCP_OAUTH=true \ | ||
| MCP_SERVER_URL=http://localhost:3002 \ | ||
| GITLAB_API_URL=https://gitlab.com/api/v4 \ | ||
| node build/index.js | ||
| ``` | ||
|
|
||
| **Claude.ai configuration:** | ||
|
|
||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "GitLab": { | ||
| "url": "https://your-mcp-server.example.com/mcp" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| No `headers` field is needed — Claude.ai obtains the token via OAuth automatically. | ||
|
|
||
| **Environment variables:** | ||
|
|
||
| | 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 | Your GitLab instance API URL (e.g. `https://gitlab.com/api/v4`) | | ||
| | `STREAMABLE_HTTP` | Yes | Must be `true` (SSE is not supported) | | ||
| | `MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL` | No | Set `true` for local HTTP dev only | |
| test("LRU: both clients remain cached after sequential registration", async () => { | ||
| const { provider, stub } = await buildCachingProvider(); | ||
|
|
||
| try { | ||
| const store = provider.clientsStore; | ||
|
|
||
| // Register two clients with known client_ids (simulating what the SDK does | ||
| // — it generates the client_id before calling registerClient) | ||
| await store.registerClient!({ | ||
| client_id: "id-A", | ||
| client_name: "Claude", | ||
| redirect_uris: ["https://a.com/cb"], | ||
| token_endpoint_auth_method: "none", | ||
| } as any); | ||
| await store.registerClient!({ | ||
| client_id: "id-B", | ||
| client_name: "Cursor", | ||
| redirect_uris: ["https://b.com/cb"], | ||
| token_endpoint_auth_method: "none", | ||
| } as any); | ||
|
|
||
| const a = await store.getClient("id-A"); | ||
| const b = await store.getClient("id-B"); | ||
|
|
||
| assert.deepStrictEqual(a!.redirect_uris, ["https://a.com/cb"], "client-A still cached"); | ||
| assert.deepStrictEqual(b!.redirect_uris, ["https://b.com/cb"], "client-B still cached"); | ||
| console.log(" ✓ Both clients remain cached after sequential registration"); |
| test("clientsStore caches DCR response so getClient returns real redirect_uris", async () => { | ||
| // Spin up a stub DCR server that echoes back the request body as-is | ||
| const { createServer } = await import("node:http"); | ||
| const REGISTERED_CLIENT_ID = "cached-client-id-abc"; | ||
| const REGISTERED_REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback"; | ||
|
|
||
| const stub = createServer((req, res) => { | ||
| if (req.method === "POST" && req.url === "/oauth/register") { | ||
| let body = ""; | ||
| req.on("data", c => (body += c)); | ||
| req.on("end", () => { | ||
| const parsed = JSON.parse(body); | ||
| res.writeHead(201, { "Content-Type": "application/json" }); | ||
| res.end( | ||
| JSON.stringify({ | ||
| client_id: REGISTERED_CLIENT_ID, | ||
| client_name: `[Unverified Dynamic Application] ${parsed.client_name}`, | ||
| redirect_uris: parsed.redirect_uris ?? [REGISTERED_REDIRECT_URI], | ||
| token_endpoint_auth_method: "none", | ||
| require_pkce: true, | ||
| }) | ||
| ); | ||
| }); | ||
| } else { | ||
| res.writeHead(404); | ||
| res.end(); | ||
| } | ||
| }); | ||
|
|
||
| await new Promise<void>(resolve => stub.listen(0, "127.0.0.1", resolve)); | ||
| const addr = stub.address() as { port: number }; | ||
| const baseUrl = `http://127.0.0.1:${addr.port}`; | ||
|
|
||
| try { | ||
| const { createGitLabOAuthProvider } = await import("../oauth-proxy.js"); | ||
| const provider = createGitLabOAuthProvider(baseUrl, "test-app-id", "My MCP Server"); | ||
|
|
||
| // Before registration: stub returns empty redirect_uris | ||
| const beforeReg = await provider.clientsStore.getClient(REGISTERED_CLIENT_ID); | ||
| assert.deepStrictEqual(beforeReg!.redirect_uris, [], "Should be empty before registration"); | ||
|
|
||
| // Simulate DCR registration (as the SDK would call it) | ||
| const registered = await provider.clientsStore.registerClient!({ | ||
| client_name: "Claude", | ||
| redirect_uris: [REGISTERED_REDIRECT_URI], | ||
| token_endpoint_auth_method: "none", | ||
| }); | ||
|
|
||
| assert.strictEqual(registered.client_id, REGISTERED_CLIENT_ID, "client_id from GitLab"); | ||
| assert.deepStrictEqual( | ||
| registered.redirect_uris, | ||
| [REGISTERED_REDIRECT_URI], | ||
| "redirect_uris from GitLab" | ||
| ); | ||
|
|
||
| // client_name forwarded to GitLab should be annotated with the resource name | ||
| assert.ok( | ||
| registered.client_name?.includes("Claude via My MCP Server"), | ||
| `client_name should include 'Claude via My MCP Server', got: ${registered.client_name}` | ||
| ); | ||
|
|
||
| // After registration: getClient returns cached entry with real redirect_uris | ||
| const afterReg = await provider.clientsStore.getClient(REGISTERED_CLIENT_ID); | ||
| assert.deepStrictEqual( | ||
| afterReg!.redirect_uris, | ||
| [REGISTERED_REDIRECT_URI], | ||
| "getClient should return real redirect_uris from cache after registration" | ||
| ); |
app.set('trust proxy', 1) n'est maintenant activé que lorsque
GITLAB_MCP_OAUTH=true, évitant de modifier le comportement pour
les déploiements existants qui pourraient être directement exposés
à Internet (risque de spoofing d'IP via X-Forwarded-For).
Co-authored-by: Claude <claude@anthropic.com>
registerClient génère un UUID aléatoire comme virtual client_id et ne réutilise pas le client_id passé en entrée. Les tests doivent utiliser le client_id retourné pour les lookups getClient. Ajoute aussi GITLAB_OAUTH_APP_ID dans l'env des tests d'intégration qui lançaient un vrai serveur (exit code 1 sans cette variable). Co-authored-by: Claude <claude@anthropic.com>
Supprime la mention erronée 'No GitLab OAuth application needs to be pre-created' et ajoute les instructions de création d'application, GITLAB_OAUTH_APP_ID dans les exemples Docker et la table des variables. Co-authored-by: Claude <claude@anthropic.com>
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