Skip to content

feat: add MCP OAuth mode#359

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

feat: add MCP OAuth mode#359
titouanmathis wants to merge 13 commits intozereight:mainfrom
studiometa:feature/mcp-oauth

Conversation

@titouanmathis
Copy link

@titouanmathis titouanmathis commented Mar 5, 2026

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, 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 mcp scope, which is insufficient for API calls (need api or read_api).

  1. Go to your GitLab instance → Admin Area > Applications (instance-wide) or User Settings > Applications (personal)
  2. Create a new application with:
    • Confidential: unchecked
    • Scopes: api, read_api, read_user
  3. Save and copy the Application ID — this is your GITLAB_OAUTH_APP_ID

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) — handled locally by the MCP server (each client gets a virtual client ID)
  4. Client redirects the user's browser to GitLab's login page using the pre-registered OAuth application
  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

The server enforces at least the api scope during authorization, even if the MCP client requests fewer scopes (e.g. Claude.ai sends ai_workflows which is insufficient).

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_OAUTH_APP_ID="your-gitlab-oauth-app-client-id" \
  -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
GITLAB_OAUTH_APP_ID Yes Client ID of the pre-registered GitLab OAuth application
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 8 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>
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>
@titouanmathis titouanmathis changed the title feat: add MCP OAuth mode (GITLAB_MCP_OAUTH=true) feat: add MCP OAuth mode Mar 8, 2026
@PedroFCM
Copy link

PedroFCM commented Mar 9, 2026

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:

  1. OAuth scopes should respect GITLAB_READ_ONLY_MODE

REQUIRED_GITLAB_SCOPES is hardcoded to ["api"]. When GITLAB_READ_ONLY_MODE=true, it should default to ["read_api"] to follow least-privilege. I implemented this by passing a readOnly flag through createGitLabOAuthProvider():

// 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
);
  1. Add trust proxy for reverse proxy deployments

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.
Adding before app.use(express.json()) fixes this - Express reads X-Forwarded-For for the real client IP

// 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.
@titouanmathis
Copy link
Author

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!

@PedroFCM
Copy link

PedroFCM commented Mar 9, 2026

It works as expected, thanks!

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.ts implementing OAuthServerProvider with LRU client cache, token exchange, verification, and revocation against GitLab
  • Integration into index.ts with auth router mounting, bearer-auth middleware on /mcp, and per-session token storage mirroring the existing REMOTE_AUTHORIZATION pattern
  • 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
Comment on lines +7598 to +7603
// 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
Comment on lines +502 to +550
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 |
Comment on lines +329 to +355
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");
Comment on lines +515 to +582
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"
);
Copy link
Owner

@zereight zereight left a comment

Choose a reason for hiding this comment

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

Thanks
Check ai review plz!

titouanmathis and others added 3 commits March 16, 2026 09:04
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>
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.

4 participants