All files / src/services TokenScopeDetector.ts

100% Statements 91/91
91.3% Branches 42/46
100% Functions 12/12
100% Lines 88/88

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386                    22x 22x 22x 22x                             22x                               22x             22x         12x                                                               22x                                                                                                                                                                                           22x 15x 1x     14x 14x               13x   4x 1x 1x   3x 1x 1x   2x   1x 1x   1x 1x     9x 9x 9x 2x     2x   7x   7x 7x 7x       7x 7x 4x 4x 4x 4x   4x 4x 4x 4x 4x           7x   7x                       1x     1x             22x 371x     371x 2x       467x           22x 8x 344x                 22x 4x 172x             22x       21x 21x   20x 20x 20x 20x 20x     1x 1x       1x             22x 5x 5x     5x 3x 1x       2x 1x         1x               5x   4x             1x                   1x 1x     1x 1x      
/**
 * Token Scope Detection Service
 *
 * Detects token scopes at startup via GET /api/v4/personal_access_tokens/self
 * and determines which capabilities are available. This allows the server to:
 * - Skip GraphQL introspection when scopes are insufficient
 * - Register only tools that will work with the current token
 * - Show clean, actionable messages instead of error stack traces
 */
 
import { z } from "zod";
import { logInfo, logWarn, logDebug } from "../logger";
import { GITLAB_BASE_URL, GITLAB_TOKEN } from "../config";
import { enhancedFetch } from "../utils/fetch";
 
/**
 * GitLab token types that can be detected
 */
export type GitLabTokenType =
  | "personal_access_token"
  | "project_access_token"
  | "group_access_token"
  | "oauth"
  | "unknown";
 
/**
 * Known GitLab token scopes
 */
const GITLAB_SCOPES = [
  "api",
  "read_api",
  "read_user",
  "read_repository",
  "write_repository",
  "read_registry",
  "write_registry",
  "sudo",
  "admin_mode",
  "create_runner",
  "manage_runner",
  "ai_features",
  "k8s_proxy",
] as const;
 
const GitLabScopeSchema = z.enum(GITLAB_SCOPES);
export type GitLabScope = z.infer<typeof GitLabScopeSchema>;
 
/**
 * Zod schema for the /api/v4/personal_access_tokens/self response.
 * Validates shape and types; filters scopes to known values only.
 */
const TokenSelfResponseSchema = z.object({
  id: z.number(),
  name: z.string(),
  scopes: z
    .array(z.string())
    .transform(arr => arr.filter((s): s is GitLabScope => GitLabScopeSchema.safeParse(s).success)),
  expires_at: z.string().nullable(),
  active: z.boolean(),
  revoked: z.boolean(),
});
 
/**
 * Result of token scope detection
 */
export interface TokenScopeInfo {
  /** Token name (e.g. "gitlab-mcp") */
  name: string;
  /** Detected scopes */
  scopes: GitLabScope[];
  /** Token expiration date (null if never expires) */
  expiresAt: string | null;
  /** Whether the token is currently active */
  active: boolean;
  /** Token type (PAT, project, group, etc.) */
  tokenType: GitLabTokenType;
  /** Whether GraphQL API access is available (requires api or read_api) */
  hasGraphQLAccess: boolean;
  /** Whether the token has full write access (api scope) */
  hasWriteAccess: boolean;
  /** Number of days until token expires (null if no expiry) */
  daysUntilExpiry: number | null;
}
 
/**
 * Scope requirements for each tool.
 * A tool is available if the token has ANY of the listed scopes.
 */
const TOOL_SCOPE_REQUIREMENTS: Record<string, GitLabScope[]> = {
  // Core tools - require api or read_api for most, read_user for user-related
  browse_projects: ["api", "read_api"],
  browse_namespaces: ["api", "read_api"],
  browse_commits: ["api", "read_api"],
  browse_events: ["api", "read_api", "read_user"],
  browse_users: ["api", "read_api", "read_user"],
  browse_todos: ["api", "read_api"],
  manage_project: ["api"],
  manage_namespace: ["api"],
  manage_todos: ["api"],
  // manage_context is intentionally excluded — it manages local session state
  // and never calls GitLab API, so it's available with any token scope.
 
  // Labels
  browse_labels: ["api", "read_api"],
  manage_label: ["api"],
 
  // Merge requests
  browse_merge_requests: ["api", "read_api"],
  browse_mr_discussions: ["api", "read_api"],
  manage_merge_request: ["api"],
  manage_mr_discussion: ["api"],
  manage_draft_notes: ["api"],
 
  // Files - also works with repository scopes
  browse_files: ["api", "read_api", "read_repository"],
  manage_files: ["api", "write_repository"],
 
  // Milestones
  browse_milestones: ["api", "read_api"],
  manage_milestone: ["api"],
 
  // Pipelines
  browse_pipelines: ["api", "read_api"],
  manage_pipeline: ["api"],
  manage_pipeline_job: ["api"],
 
  // Variables
  browse_variables: ["api", "read_api"],
  manage_variable: ["api"],
 
  // Wiki
  browse_wiki: ["api", "read_api"],
  manage_wiki: ["api"],
 
  // Work items
  browse_work_items: ["api", "read_api"],
  manage_work_item: ["api"],
 
  // Snippets
  browse_snippets: ["api", "read_api"],
  manage_snippet: ["api"],
 
  // Webhooks
  browse_webhooks: ["api", "read_api"],
  manage_webhook: ["api"],
 
  // Integrations
  browse_integrations: ["api", "read_api"],
  manage_integration: ["api"],
 
  // Releases
  browse_releases: ["api", "read_api"],
  manage_release: ["api"],
 
  // Refs (branches, tags)
  browse_refs: ["api", "read_api"],
  manage_ref: ["api"],
 
  // Members
  browse_members: ["api", "read_api"],
  manage_member: ["api"],
 
  // Search
  browse_search: ["api", "read_api"],
 
  // Iterations
  browse_iterations: ["api", "read_api"],
};
 
/**
 * Detect token scopes by calling GET /api/v4/personal_access_tokens/self
 *
 * This endpoint works with:
 * - Personal access tokens (PAT) - returns full token info
 * - Project/Group access tokens - returns full token info
 *
 * Does NOT work with:
 * - OAuth tokens - use OAuth introspection instead
 * - Job tokens - have different scope model
 *
 * @returns TokenScopeInfo or null if detection fails
 */
export async function detectTokenScopes(): Promise<TokenScopeInfo | null> {
  if (!GITLAB_BASE_URL || !GITLAB_TOKEN) {
    return null;
  }
 
  try {
    const response = await enhancedFetch(`${GITLAB_BASE_URL}/api/v4/personal_access_tokens/self`, {
      headers: {
        "PRIVATE-TOKEN": GITLAB_TOKEN,
        Accept: "application/json",
      },
      retry: false, // Don't retry scope detection - it runs at startup
    });
 
    if (!response.ok) {
      // 401 = invalid token, 403 = insufficient permissions, 404 = endpoint not available
      if (response.status === 404) {
        logDebug("Token self-introspection endpoint not available (older GitLab version)");
        return null;
      }
      if (response.status === 401) {
        logInfo("Token is invalid or expired");
        return null;
      }
      if (response.status === 403) {
        // Some token types (e.g. deploy tokens) can't self-introspect
        logDebug("Token self-introspection not permitted for this token type");
        return null;
      }
      logDebug("Unexpected response from token self-introspection", { status: response.status });
      return null;
    }
 
    const raw: unknown = await response.json();
    const parsed = TokenSelfResponseSchema.safeParse(raw);
    if (!parsed.success) {
      logDebug("Token self-introspection response validation failed", {
        error: parsed.error.message,
      });
      return null;
    }
    const data = parsed.data;
 
    const scopes = data.scopes;
    const hasGraphQLAccess = scopes.some(s => s === "api" || s === "read_api");
    const hasWriteAccess = scopes.includes("api");
 
    // Calculate days until expiry using UTC dates to avoid timezone off-by-one errors.
    // expires_at is a date-only string (YYYY-MM-DD) — parse as UTC midnight.
    let daysUntilExpiry: number | null = null;
    if (data.expires_at) {
      const [yearStr, monthStr, dayStr] = data.expires_at.split("-");
      const year = Number(yearStr);
      const month = Number(monthStr);
      const day = Number(dayStr);
 
      Eif (!Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) {
        const expiryUtcMs = Date.UTC(year, month - 1, day);
        const now = new Date();
        const todayUtcMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
        daysUntilExpiry = Math.ceil((expiryUtcMs - todayUtcMs) / (1000 * 60 * 60 * 24));
      }
    }
 
    // /personal_access_tokens/self works for PAT, project, and group tokens,
    // but the type cannot be reliably inferred from user-controlled fields.
    const tokenType: GitLabTokenType = "unknown";
 
    return {
      name: data.name,
      scopes,
      expiresAt: data.expires_at,
      active: data.active && !data.revoked,
      tokenType,
      hasGraphQLAccess,
      hasWriteAccess,
      daysUntilExpiry,
    };
  } catch (error) {
    // Network errors, DNS failures, etc. - don't block startup
    logDebug("Token scope detection failed (network error)", {
      error: error instanceof Error ? error.message : String(error),
    });
    return null;
  }
}
 
/**
 * Check if a tool is available given the detected token scopes
 */
export function isToolAvailableForScopes(toolName: string, scopes: GitLabScope[]): boolean {
  const requiredScopes = TOOL_SCOPE_REQUIREMENTS[toolName];
 
  // Tool not in scope map - allow it (might be a new tool without mapping)
  if (!requiredScopes) {
    return true;
  }
 
  // Tool is available if the token has ANY of the required scopes
  return requiredScopes.some(required => scopes.includes(required));
}
 
/**
 * Get the list of tools available for given scopes
 */
export function getToolsForScopes(scopes: GitLabScope[]): string[] {
  return Object.keys(TOOL_SCOPE_REQUIREMENTS).filter(toolName =>
    isToolAvailableForScopes(toolName, scopes)
  );
}
 
/**
 * Get all known tool scope requirements.
 * Returns a deep clone so callers can safely mutate the returned
 * arrays without affecting the internal TOOL_SCOPE_REQUIREMENTS map.
 */
export function getToolScopeRequirements(): Record<string, GitLabScope[]> {
  return Object.fromEntries(
    Object.entries(TOOL_SCOPE_REQUIREMENTS).map(([toolName, scopes]) => [toolName, [...scopes]])
  );
}
 
/**
 * Generate an actionable URL for creating a new token with correct scopes
 */
export function getTokenCreationUrl(
  baseUrl: string,
  scopes: string[] = ["api", "read_user"]
): string {
  try {
    const url = new URL(baseUrl);
    // Preserve any existing subpath (e.g. https://host/gitlab) and append PAT settings path
    const basePath = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
    url.pathname = `${basePath}/-/user_settings/personal_access_tokens`;
    url.searchParams.set("name", "gitlab-mcp");
    url.searchParams.set("scopes", scopes.join(","));
    return url.toString();
  } catch {
    // baseUrl lacks a scheme or is otherwise unparseable — fall back to string concat
    const base = baseUrl.replace(/\/$/, "");
    const params = new URLSearchParams({
      name: "gitlab-mcp",
      scopes: scopes.join(","),
    });
    return `${base}/-/user_settings/personal_access_tokens?${params.toString()}`;
  }
}
 
/**
 * Log a clean, user-friendly startup message about token scopes
 */
export function logTokenScopeInfo(info: TokenScopeInfo, totalTools: number): void {
  const availableTools = getToolsForScopes(info.scopes);
  const scopeList = info.scopes.join(", ");
 
  // Token expiry warning (< 7 days)
  if (info.daysUntilExpiry !== null && info.daysUntilExpiry <= 7) {
    if (info.daysUntilExpiry < 0) {
      logWarn(`Token "${info.name}" has expired! Please create a new token.`, {
        tokenName: info.name,
        expiresAt: info.expiresAt,
      });
    } else if (info.daysUntilExpiry === 0) {
      logWarn(`Token "${info.name}" expires today!`, {
        tokenName: info.name,
        expiresAt: info.expiresAt,
      });
    } else {
      logWarn(`Token "${info.name}" expires in ${info.daysUntilExpiry} day(s)`, {
        tokenName: info.name,
        daysUntilExpiry: info.daysUntilExpiry,
        expiresAt: info.expiresAt,
      });
    }
  }
 
  if (info.hasWriteAccess) {
    // Full access (api scope) - brief message
    logInfo(`Token "${info.name}" detected`, {
      tokenName: info.name,
      scopes: scopeList,
      expiresAt: info.expiresAt ?? "never",
    });
  } else {
    // Limited access - explain what's available and how to fix
    logInfo(
      `Token "${info.name}" has limited scopes - ${availableTools.length} of ${totalTools} scope-gated tools available`,
      {
        tokenName: info.name,
        scopes: scopeList,
        availableTools: availableTools.length,
        totalTools,
      }
    );
 
    Eif (!info.hasGraphQLAccess) {
      logInfo("GraphQL introspection skipped (requires 'api' or 'read_api' scope)");
    }
 
    const fixUrl = getTokenCreationUrl(GITLAB_BASE_URL);
    logInfo(`For full functionality, create a token with 'api' scope: ${fixUrl}`, { url: fixUrl });
  }
}