Skip to content
Merged
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
35 changes: 31 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { CookieJar, parse as parseCookie } from "tough-cookie";
import { fileURLToPath, URL } from "node:url";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { initializeOAuth } from "./oauth.js";
import { initializeOAuthClient, GitLabOAuth } from "./oauth.js";
import { GitLabClientPool } from "./gitlab-client-pool.js";
// Add type imports for proxy agents
import { Agent } from "node:http";
Expand Down Expand Up @@ -463,6 +463,28 @@ function validateConfiguration(): void {

const GITLAB_PERSONAL_ACCESS_TOKEN = getConfig("token", "GITLAB_PERSONAL_ACCESS_TOKEN");
let OAUTH_ACCESS_TOKEN: string | null = null;
let oauthClient: GitLabOAuth | null = null;
/**
* Ensure the OAuth token is valid before making an API call.
* Refreshes the token lazily (only when a tool is actually called).
* This avoids background timers that cause issues with multiple instances.
*/
async function ensureValidOAuthToken(): Promise<void> {
if (!oauthClient) return;

if (oauthClient.hasValidToken()) return;

try {
logger.info("OAuth token expired or missing, refreshing...");
const freshToken = await oauthClient.getAccessToken();
OAUTH_ACCESS_TOKEN = freshToken;
logger.info("OAuth token refreshed successfully");
} catch (error) {
logger.error("Failed to refresh OAuth token:", error);
throw error;
}
}

const GITLAB_AUTH_COOKIE_PATH = getConfig("cookie-path", "GITLAB_AUTH_COOKIE_PATH");
const USE_OAUTH = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
const IS_OLD = getConfig("is-old", "GITLAB_IS_OLD") === "true";
Expand Down Expand Up @@ -720,7 +742,7 @@ const BASE_HEADERS: Record<string, string> = {
/**
* Build authentication headers dynamically based on context
* In REMOTE_AUTHORIZATION mode, reads from AsyncLocalStorage session context
* Otherwise, uses environment token
* Otherwise, uses environment token (OAuth token is refreshed lazily before each tool call)
*/
function buildAuthHeaders(): Record<string, string> {
if (REMOTE_AUTHORIZATION) {
Expand Down Expand Up @@ -5527,6 +5549,10 @@ async function handleToolCall(params: any) {
if (GITLAB_AUTH_COOKIE_PATH) {
await ensureSessionForRequest();
}

// Lazy OAuth token refresh: only validate/refresh when a tool is actually called
await ensureValidOAuthToken();

logger.info(params.name);
switch (params.name) {
case "execute_graphql": {
Expand Down Expand Up @@ -7458,9 +7484,10 @@ async function runServer() {
logger.info("Using OAuth authentication...");
try {
const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4$/, "");
OAUTH_ACCESS_TOKEN = await initializeOAuth(gitlabBaseUrl);
const oauthResult = await initializeOAuthClient(gitlabBaseUrl);
oauthClient = oauthResult.client;
OAUTH_ACCESS_TOKEN = oauthResult.accessToken;
logger.info("OAuth authentication successful");
// Note: Headers are automatically generated by buildAuthHeaders() using OAUTH_ACCESS_TOKEN
} catch (error) {
logger.error("OAuth authentication failed:", error);
process.exit(1);
Expand Down
38 changes: 34 additions & 4 deletions oauth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as http from "http";
import * as net from "net";
Expand Down Expand Up @@ -118,7 +119,7 @@ export class GitLabOAuth {
constructor(config: OAuthConfig) {
this.config = config;
this.tokenStoragePath =
config.tokenStoragePath || path.join(process.env.HOME || "", ".gitlab-mcp-token.json");
config.tokenStoragePath || path.join(os.homedir(), ".gitlab-mcp-token.json");
}

/**
Expand Down Expand Up @@ -287,6 +288,22 @@ export class GitLabOAuth {
return Date.now() >= expiryTime - 5 * 60 * 1000;
}

/**
* Get the number of milliseconds until the token expires.
* Returns null if no token or no expiry info.
* Returns 0 if already expired.
*/
getTokenExpiresInMs(): number | null {
const tokenData = this.loadToken();
if (!tokenData || !tokenData.expires_in) {
return null;
}

const expiryTime = tokenData.created_at + tokenData.expires_in * 1000;
const remaining = expiryTime - Date.now();
Copy link

Copilot AI Feb 18, 2026

Choose a reason for hiding this comment

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

New public API getTokenExpiresInMs() isn’t covered by the existing OAuth tests. Adding unit tests for the null cases (no token / no expires_in) and for correct remaining-time calculation would help prevent regressions (especially around the 5-minute buffer behavior used by the server).

Suggested change
const remaining = expiryTime - Date.now();
// Apply the same 5 minute buffer used in isTokenExpired()
const bufferedExpiryTime = expiryTime - 5 * 60 * 1000;
const remaining = bufferedExpiryTime - Date.now();

Copilot uses AI. Check for mistakes.
return Math.max(0, remaining);
}

/**
* Start OAuth flow and wait for callback
* Uses a shared server if port is already in use
Expand Down Expand Up @@ -609,9 +626,11 @@ export class GitLabOAuth {
}

/**
* Initialize OAuth authentication for GitLab MCP server
* Create and initialize a GitLabOAuth client.
* Performs initial authentication (triggers browser flow if needed).
* Returns the client instance and the initial access token.
*/
export async function initializeOAuth(gitlabUrl: string = "https://gitlab.com"): Promise<string> {
export async function initializeOAuthClient(gitlabUrl: string = "https://gitlab.com"): Promise<{ client: GitLabOAuth; accessToken: string }> {
const clientId = process.env.GITLAB_OAUTH_CLIENT_ID;
const clientSecret = process.env.GITLAB_OAUTH_CLIENT_SECRET;
const redirectUri = process.env.GITLAB_OAUTH_REDIRECT_URI || "http://127.0.0.1:8888/callback";
Expand All @@ -632,5 +651,16 @@ export async function initializeOAuth(gitlabUrl: string = "https://gitlab.com"):
tokenStoragePath,
});

return await oauth.getAccessToken();
// Single call: triggers browser flow if needed, or reads cached token
const accessToken = await oauth.getAccessToken();

return { client: oauth, accessToken };
}

/**
* Initialize OAuth authentication for GitLab MCP server
*/
export async function initializeOAuth(gitlabUrl: string = "https://gitlab.com"): Promise<string> {
const { accessToken } = await initializeOAuthClient(gitlabUrl);
return accessToken;
}
Loading