Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,17 @@ NO_PROXY=localhost,127.0.0.1
STREAMABLE_HTTP=true
REMOTE_AUTHORIZATION=true
GITLAB_API_URL="https://gitlab.com/api/v4"

# MCP OAuth — server-side GitLab OAuth proxy for Claude.ai integration
# Enables the MCP spec OAuth flow: Claude.ai drives the browser auth,
# users log in with their GitLab account, tokens are validated per-session.
# Requires STREAMABLE_HTTP=true. Incompatible with SSE.
# Requires a pre-registered GitLab OAuth application (Admin > Applications)
# with scopes: api, read_api, read_user — GitLab DCR limits dynamic apps
# to the "mcp" scope which is insufficient for API calls.
# GITLAB_MCP_OAUTH=true
# GITLAB_OAUTH_APP_ID=your-gitlab-oauth-app-client-id
# MCP_SERVER_URL=https://your-mcp-server.example.com
# GITLAB_API_URL=https://gitlab.example.com/api/v4
# STREAMABLE_HTTP=true
# MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL=true # local dev only (HTTP)
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,80 @@ The token is stored per session (identified by `mcp-session-id` header) and reus
- **Rate limiting:** Each session is limited to `MAX_REQUESTS_PER_MINUTE` requests per minute (default 60)
- **Capacity limit:** Server accepts up to `MAX_SESSIONS` concurrent sessions (default 1000)

### MCP OAuth Setup (Claude.ai Native OAuth)

When using `GITLAB_MCP_OAUTH=true`, the server acts as an OAuth proxy to your GitLab
instance. Claude.ai (and any MCP-spec-compliant client) handles the entire browser
authentication flow automatically — no manual Personal Access Token management needed.

**How it works:**

1. User adds your MCP server URL in Claude.ai
2. Claude.ai discovers OAuth endpoints via `/.well-known/oauth-authorization-server`
3. Claude.ai registers itself via Dynamic Client Registration (`POST /register`)
4. Claude.ai redirects the user's browser to your GitLab login page
5. User authenticates; GitLab redirects back to `https://claude.ai/api/mcp/auth_callback`
6. Claude.ai sends `Authorization: Bearer <token>` on every MCP request
7. Server validates the token with GitLab and stores it per session

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 |

**Important Notes:**

- MCP OAuth **only works with Streamable HTTP transport** (`SSE=true` is incompatible)
- Each user session stores its own OAuth token — sessions are fully isolated
- Session timeout, rate limiting, and capacity limits apply identically to the
`REMOTE_AUTHORIZATION` mode (`SESSION_TIMEOUT_SECONDS`, `MAX_REQUESTS_PER_MINUTE`,
`MAX_SESSIONS`)

## Tools 🛠️

<details>
Expand Down
167 changes: 153 additions & 14 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { AsyncLocalStorage } from "node:async_hooks";
import express, { Request, Response } from "express";
import express, { NextFunction, Request, Response } from "express";
import fetchCookie from "fetch-cookie";
import fs from "node:fs";
import os from "node:os";
Expand All @@ -43,6 +43,9 @@ import { fileURLToPath, URL } from "node:url";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { initializeOAuth } from "./oauth.js";
import { createGitLabOAuthProvider } from "./oauth-proxy.js";
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import { GitLabClientPool } from "./gitlab-client-pool.js";
// Add type imports for proxy agents
import { Agent } from "node:http";
Expand Down Expand Up @@ -458,13 +461,50 @@ function validateConfiguration(): void {
const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
const hasToken = !!getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
const hasCookie = !!getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
const mcpOAuth = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
const mcpServerUrl = getConfig("mcp-server-url", "MCP_SERVER_URL");

if (!remoteAuth && !useOAuth && !hasToken && !hasCookie) {
if (!remoteAuth && !useOAuth && !hasToken && !hasCookie && !mcpOAuth) {
errors.push(
"Either --token, --cookie-path, --use-oauth=true, or --remote-auth=true must be set (or use environment variables)"
"Either --token, --cookie-path, --use-oauth=true, --remote-auth=true, or --mcp-oauth=true must be set (or use environment variables)"
);
}

if (mcpOAuth) {
if (!mcpServerUrl) {
errors.push(
"MCP_SERVER_URL is required when GITLAB_MCP_OAUTH=true (e.g. https://mcp.example.com)"
);
} else {
try {
const u = new URL(mcpServerUrl);
const isInsecure = u.protocol !== "https:";
const isLocalhost = u.hostname === "localhost" || u.hostname === "127.0.0.1";
const allowInsecure =
process.env.MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL === "true";
if (isInsecure && !isLocalhost && !allowInsecure) {
errors.push(
"MCP_SERVER_URL must use HTTPS in production " +
"(set MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL=true for local dev)"
);
}
} catch {
errors.push(`MCP_SERVER_URL is not a valid URL: ${mcpServerUrl}`);
}
}

if (!getConfig("api-url", "GITLAB_API_URL")) {
errors.push("GITLAB_API_URL is required when GITLAB_MCP_OAUTH=true");
}

if (!getConfig("oauth-app-id", "GITLAB_OAUTH_APP_ID")) {
errors.push(
"GITLAB_OAUTH_APP_ID is required when GITLAB_MCP_OAUTH=true " +
"(create an OAuth application in GitLab Admin with the required scopes)"
);
}
}

const enableDynamicApiUrl =
getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
if (enableDynamicApiUrl && !remoteAuth) {
Expand Down Expand Up @@ -527,6 +567,9 @@ const GITLAB_TOOLS_RAW = getConfig("tools", "GITLAB_TOOLS");
const SSE = getConfig("sse", "SSE") === "true";
const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
const GITLAB_MCP_OAUTH = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
const MCP_SERVER_URL = getConfig("mcp-server-url", "MCP_SERVER_URL");
const GITLAB_OAUTH_APP_ID = getConfig("oauth-app-id", "GITLAB_OAUTH_APP_ID");
const ENABLE_DYNAMIC_API_URL =
getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
const SESSION_TIMEOUT_SECONDS = Number.parseInt(
Expand Down Expand Up @@ -740,11 +783,11 @@ const BASE_HEADERS: Record<string, string> = {

/**
* Build authentication headers dynamically based on context
* In REMOTE_AUTHORIZATION mode, reads from AsyncLocalStorage session context
* In REMOTE_AUTHORIZATION or GITLAB_MCP_OAUTH mode, reads from AsyncLocalStorage session context
* Otherwise, uses environment token
*/
function buildAuthHeaders(): Record<string, string> {
if (REMOTE_AUTHORIZATION) {
if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
const ctx = sessionAuthStore.getStore();
logger.debug({ context: ctx }, "buildAuthHeaders: session context");
if (ctx?.token) {
Expand Down Expand Up @@ -1843,7 +1886,23 @@ if (REMOTE_AUTHORIZATION) {
process.exit(1);
}
logger.info("Remote authorization enabled: tokens will be read from HTTP headers");
} else if (!USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
}

if (GITLAB_MCP_OAUTH) {
if (SSE) {
logger.error("GITLAB_MCP_OAUTH=true is not compatible with SSE transport mode");
logger.error("Please use STREAMABLE_HTTP=true instead");
process.exit(1);
}
if (!STREAMABLE_HTTP) {
logger.error("GITLAB_MCP_OAUTH=true requires STREAMABLE_HTTP=true");
logger.error("Set STREAMABLE_HTTP=true to enable MCP OAuth");
process.exit(1);
}
logger.info("MCP OAuth enabled: GitLab OAuth proxy active");
}

if (!REMOTE_AUTHORIZATION && !GITLAB_MCP_OAUTH && !USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_AUTH_COOKIE_PATH) {
// Standard mode: token must be in environment (unless using OAuth)
logger.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true");
Expand Down Expand Up @@ -7536,17 +7595,52 @@ async function startStreamableHTTPServer(): Promise<void> {
};

// Configure Express middleware
// 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) {
const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4\/?$/, "").replace(/\/$/, "");
const issuerUrl = new URL(MCP_SERVER_URL!);
const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID!, "GitLab MCP Server", GITLAB_READ_ONLY_MODE);

// Mounts /.well-known/oauth-authorization-server,
// /.well-known/oauth-protected-resource,
// /authorize, /token, /register, /revoke
app.use(
mcpAuthRouter({
provider: oauthProvider,
issuerUrl,
baseUrl: issuerUrl,
scopesSupported: ["api", "read_api", "read_user"],
resourceName: "GitLab MCP Server",
})
);

// Expose provider so the /mcp route middleware can reference it
(app as any)._mcpOAuthProvider = oauthProvider;
}

// Build bearer-auth middleware — no-op unless GITLAB_MCP_OAUTH is enabled.
// Unauthenticated requests receive 401 + WWW-Authenticate header, which is
// exactly what Claude.ai needs to trigger the OAuth browser flow.
const mcpBearerAuth = GITLAB_MCP_OAUTH
? requireBearerAuth({
verifier: (app as any)._mcpOAuthProvider,
requiredScopes: [],
})
: (_req: Request, _res: Response, next: NextFunction) => next();

// Streamable HTTP endpoint - handles both session creation and message handling
app.post("/mcp", async (req: Request, res: Response) => {
app.post("/mcp", mcpBearerAuth, async (req: Request, res: Response) => {
const sessionId = req.headers["mcp-session-id"] as string;

// Track request
metrics.requestsProcessed++;

// Rate limiting check for existing sessions
if (REMOTE_AUTHORIZATION && sessionId && !checkRateLimit(sessionId)) {
if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && !checkRateLimit(sessionId)) {
metrics.rejectedByRateLimit++;
res.status(429).json({
error: "Rate limit exceeded",
Expand Down Expand Up @@ -7598,6 +7692,33 @@ async function startStreamableHTTPServer(): Promise<void> {
}
}

// MCP OAuth mode — token already validated by requireBearerAuth middleware.
// req.auth is populated by the middleware; store/refresh per session so that
// buildAuthHeaders() can pick it up via AsyncLocalStorage, exactly like the
// REMOTE_AUTHORIZATION path.
if (GITLAB_MCP_OAUTH) {
const authInfo = req.auth;
if (authInfo?.token && sessionId) {
if (!authBySession[sessionId]) {
authBySession[sessionId] = {
header: "Authorization",
token: authInfo.token,
lastUsed: Date.now(),
apiUrl: GITLAB_API_URL,
};
logger.info(
`Session ${sessionId}: stored OAuth token (client: ${authInfo.clientId})`
);
setAuthTimeout(sessionId);
} else {
// Update token on every request — the client may have refreshed it
authBySession[sessionId].token = authInfo.token;
authBySession[sessionId].lastUsed = Date.now();
setAuthTimeout(sessionId);
}
}
}

// Handle request with proper AsyncLocalStorage context
const handleRequest = async () => {
try {
Expand Down Expand Up @@ -7627,6 +7748,23 @@ async function startStreamableHTTPServer(): Promise<void> {
setAuthTimeout(newSessionId);
}
}

// Store OAuth token for newly created session in MCP OAuth mode
if (GITLAB_MCP_OAUTH && !authBySession[newSessionId]) {
const authInfo = req.auth;
if (authInfo?.token) {
authBySession[newSessionId] = {
header: "Authorization",
token: authInfo.token,
lastUsed: Date.now(),
apiUrl: GITLAB_API_URL,
};
logger.info(
`Session ${newSessionId}: stored OAuth token (client: ${authInfo.clientId})`
);
setAuthTimeout(newSessionId);
}
}
},
});

Expand All @@ -7637,7 +7775,7 @@ async function startStreamableHTTPServer(): Promise<void> {
logger.warn(`Streamable HTTP transport closed for session ${sid}, cleaning up`);
delete streamableTransports[sid];
metrics.activeSessions--;
if (REMOTE_AUTHORIZATION) {
if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
cleanupSessionAuth(sid);
delete sessionRequestCounts[sid];
logger.info(`Session ${sid}: cleaned up auth mapping`);
Expand All @@ -7662,8 +7800,8 @@ async function startStreamableHTTPServer(): Promise<void> {
}
};

// Execute with auth context in remote mode
if (REMOTE_AUTHORIZATION && sessionId && authBySession[sessionId]) {
// Execute with auth context in remote mode (REMOTE_AUTHORIZATION or GITLAB_MCP_OAUTH)
if ((REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) && sessionId && authBySession[sessionId]) {
const authData = authBySession[sessionId];
const ctx: SessionAuth = {
sessionId,
Expand All @@ -7676,7 +7814,7 @@ async function startStreamableHTTPServer(): Promise<void> {
// Run the entire request handling within AsyncLocalStorage context
await sessionAuthStore.run(ctx, handleRequest);
} else {
// Standard execution (no remote auth or no session yet)
// Standard execution (no per-session auth or no session yet)
await handleRequest();
}
});
Expand Down Expand Up @@ -7704,6 +7842,7 @@ async function startStreamableHTTPServer(): Promise<void> {
maxRequestsPerMinute: MAX_REQUESTS_PER_MINUTE,
sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS,
remoteAuthEnabled: REMOTE_AUTHORIZATION,
mcpOAuthEnabled: GITLAB_MCP_OAUTH,
},
});
});
Expand Down Expand Up @@ -7734,7 +7873,7 @@ async function startStreamableHTTPServer(): Promise<void> {
try {
await transport.close();
logger.info(`Explicitly closed session via DELETE request: ${sessionId}`);
if (REMOTE_AUTHORIZATION) {
if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
cleanupSessionAuth(sessionId);
delete sessionRequestCounts[sessionId];
logger.info(`Session ${sessionId}: cleaned up auth mapping on DELETE`);
Expand Down Expand Up @@ -7773,7 +7912,7 @@ async function startStreamableHTTPServer(): Promise<void> {
const transport = streamableTransports[sessionId];
if (transport) {
await transport.close();
if (REMOTE_AUTHORIZATION) {
if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
cleanupSessionAuth(sessionId);
delete sessionRequestCounts[sessionId];
}
Expand Down
Loading