diff --git a/.env.example b/.env.example index 6e3eb50a..5f1898f5 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/README.md b/README.md index 443049f4..acfcea5d 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 🛠️
diff --git a/index.ts b/index.ts index 865cc00d..d213d73f 100644 --- a/index.ts +++ b/index.ts @@ -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"; @@ -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"; @@ -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) { @@ -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( @@ -740,11 +783,11 @@ const BASE_HEADERS: Record = { /** * 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 { - if (REMOTE_AUTHORIZATION) { + if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) { const ctx = sessionAuthStore.getStore(); logger.debug({ context: ctx }, "buildAuthHeaders: session context"); if (ctx?.token) { @@ -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"); @@ -7536,17 +7595,52 @@ async function startStreamableHTTPServer(): Promise { }; // 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", @@ -7598,6 +7692,33 @@ async function startStreamableHTTPServer(): Promise { } } + // 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 { @@ -7627,6 +7748,23 @@ async function startStreamableHTTPServer(): Promise { 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); + } + } }, }); @@ -7637,7 +7775,7 @@ async function startStreamableHTTPServer(): Promise { 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`); @@ -7662,8 +7800,8 @@ async function startStreamableHTTPServer(): Promise { } }; - // 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, @@ -7676,7 +7814,7 @@ async function startStreamableHTTPServer(): Promise { // 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(); } }); @@ -7704,6 +7842,7 @@ async function startStreamableHTTPServer(): Promise { maxRequestsPerMinute: MAX_REQUESTS_PER_MINUTE, sessionTimeoutSeconds: SESSION_TIMEOUT_SECONDS, remoteAuthEnabled: REMOTE_AUTHORIZATION, + mcpOAuthEnabled: GITLAB_MCP_OAUTH, }, }); }); @@ -7734,7 +7873,7 @@ async function startStreamableHTTPServer(): Promise { 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`); @@ -7773,7 +7912,7 @@ async function startStreamableHTTPServer(): Promise { const transport = streamableTransports[sessionId]; if (transport) { await transport.close(); - if (REMOTE_AUTHORIZATION) { + if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) { cleanupSessionAuth(sessionId); delete sessionRequestCounts[sessionId]; } diff --git a/oauth-proxy.ts b/oauth-proxy.ts new file mode 100644 index 00000000..db4679a1 --- /dev/null +++ b/oauth-proxy.ts @@ -0,0 +1,354 @@ +/** + * MCP OAuth Proxy — GitLab upstream + * + * Builds an OAuthServerProvider that handles the MCP spec OAuth flow while + * delegating actual authentication to a GitLab instance. + * + * ### Why not pure GitLab DCR? + * + * GitLab restricts dynamically registered (unverified) applications to the + * `mcp` scope, which is insufficient for API calls (need `api` or `read_api`). + * To work around this, the MCP server uses a **pre-registered GitLab OAuth + * application** (set via GITLAB_OAUTH_APP_ID env var) with the required scopes, + * and handles DCR locally — each MCP client gets a unique virtual client_id + * mapped to the real GitLab app. + * + * ### Flow + * + * 1. MCP client calls POST /register (DCR) — proxy stores redirect_uris locally + * and returns a virtual client_id. + * 2. MCP client redirects to /authorize — proxy replaces the virtual client_id + * with the real GitLab app client_id and forwards to GitLab. + * 3. User authorizes on GitLab — redirect comes back with auth code. + * 4. MCP client calls POST /token — proxy exchanges the code with GitLab using + * the real client_id. + * + * Activated when GITLAB_MCP_OAUTH=true. All other auth modes are unaffected. + */ + +import { InvalidTokenError, ServerError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { + OAuthClientInformationFull, + OAuthTokens, + OAuthTokenRevocationRequest, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js"; +import type { AuthorizationParams, OAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/provider.js"; +import type { Response } from "express"; +import { randomUUID } from "node:crypto"; +import { pino } from "pino"; + +const logger = pino({ name: "gitlab-mcp-oauth-proxy" }); + +/** + * Shape of the response from GitLab's /oauth/token/info endpoint. + * @see https://docs.gitlab.com/ee/api/oauth2.html#retrieve-the-token-information + */ +export interface GitLabTokenInfo { + resource_owner_id: number; + scopes: string[]; + expires_in_seconds: number | null; + application: { uid: string } | null; + created_at: number; +} + +// --------------------------------------------------------------------------- +// Bounded LRU client cache +// --------------------------------------------------------------------------- + +const CLIENT_CACHE_MAX_SIZE = 1000; + +class BoundedClientCache { + private readonly _map = new Map(); + private readonly _maxSize: number; + + constructor(maxSize: number) { + this._maxSize = maxSize; + } + + get(clientId: string): OAuthClientInformationFull | undefined { + const entry = this._map.get(clientId); + if (entry) { + this._map.delete(clientId); + this._map.set(clientId, entry); + } + return entry; + } + + set(clientId: string, client: OAuthClientInformationFull): void { + if (this._map.has(clientId)) { + this._map.delete(clientId); + } else if (this._map.size >= this._maxSize) { + const lruKey = this._map.keys().next().value; + if (lruKey !== undefined) this._map.delete(lruKey); + } + this._map.set(clientId, client); + } + + get size(): number { + return this._map.size; + } +} + +// --------------------------------------------------------------------------- +// GitLab OAuth Server Provider +// --------------------------------------------------------------------------- + +/** + * Minimum GitLab scopes required for the MCP server to function. + * Injected into the authorization request when the client does not request them. + */ +const REQUIRED_GITLAB_SCOPES_RW = ["api"]; +const REQUIRED_GITLAB_SCOPES_RO = ["read_api"]; + +class GitLabOAuthServerProvider implements OAuthServerProvider { + /** + * Tell the SDK not to validate PKCE locally — GitLab handles it. + */ + readonly skipLocalPkceValidation = true; + + private readonly _gitlabBaseUrl: string; + private readonly _gitlabAppId: string; + private readonly _resourceName: string; + private readonly _requiredScopes: string[]; + private readonly _clientCache = new BoundedClientCache(CLIENT_CACHE_MAX_SIZE); + + constructor(gitlabBaseUrl: string, gitlabAppId: string, resourceName: string, readOnly: boolean) { + this._gitlabBaseUrl = gitlabBaseUrl; + this._gitlabAppId = gitlabAppId; + this._resourceName = resourceName; + this._requiredScopes = readOnly ? REQUIRED_GITLAB_SCOPES_RO : REQUIRED_GITLAB_SCOPES_RW; + } + + // ---- Client store (local DCR) ------------------------------------------ + + get clientsStore(): OAuthRegisteredClientsStore { + const cache = this._clientCache; + const resourceName = this._resourceName; + + return { + getClient: async (clientId: string) => { + const cached = cache.get(clientId); + if (cached) return cached; + + // Unknown client — return a minimal stub so token exchange can proceed + // (GitLab is the ultimate validator). + return { + client_id: clientId, + redirect_uris: [], + token_endpoint_auth_method: "none" as const, + }; + }, + + registerClient: async ( + client: Omit + ) => { + // Generate a virtual client_id; all real OAuth operations use _gitlabAppId. + const virtualClientId = randomUUID(); + + const registered: OAuthClientInformationFull = { + client_id: virtualClientId, + client_id_issued_at: Math.floor(Date.now() / 1000), + redirect_uris: client.redirect_uris ?? [], + token_endpoint_auth_method: "none", + grant_types: client.grant_types ?? ["authorization_code"], + client_name: client.client_name + ? `${client.client_name} via ${resourceName}` + : resourceName, + }; + + cache.set(virtualClientId, registered); + logger.info( + `DCR: registered virtual client ${virtualClientId} (name: ${registered.client_name})` + ); + return registered; + }, + }; + } + + // ---- Authorize --------------------------------------------------------- + + async authorize( + _client: OAuthClientInformationFull, + params: AuthorizationParams, + res: Response + ): Promise { + const scopes = params.scopes ?? []; + const hasRequired = this._requiredScopes.some((s) => scopes.includes(s)); + const effectiveScopes = hasRequired + ? scopes + : [...new Set([...scopes, ...this._requiredScopes])]; + + // Build the GitLab authorize URL with the REAL app client_id + const targetUrl = new URL(`${this._gitlabBaseUrl}/oauth/authorize`); + const searchParams = new URLSearchParams({ + client_id: this._gitlabAppId, + response_type: "code", + redirect_uri: params.redirectUri, + code_challenge: params.codeChallenge, + code_challenge_method: "S256", + }); + + if (params.state) searchParams.set("state", params.state); + if (effectiveScopes.length) searchParams.set("scope", effectiveScopes.join(" ")); + if (params.resource) searchParams.set("resource", params.resource.href); + + targetUrl.search = searchParams.toString(); + + logger.info( + `authorize: redirecting to GitLab (app: ${this._gitlabAppId}, scopes: ${effectiveScopes.join(" ")})` + ); + res.redirect(targetUrl.toString()); + } + + // ---- PKCE challenge (delegated to GitLab) ------------------------------ + + async challengeForAuthorizationCode( + _client: OAuthClientInformationFull, + _authorizationCode: string + ): Promise { + return ""; + } + + // ---- Token exchange ---------------------------------------------------- + + async exchangeAuthorizationCode( + _client: OAuthClientInformationFull, + authorizationCode: string, + codeVerifier?: string, + redirectUri?: string, + resource?: URL + ): Promise { + const params = new URLSearchParams({ + grant_type: "authorization_code", + client_id: this._gitlabAppId, + code: authorizationCode, + }); + + if (codeVerifier) params.append("code_verifier", codeVerifier); + if (redirectUri) params.append("redirect_uri", redirectUri); + if (resource) params.append("resource", resource.href); + + const response = await fetch(`${this._gitlabBaseUrl}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + + if (!response.ok) { + const body = await response.text(); + logger.error(`Token exchange failed (${response.status}): ${body}`); + throw new ServerError(`Token exchange failed: ${response.status}`); + } + + const data = await response.json(); + return OAuthTokensSchema.parse(data); + } + + // ---- Refresh token ----------------------------------------------------- + + async exchangeRefreshToken( + _client: OAuthClientInformationFull, + refreshToken: string, + scopes?: string[], + resource?: URL + ): Promise { + const params = new URLSearchParams({ + grant_type: "refresh_token", + client_id: this._gitlabAppId, + refresh_token: refreshToken, + }); + + if (scopes?.length) params.set("scope", scopes.join(" ")); + if (resource) params.set("resource", resource.href); + + const response = await fetch(`${this._gitlabBaseUrl}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + + if (!response.ok) { + const body = await response.text(); + logger.error(`Token refresh failed (${response.status}): ${body}`); + throw new ServerError(`Token refresh failed: ${response.status}`); + } + + const data = await response.json(); + return OAuthTokensSchema.parse(data); + } + + // ---- Verify access token ----------------------------------------------- + + async verifyAccessToken(token: string): Promise { + const res = await fetch(`${this._gitlabBaseUrl}/oauth/token/info`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + throw new InvalidTokenError("Invalid or expired GitLab OAuth token"); + } + + const info = (await res.json()) as GitLabTokenInfo; + + return { + token, + clientId: info.application?.uid ?? "dynamic", + scopes: info.scopes ?? [], + expiresAt: + info.expires_in_seconds != null + ? Math.floor(Date.now() / 1000) + info.expires_in_seconds + : undefined, + }; + } + + // ---- Revoke token ------------------------------------------------------ + + async revokeToken( + _client: OAuthClientInformationFull, + request: OAuthTokenRevocationRequest + ): Promise { + const params = new URLSearchParams({ + token: request.token, + client_id: this._gitlabAppId, + }); + + if (request.token_type_hint) { + params.set("token_type_hint", request.token_type_hint); + } + + const response = await fetch(`${this._gitlabBaseUrl}/oauth/revoke`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + + if (!response.ok) { + throw new ServerError(`Token revocation failed: ${response.status}`); + } + + await response.body?.cancel(); + } +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Build a GitLabOAuthServerProvider for the given GitLab instance. + * + * @param gitlabBaseUrl Root URL of the GitLab instance (no trailing slash, no /api/v4). + * @param gitlabAppId Client ID of the pre-registered GitLab OAuth application. + * @param resourceName Human-readable name shown on the GitLab consent screen. + */ +export function createGitLabOAuthProvider( + gitlabBaseUrl: string, + gitlabAppId: string, + resourceName = "GitLab MCP Server", + readOnly = false +): GitLabOAuthServerProvider { + return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly); +} diff --git a/package-lock.json b/package-lock.json index aec977ef..cb541a35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -887,7 +887,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -1046,7 +1045,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz", "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -1134,7 +1132,6 @@ "integrity": "sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.33.0", "@typescript-eslint/types": "8.33.0", @@ -1349,7 +1346,6 @@ "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2063,7 +2059,6 @@ "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -2296,7 +2291,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2841,7 +2835,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -4367,7 +4360,6 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4538,7 +4530,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index ea352c1b..39c8498f 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "changelog": "auto-changelog -p", "test": "npm run test:all", "test:all": "npm run build && npm run test:mock && npm run test:live", - "test:mock": "npx tsx --test test/remote-auth-simple-test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts", + "test:mock": "npx tsx --test test/remote-auth-simple-test.ts && npx tsx --test test/mcp-oauth-tests.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts", + "test:mcp-oauth": "npm run build && npx tsx --test test/mcp-oauth-tests.ts", "test:live": "node test/validate-api.js", "test:remote-auth": "npm run build && npx tsx --test test/remote-auth-simple-test.ts", "test:oauth": "tsx test/oauth-tests.ts", diff --git a/test/mcp-oauth-tests.ts b/test/mcp-oauth-tests.ts new file mode 100644 index 00000000..2aaa742e --- /dev/null +++ b/test/mcp-oauth-tests.ts @@ -0,0 +1,591 @@ +/** + * MCP OAuth Tests + * Tests for the GITLAB_MCP_OAUTH=true server-side OAuth proxy mode. + * + * The suite uses a mock GitLab server that implements the minimal OAuth + * endpoints (token/info, DCR register, well-known metadata) so no live + * GitLab instance is required. + */ + +import { describe, test, after, before } from "node:test"; +import assert from "node:assert"; +import { + launchServer, + findAvailablePort, + cleanupServers, + ServerInstance, + TransportMode, + HOST, +} from "./utils/server-launcher.js"; +import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const MOCK_OAUTH_TOKEN = "ya29.mock-oauth-token-abcdef123456"; +const MOCK_CLIENT_ID = "mock-app-uid-from-dcr"; + +const MOCK_GITLAB_PORT_BASE = 9200; +const MCP_SERVER_PORT_BASE = 3200; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Add minimal OAuth endpoints to a MockGitLabServer. + * + * GitLab serves these at the instance root (not under /api/v4), so we use + * addRootHandler rather than addMockHandler. + * + * Endpoints added: + * GET /oauth/token/info — token introspection (Bearer header required) + * POST /oauth/register — Dynamic Client Registration stub + * GET /.well-known/oauth-authorization-server — AS metadata + */ +function addOAuthEndpoints( + mockGitLab: MockGitLabServer, + validToken: string, + clientId: string, + baseUrl: string +): void { + // Token introspection — called by verifyAccessToken() + mockGitLab.addRootHandler("get", "/oauth/token/info", (req, res) => { + const auth = req.headers["authorization"] as string | undefined; + const token = auth?.replace(/^Bearer\s+/i, ""); + + if (token !== validToken) { + res.status(401).json({ error: "invalid_token" }); + return; + } + + res.json({ + resource_owner_id: 42, + scopes: ["api"], + expires_in_seconds: 7200, + application: { uid: clientId }, + created_at: Math.floor(Date.now() / 1000), + }); + }); + + // Dynamic Client Registration — proxied by mcpAuthRouter + mockGitLab.addRootHandler("post", "/oauth/register", (req, res) => { + res.status(201).json({ + client_id: clientId, + client_name: req.body?.client_name ?? "test", + redirect_uris: req.body?.redirect_uris ?? [], + token_endpoint_auth_method: "none", + require_pkce: true, + }); + }); + + // OAuth Authorization Server well-known metadata + mockGitLab.addRootHandler("get", "/.well-known/oauth-authorization-server", (_req, res) => { + res.json({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/oauth/authorize`, + token_endpoint: `${baseUrl}/oauth/token`, + registration_endpoint: `${baseUrl}/oauth/register`, + revocation_endpoint: `${baseUrl}/oauth/revoke`, + scopes_supported: ["api", "read_api", "read_user"], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + }); + }); +} + +// --------------------------------------------------------------------------- +// Test suite: Discovery endpoints +// --------------------------------------------------------------------------- + +describe("MCP OAuth — Discovery Endpoints", () => { + let mcpUrl: string; + let mcpBaseUrl: string; + let mockGitLab: MockGitLabServer; + let servers: ServerInstance[] = []; + + before(async () => { + const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE); + mockGitLab = new MockGitLabServer({ + port: mockPort, + validTokens: [MOCK_OAUTH_TOKEN], + }); + await mockGitLab.start(); + const mockGitLabUrl = mockGitLab.getUrl(); + + addOAuthEndpoints(mockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl); + + const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE); + mcpBaseUrl = `http://${HOST}:${mcpPort}`; + mcpUrl = `${mcpBaseUrl}/mcp`; + + const server = await launchServer({ + mode: TransportMode.STREAMABLE_HTTP, + port: mcpPort, + timeout: 5000, + env: { + STREAMABLE_HTTP: "true", + GITLAB_MCP_OAUTH: "true", + GITLAB_API_URL: `${mockGitLabUrl}/api/v4`, + MCP_SERVER_URL: mcpBaseUrl, + MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true", + }, + }); + servers.push(server); + + console.log(`Mock GitLab: ${mockGitLabUrl}`); + console.log(`MCP Server: ${mcpBaseUrl}`); + }); + + after(async () => { + cleanupServers(servers); + if (mockGitLab) { + await mockGitLab.stop(); + } + }); + + test("GET /.well-known/oauth-authorization-server returns AS metadata", async () => { + const res = await fetch(`${mcpBaseUrl}/.well-known/oauth-authorization-server`); + assert.strictEqual(res.status, 200, "Should return 200"); + + const body = (await res.json()) as Record; + assert.ok(body.issuer, "Should have issuer"); + assert.ok(body.authorization_endpoint, "Should have authorization_endpoint"); + assert.ok(body.token_endpoint, "Should have token_endpoint"); + assert.ok(body.registration_endpoint, "Should have registration_endpoint"); + console.log(" ✓ AS metadata returned with all required fields"); + }); + + test("GET /.well-known/oauth-protected-resource returns resource metadata", async () => { + // The SDK mounts the protected resource metadata at + // /.well-known/oauth-protected-resource{pathname} where pathname is derived + // from resourceServerUrl (or issuerUrl). With issuerUrl = "http://host:port/" + // the pathname "/" is stripped, yielding the bare endpoint. + const res = await fetch(`${mcpBaseUrl}/.well-known/oauth-protected-resource`); + assert.strictEqual(res.status, 200, "Should return 200"); + + const body = (await res.json()) as Record; + assert.ok(body.resource, "Should have resource field"); + console.log(" ✓ Protected resource metadata returned"); + }); +}); + +// --------------------------------------------------------------------------- +// Test suite: /mcp auth enforcement +// --------------------------------------------------------------------------- + +describe("MCP OAuth — /mcp Auth Enforcement", () => { + let mcpUrl: string; + let mcpBaseUrl: string; + let mockGitLab: MockGitLabServer; + let servers: ServerInstance[] = []; + + before(async () => { + const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 50); + mockGitLab = new MockGitLabServer({ + port: mockPort, + validTokens: [MOCK_OAUTH_TOKEN], + }); + await mockGitLab.start(); + const mockGitLabUrl = mockGitLab.getUrl(); + + addOAuthEndpoints(mockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl); + + const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 50); + mcpBaseUrl = `http://${HOST}:${mcpPort}`; + mcpUrl = `${mcpBaseUrl}/mcp`; + + const server = await launchServer({ + mode: TransportMode.STREAMABLE_HTTP, + port: mcpPort, + timeout: 5000, + env: { + STREAMABLE_HTTP: "true", + GITLAB_MCP_OAUTH: "true", + GITLAB_API_URL: `${mockGitLabUrl}/api/v4`, + MCP_SERVER_URL: mcpBaseUrl, + MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true", + }, + }); + servers.push(server); + }); + + after(async () => { + cleanupServers(servers); + if (mockGitLab) { + await mockGitLab.stop(); + } + }); + + test("POST /mcp without Authorization header returns 401 with WWW-Authenticate", async () => { + const res = await fetch(mcpUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + + assert.strictEqual(res.status, 401, "Should return 401 Unauthorized"); + + const wwwAuth = res.headers.get("www-authenticate"); + assert.ok(wwwAuth, "Should have WWW-Authenticate header"); + assert.ok( + wwwAuth.toLowerCase().startsWith("bearer"), + "WWW-Authenticate should use Bearer scheme" + ); + console.log(" ✓ 401 returned with WWW-Authenticate header (no auth)"); + console.log(` ℹ️ WWW-Authenticate: ${wwwAuth}`); + }); + + test("POST /mcp with invalid token returns 401", async () => { + const res = await fetch(mcpUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer invalid-token-xyz", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + + assert.strictEqual(res.status, 401, "Should return 401 for invalid token"); + console.log(" ✓ 401 returned for invalid OAuth token"); + }); + + test("POST /mcp with valid Bearer token returns non-401", async () => { + const res = await fetch(mcpUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${MOCK_OAUTH_TOKEN}`, + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + }), + }); + + assert.notStrictEqual(res.status, 401, "Should not return 401 with valid token"); + assert.notStrictEqual(res.status, 403, "Should not return 403 with valid token"); + console.log(` ✓ Valid token accepted (status: ${res.status})`); + }); +}); + +// --------------------------------------------------------------------------- +// Test suite: BoundedClientCache unit tests +// --------------------------------------------------------------------------- + +describe("MCP OAuth — BoundedClientCache", () => { + // Access the internal class via a minimal provider (it's not exported directly) + // by driving it through the public clientsStore API. + + function makeClient(id: string, redirectUri = "https://example.com/cb"): Record { + return { + client_id: id, + redirect_uris: [redirectUri], + token_endpoint_auth_method: "none", + }; + } + + async function buildCachingProvider() { + // Spin up a DCR stub that returns a stable client_id from the request body. + // The stub reads client_id from the incoming body (set by the SDK's register + // handler before calling registerClient), so cache keys are predictable. + const { createServer } = await import("node:http"); + const stub = createServer((req, res) => { + let body = ""; + req.on("data", chunk => (body += chunk)); + req.on("end", () => { + const parsed = JSON.parse(body || "{}"); + res.writeHead(201, { "Content-Type": "application/json" }); + // Echo client_id back so tests can look it up by a known key + res.end( + JSON.stringify({ + client_id: parsed.client_id ?? "unknown", + client_name: parsed.client_name ?? "unnamed", + redirect_uris: parsed.redirect_uris ?? [], + token_endpoint_auth_method: "none", + }) + ); + }); + }); + await new Promise(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}`; + + const { createGitLabOAuthProvider } = await import("../oauth-proxy.js"); + const provider = createGitLabOAuthProvider(baseUrl, "test-app-id"); + + return { provider, stub }; + } + + 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"); + } finally { + stub.close(); + } + }); + + test("cache: re-registration with same client_id updates the stored entry", async () => { + const { provider, stub } = await buildCachingProvider(); + + try { + const store = provider.clientsStore; + + await store.registerClient!({ + client_id: "id-A", + client_name: "Claude", + redirect_uris: ["https://old.com/cb"], + token_endpoint_auth_method: "none", + } as any); + const first = await store.getClient("id-A"); + assert.deepStrictEqual(first!.redirect_uris, ["https://old.com/cb"]); + + await store.registerClient!({ + client_id: "id-A", + client_name: "Claude", + redirect_uris: ["https://new.com/cb"], + token_endpoint_auth_method: "none", + } as any); + const second = await store.getClient("id-A"); + assert.deepStrictEqual( + second!.redirect_uris, + ["https://new.com/cb"], + "Cache entry updated on re-registration" + ); + console.log(" ✓ Re-registration updates the cached entry"); + } finally { + stub.close(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Test suite: createGitLabOAuthProvider unit tests +// --------------------------------------------------------------------------- + +describe("MCP OAuth — createGitLabOAuthProvider", () => { + test("verifyAccessToken throws on non-OK response", async () => { + // Spin up a tiny local server that always returns 401 + const { createServer } = await import("node:http"); + const stub = createServer((req, res) => { + res.writeHead(401); + res.end(JSON.stringify({ error: "invalid_token" })); + }); + + await new Promise(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"); + + await assert.rejects( + () => provider.verifyAccessToken("bad-token"), + /invalid or expired/i, + "Should throw InvalidTokenError for non-OK response" + ); + console.log(" ✓ verifyAccessToken throws for 401 from GitLab"); + } finally { + stub.close(); + } + }); + + test("verifyAccessToken maps GitLab token info to AuthInfo", async () => { + const createdAt = Math.floor(Date.now() / 1000); + const { createServer } = await import("node:http"); + const stub = createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + resource_owner_id: 7, + scopes: ["api", "read_user"], + expires_in_seconds: 3600, + application: { uid: "app-uid-abc" }, + created_at: createdAt, + }) + ); + }); + + await new Promise(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"); + const authInfo = await provider.verifyAccessToken("good-token"); + + assert.strictEqual(authInfo.token, "good-token", "token must be preserved"); + assert.strictEqual(authInfo.clientId, "app-uid-abc", "clientId from application.uid"); + assert.deepStrictEqual(authInfo.scopes, ["api", "read_user"], "scopes forwarded"); + assert.ok(typeof authInfo.expiresAt === "number", "expiresAt must be a number"); + assert.ok( + authInfo.expiresAt! > Math.floor(Date.now() / 1000), + "expiresAt must be in the future" + ); + console.log(" ✓ verifyAccessToken returns correct AuthInfo"); + } finally { + stub.close(); + } + }); + + test("verifyAccessToken uses 'dynamic' clientId when application is null", async () => { + const { createServer } = await import("node:http"); + const stub = createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + resource_owner_id: 1, + scopes: ["read_api"], + expires_in_seconds: null, + application: null, + created_at: Math.floor(Date.now() / 1000), + }) + ); + }); + + await new Promise(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"); + const authInfo = await provider.verifyAccessToken("tok"); + + assert.strictEqual(authInfo.clientId, "dynamic", "clientId should fall back to 'dynamic'"); + assert.strictEqual(authInfo.expiresAt, undefined, "expiresAt should be undefined when null"); + console.log(" ✓ null application and null expires_in_seconds handled correctly"); + } finally { + stub.close(); + } + }); + + test("getClient returns stub for unknown clientId", async () => { + const { createGitLabOAuthProvider } = await import("../oauth-proxy.js"); + const provider = createGitLabOAuthProvider("https://gitlab.example.com", "test-app-id"); + + const client = await provider.clientsStore.getClient("unknown-client-id"); + + assert.ok(client, "Should return a client object"); + assert.strictEqual(client!.client_id, "unknown-client-id", "client_id should match input"); + assert.deepStrictEqual( + client!.redirect_uris, + [], + "redirect_uris should be empty for unknown client" + ); + assert.strictEqual(client!.token_endpoint_auth_method, "none", "Should be a public client"); + console.log(" ✓ getClient returns stub for unknown clientId"); + }); + + 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(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" + ); + console.log( + " ✓ DCR response cached: getClient returns real redirect_uris after registration" + ); + console.log(` ✓ client_name annotated: ${registered.client_name}`); + } finally { + stub.close(); + } + }); +}); diff --git a/test/utils/mock-gitlab-server.ts b/test/utils/mock-gitlab-server.ts index e8b89187..835943cf 100644 --- a/test/utils/mock-gitlab-server.ts +++ b/test/utils/mock-gitlab-server.ts @@ -3,8 +3,8 @@ * Implements minimal GitLab API endpoints for testing remote authorization */ -import express, { Request, Response, NextFunction, Handler } from 'express'; -import { Server } from 'http'; +import express, { Request, Response, NextFunction, Handler } from "express"; +import { Server } from "http"; export interface MockGitLabConfig { port: number; @@ -26,43 +26,73 @@ export class MockGitLabServer { private requestCount = 0; private customRouter: express.Router; private customHandlers = new Map(); + // Root-level dynamic router (for OAuth paths not under /api/v4) + private rootRouter: express.Router; + private rootHandlers = new Map(); constructor(config: MockGitLabConfig) { this.config = config; this.app = express(); this.customRouter = express.Router(); - - // Dynamic dispatcher for custom handlers + this.rootRouter = express.Router(); + + // Dynamic dispatcher for /api/v4 handlers this.customRouter.use((req, res, next) => { // Create a key from method and path (relative to /api/v4) // req.path is already relative to the mount point const key = `${req.method.toUpperCase()}:${req.path}`; console.log(`[CustomRouter] Checking key: '${key}'`); const handler = this.customHandlers.get(key); - + if (handler) { console.log(`[MockServer] Custom handler hit: ${key}`); return handler(req, res, next); } else { - console.log(`[CustomRouter] No handler found for key: '${key}'. Available keys: ${Array.from(this.customHandlers.keys()).join(', ')}`); + console.log( + `[CustomRouter] No handler found for key: '${key}'. Available keys: ${Array.from(this.customHandlers.keys()).join(", ")}` + ); + } + next(); + }); + + // Dynamic dispatcher for root-level handlers (OAuth endpoints, well-known, etc.) + this.rootRouter.use((req, res, next) => { + const key = `${req.method.toUpperCase()}:${req.path}`; + const handler = this.rootHandlers.get(key); + if (handler) { + console.log(`[MockServer] Root handler hit: ${key}`); + return handler(req, res, next); } next(); }); this.setupMiddleware(); - this.app.use('/api/v4', this.customRouter); // Mount router on API path + // Root router must be mounted BEFORE setupRoutes() installs the catch-all + this.app.use(this.rootRouter); + this.app.use("/api/v4", this.customRouter); // Mount router on API path this.setupRoutes(); } - public addMockHandler(method: 'get' | 'post' | 'put' | 'delete', path: string, handler: Handler) { + public addMockHandler(method: "get" | "post" | "put" | "delete", path: string, handler: Handler) { // Note: path should be relative to /api/v4 const key = `${method.toUpperCase()}:${path}`; console.log(`[MockServer] Adding custom handler: ${key}`); this.customHandlers.set(key, handler); } + /** + * Add a route at the instance root (not under /api/v4). + * Use this for OAuth endpoints (/oauth/*, /.well-known/*) that GitLab + * serves at the instance root rather than under the API prefix. + */ + public addRootHandler(method: "get" | "post" | "put" | "delete", path: string, handler: Handler) { + const key = `${method.toUpperCase()}:${path}`; + console.log(`[MockServer] Adding root handler: ${key}`); + this.rootHandlers.set(key, handler); + } + public clearCustomHandlers() { - console.log('[MockServer] Clearing custom handlers'); + console.log("[MockServer] Clearing custom handlers"); this.customHandlers.clear(); } @@ -91,8 +121,8 @@ export class MockGitLabServer { this.app.use((req: Request, res: Response, next: NextFunction) => { if (this.requestCount > this.config.rateLimitAfter!) { res.status(429).json({ - message: 'Rate limit exceeded', - retry_after: 60 + message: "Rate limit exceeded", + retry_after: 60, }); return; } @@ -101,9 +131,9 @@ export class MockGitLabServer { } // Authentication middleware - applies to all /api/v4/* routes - this.app.use('/api/v4', (req: AuthenticatedRequest, res: Response, next: NextFunction) => { - const authHeader = req.headers['authorization'] as string | undefined; - const privateToken = req.headers['private-token'] as string | undefined; + this.app.use("/api/v4", (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + const authHeader = req.headers["authorization"] as string | undefined; + const privateToken = req.headers["private-token"] as string | undefined; let token: string | null = null; @@ -117,16 +147,16 @@ export class MockGitLabServer { if (!token) { res.status(401).json({ - message: 'Unauthorized', - error: 'Missing authentication token' + message: "Unauthorized", + error: "Missing authentication token", }); return; } if (!this.config.validTokens.includes(token)) { res.status(401).json({ - message: 'Unauthorized', - error: 'Invalid authentication token' + message: "Unauthorized", + error: "Invalid authentication token", }); return; } @@ -139,306 +169,321 @@ export class MockGitLabServer { private setupRoutes() { // GET /api/v4/user - Get current user - this.app.get('/api/v4/user', (req: AuthenticatedRequest, res: Response) => { - const token = req.gitlabToken || 'unknown'; + this.app.get("/api/v4/user", (req: AuthenticatedRequest, res: Response) => { + const token = req.gitlabToken || "unknown"; res.json({ id: 1, username: `user_${token.substring(0, 8)}`, - name: 'Test User', - email: 'test@example.com', - state: 'active' + name: "Test User", + email: "test@example.com", + state: "active", }); }); // GET /api/v4/projects/:projectId - Get project - this.app.get('/api/v4/projects/:projectId', (req: AuthenticatedRequest, res: Response) => { + this.app.get("/api/v4/projects/:projectId", (req: AuthenticatedRequest, res: Response) => { const projectId = req.params.projectId; res.json({ id: parseInt(projectId) || 123, - name: 'Test Project', - path: 'test-project', - path_with_namespace: 'test-group/test-project', - description: 'A mock test project', - visibility: 'private', - created_at: '2024-01-01T00:00:00Z', + name: "Test Project", + path: "test-project", + path_with_namespace: "test-group/test-project", + description: "A mock test project", + visibility: "private", + created_at: "2024-01-01T00:00:00Z", web_url: `https://gitlab.mock/project/${projectId}`, namespace: { id: 1, - name: 'Test Group', - path: 'test-group', - kind: 'group', - full_path: 'test-group' - } + name: "Test Group", + path: "test-group", + kind: "group", + full_path: "test-group", + }, }); }); // GET /api/v4/merge_requests - List all merge requests (global) - this.app.get('/api/v4/merge_requests', (req: AuthenticatedRequest, res: Response) => { + this.app.get("/api/v4/merge_requests", (req: AuthenticatedRequest, res: Response) => { res.json([ { id: 1, iid: 1, project_id: 123, - title: 'Test MR 1', - description: 'Description for MR 1', - state: 'opened', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', + title: "Test MR 1", + description: "Description for MR 1", + state: "opened", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", merged_at: null, closed_at: null, - target_branch: 'main', - source_branch: 'feature-1', - web_url: 'https://gitlab.mock/project/123/merge_requests/1', + target_branch: "main", + source_branch: "feature-1", + web_url: "https://gitlab.mock/project/123/merge_requests/1", merge_commit_sha: null, author: { id: 1, - username: 'test-user', - name: 'Test User' - } + username: "test-user", + name: "Test User", + }, }, { id: 2, iid: 2, project_id: 123, - title: 'Test MR 2', - description: 'Description for MR 2', - state: 'merged', - created_at: '2024-01-02T00:00:00Z', - updated_at: '2024-01-03T00:00:00Z', - merged_at: '2024-01-03T00:00:00Z', + title: "Test MR 2", + description: "Description for MR 2", + state: "merged", + created_at: "2024-01-02T00:00:00Z", + updated_at: "2024-01-03T00:00:00Z", + merged_at: "2024-01-03T00:00:00Z", closed_at: null, - target_branch: 'main', - source_branch: 'feature-2', - web_url: 'https://gitlab.mock/project/123/merge_requests/2', - merge_commit_sha: 'abcdef1234567890', + target_branch: "main", + source_branch: "feature-2", + web_url: "https://gitlab.mock/project/123/merge_requests/2", + merge_commit_sha: "abcdef1234567890", author: { id: 1, - username: 'test-user', - name: 'Test User' - } - } + username: "test-user", + name: "Test User", + }, + }, ]); }); // GET /api/v4/projects/:projectId/merge_requests - List merge requests - this.app.get('/api/v4/projects/:projectId/merge_requests', (req: AuthenticatedRequest, res: Response) => { - res.json([ - { - id: 1, - iid: 1, - project_id: 123, - title: 'Test MR 1', - description: 'Description for MR 1', - state: 'opened', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', - merged_at: null, - closed_at: null, - target_branch: 'main', - source_branch: 'feature-1', - web_url: 'https://gitlab.mock/project/123/merge_requests/1', - merge_commit_sha: null, - author: { - id: 1, - username: 'test-user', - name: 'Test User' - } - }, - { - id: 2, - iid: 2, - project_id: 123, - title: 'Test MR 2', - description: 'Description for MR 2', - state: 'merged', - created_at: '2024-01-02T00:00:00Z', - updated_at: '2024-01-03T00:00:00Z', - merged_at: '2024-01-03T00:00:00Z', - closed_at: null, - target_branch: 'main', - source_branch: 'feature-2', - web_url: 'https://gitlab.mock/project/123/merge_requests/2', - merge_commit_sha: 'abcdef1234567890', - author: { + this.app.get( + "/api/v4/projects/:projectId/merge_requests", + (req: AuthenticatedRequest, res: Response) => { + res.json([ + { id: 1, - username: 'test-user', - name: 'Test User' - } - } - ]); - }); + iid: 1, + project_id: 123, + title: "Test MR 1", + description: "Description for MR 1", + state: "opened", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + merged_at: null, + closed_at: null, + target_branch: "main", + source_branch: "feature-1", + web_url: "https://gitlab.mock/project/123/merge_requests/1", + merge_commit_sha: null, + author: { + id: 1, + username: "test-user", + name: "Test User", + }, + }, + { + id: 2, + iid: 2, + project_id: 123, + title: "Test MR 2", + description: "Description for MR 2", + state: "merged", + created_at: "2024-01-02T00:00:00Z", + updated_at: "2024-01-03T00:00:00Z", + merged_at: "2024-01-03T00:00:00Z", + closed_at: null, + target_branch: "main", + source_branch: "feature-2", + web_url: "https://gitlab.mock/project/123/merge_requests/2", + merge_commit_sha: "abcdef1234567890", + author: { + id: 1, + username: "test-user", + name: "Test User", + }, + }, + ]); + } + ); // GET /api/v4/projects/:projectId/merge_requests/:mr_iid - Get single MR - this.app.get('/api/v4/projects/:projectId/merge_requests/:mr_iid', (req: AuthenticatedRequest, res: Response) => { - const mrIid = parseInt(req.params.mr_iid); - res.json({ - id: mrIid, - iid: mrIid, - title: `Test MR ${mrIid}`, - state: 'opened', - created_at: '2024-01-01T00:00:00Z', - author: { - id: 1, - username: 'test-user', - name: 'Test User' - }, - source_branch: 'feature-branch', - target_branch: 'main', - merge_status: 'can_be_merged' - }); - }); + this.app.get( + "/api/v4/projects/:projectId/merge_requests/:mr_iid", + (req: AuthenticatedRequest, res: Response) => { + const mrIid = parseInt(req.params.mr_iid); + res.json({ + id: mrIid, + iid: mrIid, + title: `Test MR ${mrIid}`, + state: "opened", + created_at: "2024-01-01T00:00:00Z", + author: { + id: 1, + username: "test-user", + name: "Test User", + }, + source_branch: "feature-branch", + target_branch: "main", + merge_status: "can_be_merged", + }); + } + ); // GET /api/v4/projects/:projectId/issues - List issues - this.app.get('/api/v4/projects/:projectId/issues', (req: AuthenticatedRequest, res: Response) => { - const projectId = req.params.projectId; - res.json([ - { - id: 1, - iid: 1, + this.app.get( + "/api/v4/projects/:projectId/issues", + (req: AuthenticatedRequest, res: Response) => { + const projectId = req.params.projectId; + res.json([ + { + id: 1, + iid: 1, + project_id: projectId, + title: "Test Issue 1", + description: "Test issue description", + state: "opened", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", + closed_at: null, + web_url: `https://gitlab.mock/project/${projectId}/issues/1`, + author: { + id: 1, + username: "test-user", + name: "Test User", + avatar_url: null, + web_url: "https://gitlab.mock/test-user", + }, + assignees: [], + labels: [], + milestone: null, + }, + ]); + } + ); + + // GET /api/v4/projects/:projectId/issues/:issue_iid - Get single issue + this.app.get( + "/api/v4/projects/:projectId/issues/:issue_iid", + (req: AuthenticatedRequest, res: Response) => { + const issueIid = parseInt(req.params.issue_iid); + const projectId = req.params.projectId; + res.json({ + id: issueIid, + iid: issueIid, project_id: projectId, - title: 'Test Issue 1', - description: 'Test issue description', - state: 'opened', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-02T00:00:00Z', + title: `Test Issue ${issueIid}`, + description: `Description for issue ${issueIid}`, + state: "opened", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-02T00:00:00Z", closed_at: null, - web_url: `https://gitlab.mock/project/${projectId}/issues/1`, + web_url: `https://gitlab.mock/project/${projectId}/issues/${issueIid}`, author: { id: 1, - username: 'test-user', - name: 'Test User', + username: "test-user", + name: "Test User", avatar_url: null, - web_url: 'https://gitlab.mock/test-user' + web_url: "https://gitlab.mock/test-user", }, assignees: [], labels: [], - milestone: null - } - ]); - }); - - // GET /api/v4/projects/:projectId/issues/:issue_iid - Get single issue - this.app.get('/api/v4/projects/:projectId/issues/:issue_iid', (req: AuthenticatedRequest, res: Response) => { - const issueIid = parseInt(req.params.issue_iid); - const projectId = req.params.projectId; - res.json({ - id: issueIid, - iid: issueIid, - project_id: projectId, - title: `Test Issue ${issueIid}`, - description: `Description for issue ${issueIid}`, - state: 'opened', - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-02T00:00:00Z', - closed_at: null, - web_url: `https://gitlab.mock/project/${projectId}/issues/${issueIid}`, - author: { - id: 1, - username: 'test-user', - name: 'Test User', - avatar_url: null, - web_url: 'https://gitlab.mock/test-user' - }, - assignees: [], - labels: [], - milestone: null - }); - }); + milestone: null, + }); + } + ); // GET /api/v4/projects - List projects - this.app.get('/api/v4/projects', (req: AuthenticatedRequest, res: Response) => { + this.app.get("/api/v4/projects", (req: AuthenticatedRequest, res: Response) => { res.json([ { id: 123, - name: 'Test Project', - path: 'test-project', - path_with_namespace: 'test-group/test-project', - description: 'A mock test project', - visibility: 'private', + name: "Test Project", + path: "test-project", + path_with_namespace: "test-group/test-project", + description: "A mock test project", + visibility: "private", namespace: { id: 1, - name: 'Test Group', - path: 'test-group', - kind: 'group', - full_path: 'test-group' - } - } + name: "Test Group", + path: "test-group", + kind: "group", + full_path: "test-group", + }, + }, ]); }); // GET /api/v4/projects/:projectId/merge_requests/:mr_iid/changes - Get MR diffs - this.app.get('/api/v4/projects/:projectId/merge_requests/:mr_iid/changes', (req: AuthenticatedRequest, res: Response) => { - const mrIid = parseInt(req.params.mr_iid); - res.json({ - id: mrIid, - iid: mrIid, - project_id: parseInt(req.params.projectId), - title: `Test MR ${mrIid}`, - state: 'opened', - created_at: '2024-01-01T00:00:00Z', - changes: [ - { - old_path: 'src/index.ts', - new_path: 'src/index.ts', - a_mode: '100644', - b_mode: '100644', - diff: '@@ -1,1 +1,2 @@\n-line 1\n+line 1 modified\n+new line 2\n', - new_file: false, - renamed_file: false, - deleted_file: false - }, - { - old_path: 'vendor/package/file.js', - new_path: 'vendor/package/file.js', - a_mode: '100644', - b_mode: '100644', - diff: '@@ -1,1 +1,1 @@\n-vendor content old\n+vendor content new\n', - new_file: false, - renamed_file: false, - deleted_file: false - }, - { - old_path: 'README.md', - new_path: 'README.md', - a_mode: '100644', - b_mode: '100644', - diff: '@@ -1,1 +1,1 @@\n-old readme\n+new readme\n', - new_file: false, - renamed_file: false, - deleted_file: false - }, - { - old_path: 'package-lock.json', - new_path: 'package-lock.json', - a_mode: '100644', - b_mode: '100644', - diff: '{\n- "version": "1.0.0"\n+ "version": "1.0.1"\n}\n', - new_file: false, - renamed_file: false, - deleted_file: false - } - ] - }); - }); + this.app.get( + "/api/v4/projects/:projectId/merge_requests/:mr_iid/changes", + (req: AuthenticatedRequest, res: Response) => { + const mrIid = parseInt(req.params.mr_iid); + res.json({ + id: mrIid, + iid: mrIid, + project_id: parseInt(req.params.projectId), + title: `Test MR ${mrIid}`, + state: "opened", + created_at: "2024-01-01T00:00:00Z", + changes: [ + { + old_path: "src/index.ts", + new_path: "src/index.ts", + a_mode: "100644", + b_mode: "100644", + diff: "@@ -1,1 +1,2 @@\n-line 1\n+line 1 modified\n+new line 2\n", + new_file: false, + renamed_file: false, + deleted_file: false, + }, + { + old_path: "vendor/package/file.js", + new_path: "vendor/package/file.js", + a_mode: "100644", + b_mode: "100644", + diff: "@@ -1,1 +1,1 @@\n-vendor content old\n+vendor content new\n", + new_file: false, + renamed_file: false, + deleted_file: false, + }, + { + old_path: "README.md", + new_path: "README.md", + a_mode: "100644", + b_mode: "100644", + diff: "@@ -1,1 +1,1 @@\n-old readme\n+new readme\n", + new_file: false, + renamed_file: false, + deleted_file: false, + }, + { + old_path: "package-lock.json", + new_path: "package-lock.json", + a_mode: "100644", + b_mode: "100644", + diff: '{\n- "version": "1.0.0"\n+ "version": "1.0.1"\n}\n', + new_file: false, + renamed_file: false, + deleted_file: false, + }, + ], + }); + } + ); // Health check endpoint - this.app.get('/health', (req: Request, res: Response) => { - res.json({ status: 'ok', message: 'Mock GitLab API is running' }); + this.app.get("/health", (req: Request, res: Response) => { + res.json({ status: "ok", message: "Mock GitLab API is running" }); }); // Catch-all for unimplemented endpoints this.app.use((req: Request, res: Response) => { console.log(`Mock GitLab: Unimplemented endpoint: ${req.method} ${req.path}`); res.status(404).json({ - message: '404 Not Found', - error: 'Endpoint not implemented in mock server' + message: "404 Not Found", + error: "Endpoint not implemented in mock server", }); }); } async start(): Promise { - return new Promise((resolve) => { - this.server = this.app.listen(this.config.port, '127.0.0.1', () => { + return new Promise(resolve => { + this.server = this.app.listen(this.config.port, "127.0.0.1", () => { console.log(`Mock GitLab API listening on http://127.0.0.1:${this.config.port}`); resolve(); }); @@ -448,10 +493,10 @@ export class MockGitLabServer { async stop(): Promise { return new Promise((resolve, reject) => { if (this.server) { - this.server.close((err) => { + this.server.close(err => { if (err) reject(err); else { - console.log('Mock GitLab API stopped'); + console.log("Mock GitLab API stopped"); resolve(); } }); @@ -470,21 +515,23 @@ export class MockGitLabServer { * Helper to find available port for mock server */ export async function findMockServerPort( - basePort: number = 9000, + basePort: number = 9000, maxAttempts: number = 10 ): Promise { - const net = await import('net'); - + const net = await import("net"); + const tryPort = async (port: number, attemptsLeft: number): Promise => { if (attemptsLeft === 0) { - throw new Error(`Could not find available port after ${maxAttempts} attempts starting from ${basePort}`); + throw new Error( + `Could not find available port after ${maxAttempts} attempts starting from ${basePort}` + ); } return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); - - server.on('error', async () => { + + server.on("error", async () => { try { const nextPort = await tryPort(port + 1, attemptsLeft - 1); resolve(nextPort); @@ -492,10 +539,10 @@ export async function findMockServerPort( reject(err); } }); - - server.listen(port, '127.0.0.1', () => { + + server.listen(port, "127.0.0.1", () => { const addr = server.address(); - const actualPort = typeof addr === 'object' && addr ? addr.port : port; + const actualPort = typeof addr === "object" && addr ? addr.port : port; server.close(() => { resolve(actualPort); }); @@ -512,4 +559,3 @@ export async function findMockServerPort( export function resetMockServerState(server: MockGitLabServer) { (server as any).requestCount = 0; } - diff --git a/test/utils/server-launcher.ts b/test/utils/server-launcher.ts index 8d32e904..57228c1d 100644 --- a/test/utils/server-launcher.ts +++ b/test/utils/server-launcher.ts @@ -3,15 +3,15 @@ * Manages server processes and provides clean shutdown */ -import { spawn, ChildProcess } from 'child_process'; -import * as path from 'path'; +import { spawn, ChildProcess } from "child_process"; +import * as path from "path"; -export const HOST = process.env.HOST || '127.0.0.1'; +export const HOST = process.env.HOST || "127.0.0.1"; export enum TransportMode { - STDIO = 'stdio', - SSE = 'sse', - STREAMABLE_HTTP = 'streamable-http' + STDIO = "stdio", + SSE = "sse", + STREAMABLE_HTTP = "streamable-http", } export interface ServerConfig { @@ -33,28 +33,29 @@ export interface ServerInstance { */ export async function launchServer(config: ServerConfig): Promise { console.log("Launcher: launchServer function entered."); - const { - mode, - port = 3002, - env = {}, - timeout = 3000 - } = config; + const { mode, port = 3002, env = {}, timeout = 3000 } = config; // Prepare environment variables based on transport mode // Use same configuration pattern as existing validate-api.js const GITLAB_API_URL = process.env.GITLAB_API_URL || "https://gitlab.com"; const GITLAB_TOKEN = process.env.GITLAB_TOKEN_TEST || process.env.GITLAB_TOKEN; const TEST_PROJECT_ID = process.env.TEST_PROJECT_ID; - - // Check if remote authorization is enabled - const isRemoteAuth = env.REMOTE_AUTHORIZATION === 'true'; - - // Validate that we have required configuration (unless using remote auth) - if (!GITLAB_TOKEN && !isRemoteAuth) { - throw new Error('GITLAB_TOKEN_TEST or GITLAB_TOKEN environment variable is required for server testing'); + + // Check which auth modes are active + const isRemoteAuth = env.REMOTE_AUTHORIZATION === "true"; + const isMcpOAuth = env.GITLAB_MCP_OAUTH === "true"; + // Both REMOTE_AUTHORIZATION and GITLAB_MCP_OAUTH manage tokens at the HTTP layer; + // neither requires a pre-configured GITLAB_TOKEN on the server process. + const isServerManagedAuth = isRemoteAuth || isMcpOAuth; + + // Validate that we have required configuration (unless using a server-managed auth mode) + if (!GITLAB_TOKEN && !isServerManagedAuth) { + throw new Error( + "GITLAB_TOKEN_TEST or GITLAB_TOKEN environment variable is required for server testing" + ); } - if (!TEST_PROJECT_ID && !isRemoteAuth && env.ENABLE_DYNAMIC_API_URL !== 'true') { - throw new Error('TEST_PROJECT_ID environment variable is required for server testing'); + if (!TEST_PROJECT_ID && !isServerManagedAuth && env.ENABLE_DYNAMIC_API_URL !== "true") { + throw new Error("TEST_PROJECT_ID environment variable is required for server testing"); } const serverEnv: Record = { @@ -62,35 +63,37 @@ export async function launchServer(config: ServerConfig): Promise; - // Only set GITLAB_PERSONAL_ACCESS_TOKEN if not using remote auth - if (!isRemoteAuth && GITLAB_TOKEN) { + // Only set GITLAB_PERSONAL_ACCESS_TOKEN if not using a server-managed auth mode + if (!isServerManagedAuth && GITLAB_TOKEN) { serverEnv.GITLAB_PERSONAL_ACCESS_TOKEN = GITLAB_TOKEN; } // Set transport-specific environment variables switch (mode) { case TransportMode.SSE: - serverEnv.SSE = 'true'; + serverEnv.SSE = "true"; serverEnv.PORT = port.toString(); break; case TransportMode.STREAMABLE_HTTP: - serverEnv.STREAMABLE_HTTP = 'true'; + serverEnv.STREAMABLE_HTTP = "true"; serverEnv.PORT = port.toString(); break; case TransportMode.STDIO: // Stdio mode doesn't need port configuration - uses process communication - throw new Error(`${TransportMode.STDIO} mode is not supported for server testing, because it uses process communication.`); + throw new Error( + `${TransportMode.STDIO} mode is not supported for server testing, because it uses process communication.` + ); } - const serverPath = path.resolve(process.cwd(), 'build/index.js'); - + const serverPath = path.resolve(process.cwd(), "build/index.js"); + console.log("Launcher: Spawning server process with env:", serverEnv); console.log("Launcher: Spawning server process with env:", serverEnv); - const serverProcess = spawn('node', [serverPath], { + const serverProcess = spawn("node", [serverPath], { env: serverEnv, - stdio: ['pipe', 'pipe', 'pipe'], + stdio: ["pipe", "pipe", "pipe"], shell: false, - detached: false + detached: false, }); console.log(`Launcher: Server process spawned with PID: ${serverProcess.pid}`); console.log("Launcher: Server process spawned."); @@ -104,16 +107,16 @@ export async function launchServer(config: ServerConfig): Promise { if (!serverProcess.killed) { - serverProcess.kill('SIGTERM'); - + serverProcess.kill("SIGTERM"); + // Force kill if not terminated within 5 seconds setTimeout(() => { if (!serverProcess.killed) { - serverProcess.kill('SIGKILL'); + serverProcess.kill("SIGKILL"); } }, 5000); } - } + }, }; return instance; @@ -133,7 +136,7 @@ async function waitForServerStart( reject(new Error(`Server failed to start within ${timeout}ms for mode ${mode}`)); }, timeout); - let outputBuffer = ''; + let outputBuffer = ""; const onData = (data: Buffer) => { try { @@ -145,25 +148,23 @@ async function waitForServerStart( reject(e); return; } - + // Check for server start messages const startMessages = [ - 'Starting GitLab MCP Server with stdio transport', - 'Starting GitLab MCP Server with SSE transport', - 'Starting GitLab MCP Server with Streamable HTTP transport', - 'GitLab MCP Server running', - `port ${port}` + "Starting GitLab MCP Server with stdio transport", + "Starting GitLab MCP Server with SSE transport", + "Starting GitLab MCP Server with Streamable HTTP transport", + "GitLab MCP Server running", + `port ${port}`, ]; - const hasStartMessage = startMessages.some(msg => - outputBuffer.includes(msg) - ); + const hasStartMessage = startMessages.some(msg => outputBuffer.includes(msg)); if (hasStartMessage) { clearTimeout(timer); // process.stdout?.removeListener('data', onData); // process.stderr?.removeListener('data', onData); - + // Additional wait for HTTP servers to be fully ready if (mode !== TransportMode.STDIO) { setTimeout(resolve, 1000); @@ -189,11 +190,11 @@ async function waitForServerStart( reject(new Error(`Server process exited with code ${code} before starting`)); }; - process.stdout?.on('data', onData); - process.on('close', onClose); - process.stderr?.on('data', onData); - process.on('error', onError); - process.on('exit', onExit); + process.stdout?.on("data", onData); + process.on("close", onClose); + process.stderr?.on("data", onData); + process.on("error", onError); + process.on("exit", onExit); }); } @@ -201,20 +202,20 @@ async function waitForServerStart( * Find an available port starting from a base port */ export async function findAvailablePort(basePort: number = 3002): Promise { - const net = await import('net'); - + const net = await import("net"); + return new Promise((resolve, reject) => { const server = net.createServer(); - + server.listen(basePort, () => { const address = server.address(); - const port = typeof address === 'object' && address ? address.port : basePort; - + const port = typeof address === "object" && address ? address.port : basePort; + server.close(() => resolve(port)); }); - - server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { + + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { // Port is in use, try next one resolve(findAvailablePort(basePort + 1)); } else { @@ -235,8 +236,7 @@ export function cleanupServers(servers: ServerInstance[]): void { console.warn(`Failed to kill server process: ${error}`); } }); -} - +} /** * Health check response interface @@ -260,36 +260,39 @@ export function createTimeoutController(timeout: number): AbortController { /** * Check if a health endpoint is responding */ -export async function checkHealthEndpoint(port: number, maxRetries: number = 5): Promise { +export async function checkHealthEndpoint( + port: number, + maxRetries: number = 5 +): Promise { let lastError: Error; - + for (let i = 0; i < maxRetries; i++) { try { const controller = createTimeoutController(5000); const response = await fetch(`http://${HOST}:${port}/health`, { - method: 'GET', - signal: controller.signal + method: "GET", + signal: controller.signal, }); - + if (response.ok) { - const healthData = await response.json() as HealthCheckResponse; + const healthData = (await response.json()) as HealthCheckResponse; return healthData; } else { throw new Error(`Health check failed with status ${response.status}`); } } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - lastError = new Error('Request timeout after 5000ms'); + if (error instanceof Error && error.name === "AbortError") { + lastError = new Error("Request timeout after 5000ms"); } else { lastError = error instanceof Error ? error : new Error(String(error)); } - + if (i < maxRetries - 1) { // Wait before retry await new Promise(resolve => setTimeout(resolve, 1000)); } } } - + throw lastError!; -} \ No newline at end of file +}