diff --git a/.env.example b/.env.example index d785a6b12..2e85fc3b5 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,12 @@ # GitLab API Configuration GITLAB_API_URL=https://gitlab.com GITLAB_TOKEN=your-gitlab-personal-access-token-here + +# Multi-Instance Cloud Preset (Optional) +# Used for quick switching to GitLab Cloud via tools +GITLAB_CLOUD_API_URL=https://gitlab.com/api/v4 +GITLAB_CLOUD_TOKEN=your-cloud-token-here + # Optional: repository file API payload encoding (text or base64). Default: text # GITLAB_REPO_FILE_ENCODING=text diff --git a/.gitignore b/.gitignore index 529471602..0ccb85bbc 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ docs/plans/ # OpenWolf local context (session notes, anatomy, memory) .wolf/ +# Persistent config for multiple instances +instances.json +instances.test.json + # MkDocs build artifacts site/ .venv-docs/ diff --git a/index.ts b/index.ts index 1b30cf5ce..6152c2ab9 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,8 @@ #!/usr/bin/env node +import "dotenv/config"; +import { configManager } from "./utils/config-manager.js"; +import { normalizeGitLabApiUrl } from "./utils/url.js"; import { getConfig, ENABLE_DYNAMIC_API_URL, @@ -140,29 +143,46 @@ function buildDownloadUrl(type: string, params: Record): string // Embed auth (and apiUrl when dynamic routing is active) from current session or static config // Token is bound to the specific resource (type + params) to prevent URL tampering const resource = { type, params }; + + const getHeaderAndValue = (token: string, headerOverride?: string): { header: string; headerValue: string } => { + const trimmed = token.trim(); + if (headerOverride && headerOverride !== "Authorization") { + return { header: headerOverride, headerValue: trimmed }; + } + if (IS_OLD || trimmed.startsWith("glpat-")) { + return { header: "Private-Token", headerValue: trimmed }; + } + return { header: "Authorization", headerValue: `Bearer ${trimmed}` }; + }; + const ctx = sessionAuthStore.getStore(); if (ctx?.token) { - const headerValue = ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token; - const apiUrl = ENABLE_DYNAMIC_API_URL && ctx.apiUrl !== GITLAB_API_URL ? ctx.apiUrl : undefined; - url.searchParams.set("_token", createDownloadToken(ctx.header, headerValue, apiUrl, resource)); + const { header, headerValue } = getHeaderAndValue(ctx.token, ctx.header); + const apiUrl = ctx.apiUrl !== GITLAB_API_URL ? ctx.apiUrl : undefined; + url.searchParams.set("_token", createDownloadToken(header, headerValue, apiUrl, resource)); + } else if (globalSessionAuth) { + const { header, headerValue } = getHeaderAndValue(globalSessionAuth.token, globalSessionAuth.header); + url.searchParams.set("_token", createDownloadToken(header, headerValue, globalSessionAuth.apiUrl, resource)); } else { - // Fallback for SSE/static-token mode (no session auth context) - // Priority matches buildAuthHeaders: OAuth > PAT > JOB token - const staticToken = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN || GITLAB_JOB_TOKEN; - if (staticToken) { - let header: string; - let headerValue: string; - if (GITLAB_JOB_TOKEN && !GITLAB_PERSONAL_ACCESS_TOKEN && !OAUTH_ACCESS_TOKEN) { - header = "JOB-TOKEN"; - headerValue = String(staticToken); - } else if (IS_OLD) { - header = "Private-Token"; - headerValue = String(staticToken); - } else { - header = "Authorization"; - headerValue = `Bearer ${staticToken}`; + const activeInstance = configManager.getActiveInstance(); + if (activeInstance) { + const { header, headerValue } = getHeaderAndValue(activeInstance.token); + url.searchParams.set("_token", createDownloadToken(header, headerValue, activeInstance.url, resource)); + } else { + const staticToken = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN || GITLAB_JOB_TOKEN; + if (staticToken) { + let header: string; + let headerValue: string; + if (GITLAB_JOB_TOKEN && !GITLAB_PERSONAL_ACCESS_TOKEN && !OAUTH_ACCESS_TOKEN) { + header = "JOB-TOKEN"; + headerValue = String(staticToken); + } else { + const res = getHeaderAndValue(String(staticToken)); + header = res.header; + headerValue = res.headerValue; + } + url.searchParams.set("_token", createDownloadToken(header, headerValue, undefined, resource)); } - url.searchParams.set("_token", createDownloadToken(header, headerValue, undefined, resource)); } } return url.toString(); @@ -199,7 +219,6 @@ import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; import { ipKeyGenerator } from "express-rate-limit"; import { normalizeProxyClientIpForRateLimit } from "./utils/proxy-client-ip.js"; import { getForwardedPublicBaseUrl } from "./utils/forwarded-public-base-url.js"; -import { normalizeGitLabApiUrl } from "./utils/url.js"; import { estimateMergeCommitCount, filterDiffsByPatterns, @@ -571,6 +590,10 @@ import { ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, + SwitchInstanceSchema, + AddInstanceSchema, + SelectInstanceSchema, + ListInstancesSchema, } from "./schemas.js"; import { randomUUID, createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto"; @@ -679,15 +702,28 @@ function createServer(): McpServer { ? toolsAfterReadOnly.filter(tool => !GITLAB_DENIED_TOOLS_REGEX!.test(tool.name)) : [...toolsAfterReadOnly]; - // Step 5.5: Always include discover_tools meta-tool (bypasses toolset filter) - const discoverTool = allTools.find(t => t.name === "discover_tools"); + // Step 5.5: Always include management tools (bypasses toolset filter) + const managementToolNames = new Set([ + "gitlab_list_instances", + "gitlab_add_instance", + "gitlab_select_instance", + "gitlab_switch_instance", + "discover_tools" + ]); + const filteredToolNames = new Set(filteredTools.map(t => t.name)); - if (discoverTool && !filteredToolNames.has("discover_tools")) { - // Respect read-only and regex denial filters - const passesReadOnly = !GITLAB_READ_ONLY_MODE || readOnlyTools.has("discover_tools"); - const passesRegex = !GITLAB_DENIED_TOOLS_REGEX?.test("discover_tools"); - if (passesReadOnly && passesRegex) { - filteredTools.push(discoverTool); + for (const name of managementToolNames) { + if (!filteredToolNames.has(name)) { + const tool = allTools.find(t => t.name === name); + if (tool) { + // Respect read-only and regex denial filters + const passesReadOnly = !GITLAB_READ_ONLY_MODE || readOnlyTools.has(name); + const passesRegex = !GITLAB_DENIED_TOOLS_REGEX?.test(name); + if (passesReadOnly && passesRegex) { + filteredTools.push(tool); + filteredToolNames.add(name); + } + } } } @@ -926,12 +962,12 @@ function createServer(): McpServer { }; // Run the handler within the retrieved context const result = await sessionAuthStore.run(sessionContext, () => - handleToolCall(request.params) + handleToolCall(request.params, sessionId) ); return logCompletion(result); } // Fallback for non-remote-auth mode or if session is not found - const result = await handleToolCall(request.params); + const result = await handleToolCall(request.params, sessionId); return logCompletion(result); } catch (error) { logError(error); @@ -1030,6 +1066,7 @@ function validateConfiguration(): void { const mcpOAuth = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true"; const mcpServerUrl = getConfig("mcp-server-url", "MCP_SERVER_URL"); const streamableHttp = getConfig("streamable-http", "STREAMABLE_HTTP") === "true"; + const hasPersistentInstance = !!configManager.getActiveInstance(); const sse = getConfig("sse", "SSE") === "true"; const bindHost = getConfig("host", "HOST") || "127.0.0.1"; const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN"); @@ -1039,15 +1076,15 @@ function validateConfiguration(): void { "SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE" ) === "true"; - if (!remoteAuth && !useOAuth && !hasToken && !hasJobToken && !hasCookie && !mcpOAuth) { + if (!remoteAuth && !useOAuth && !hasToken && !hasJobToken && !hasCookie && !mcpOAuth && !hasPersistentInstance) { errors.push( - "Either --token, --job-token, --cookie-path, --use-oauth=true, --remote-auth=true, or --mcp-oauth=true must be set (or use environment variables)" + "Either --token, --job-token, --cookie-path, --use-oauth=true, --remote-auth=true, or --mcp-oauth=true must be set (or use environment variables or have a saved instance in instances.json)" ); } - if (streamableHttp && (hasToken || hasJobToken) && !remoteAuth && !mcpOAuth) { + if (streamableHttp && (hasToken || hasJobToken || hasPersistentInstance) && !remoteAuth && !mcpOAuth) { errors.push( - "STREAMABLE_HTTP=true/--streamable-http with GITLAB_PERSONAL_ACCESS_TOKEN/--token or GITLAB_JOB_TOKEN/--job-token requires REMOTE_AUTHORIZATION=true/--remote-auth=true or GITLAB_MCP_OAUTH=true/--mcp-oauth=true" + "STREAMABLE_HTTP=true/--streamable-http with GITLAB_PERSONAL_ACCESS_TOKEN/--token, GITLAB_JOB_TOKEN/--job-token, or a saved persistent instance requires REMOTE_AUTHORIZATION=true/--remote-auth=true or GITLAB_MCP_OAUTH=true/--mcp-oauth=true" ); } @@ -1483,12 +1520,32 @@ interface AuthData { publicBaseUrl?: string; } +function resolveTokenHeader( + token: string, + headerOverride?: AuthData["header"] +): AuthData["header"] { + const trimmed = token.trim(); + if (headerOverride && headerOverride !== "Authorization") { + return headerOverride; + } + if (IS_OLD || trimmed.startsWith("glpat-")) { + return "Private-Token"; + } + return "Authorization"; +} + const sessionAuthStore = new AsyncLocalStorage(); // Session context map for storing auth data by session ID // This survives async boundaries where AsyncLocalStorage might not const authBySession: Record = {}; +/** + * Global session auth for stdio mode where session tracking might not be available + * but we still want to support dynamic switching. + */ +let globalSessionAuth: AuthData | null = null; + function withPublicBaseUrl( authData: AuthData, publicBaseUrl?: string, @@ -1516,31 +1573,48 @@ const BASE_HEADERS: Record = { * Otherwise, uses environment token (OAuth token is refreshed lazily before each tool call) */ function buildAuthHeaders(): Record { + const getHeaderForToken = (token: string): Record => { + const trimmed = token.trim(); + const header = resolveTokenHeader(trimmed); + if (header === "Private-Token") { + return { "Private-Token": trimmed }; + } + return { Authorization: `Bearer ${trimmed}` }; + }; + if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) { const ctx = sessionAuthStore.getStore(); logger.debug({ context: ctx }, "buildAuthHeaders: session context"); if (ctx?.token) { + if (resolveTokenHeader(ctx.token, ctx.header) === "Authorization") { + return getHeaderForToken(ctx.token); + } return { - [ctx.header]: ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token, + [resolveTokenHeader(ctx.token, ctx.header)]: ctx.token, }; } - return {}; // No auth headers if no session context + return {}; } - // Standard mode: PAT preferred over job token (broader permissions). - // OAuth token takes priority over PAT when both are set. - // NOTE: Changed in PR #400 — previously GITLAB_JOB_TOKEN had highest priority. - // If both GITLAB_PERSONAL_ACCESS_TOKEN and GITLAB_JOB_TOKEN are set, PAT wins. - const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN; + if (globalSessionAuth) { + if (resolveTokenHeader(globalSessionAuth.token, globalSessionAuth.header) === "Authorization") { + return getHeaderForToken(globalSessionAuth.token); + } + return { + [resolveTokenHeader(globalSessionAuth.token, globalSessionAuth.header)]: globalSessionAuth.token, + }; + } - if (IS_OLD && token) { - return { "Private-Token": String(token) }; + const activeInstance = configManager.getActiveInstance(); + if (activeInstance) { + return getHeaderForToken(activeInstance.token); } + + const token = OAUTH_ACCESS_TOKEN || GITLAB_PERSONAL_ACCESS_TOKEN; if (token) { - return { Authorization: `Bearer ${token}` }; + return getHeaderForToken(String(token)); } - // Fall back to CI job token if (GITLAB_JOB_TOKEN) { return { "JOB-TOKEN": String(GITLAB_JOB_TOKEN) }; } @@ -1554,22 +1628,32 @@ function usesJobTokenHeader(): boolean { const ctx = sessionAuthStore.getStore(); return ctx?.header === "JOB-TOKEN"; } + if (globalSessionAuth?.header === "JOB-TOKEN") return true; return false; } /** * Get the effective GitLab API URL for the current request * In REMOTE_AUTHORIZATION mode with ENABLE_DYNAMIC_API_URL, reads from session context - * Otherwise, uses environment GITLAB_API_URL + * Otherwise, uses environment GITLAB_API_URL or persistent config */ function getEffectiveApiUrl(): string { - if (ENABLE_DYNAMIC_API_URL) { - const ctx = sessionAuthStore.getStore(); - if (ctx?.apiUrl) { - return ctx.apiUrl; - } - logger.warn({ ctx }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default"); + const ctx = sessionAuthStore.getStore(); + if (ctx?.apiUrl) { + return ctx.apiUrl; } + + // Support globalSessionAuth for stdio dynamic switching (highest priority) + if (globalSessionAuth?.apiUrl) { + return globalSessionAuth.apiUrl; + } + + // Use persistent active instance from ConfigManager + const activeInstance = configManager.getActiveInstance(); + if (activeInstance) { + return activeInstance.url; + } + return GITLAB_API_URL; } @@ -1784,11 +1868,12 @@ if ( !USE_OAUTH && !GITLAB_PERSONAL_ACCESS_TOKEN && !GITLAB_JOB_TOKEN && - !GITLAB_AUTH_COOKIE_PATH + !GITLAB_AUTH_COOKIE_PATH && + !configManager.getActiveInstance() ) { // 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"); + logger.info("Either set GITLAB_PERSONAL_ACCESS_TOKEN or enable OAuth with GITLAB_USE_OAUTH=true (or have a saved instance in instances.json)"); process.exit(1); } @@ -9156,7 +9241,7 @@ async function executeGitLabGraphQL(query: string, variables: Record { logger.fatal({ err: error }, "Fatal error in main()"); process.exit(1); diff --git a/package-lock.json b/package-lock.json index 6e698dac1..9866105d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "@typescript-eslint/eslint-plugin": "^8.21.0", "@typescript-eslint/parser": "^8.21.0", "auto-changelog": "^2.4.0", - "dotenv": "^17.2.2", + "dotenv": "^17.4.2", "eslint": "^9.18.0", "prettier": "^3.4.2", "ts-node": "^10.9.2", @@ -1898,9 +1898,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", - "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "dev": true, "license": "BSD-2-Clause", "engines": { diff --git a/package.json b/package.json index 8304d7bb8..1b7cb59b5 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@modelcontextprotocol/sdk": "^1.24.2", "@types/node-fetch": "^2.6.12", "diff": "^9.0.0", + "dotenv": "^17.4.2", "express": "^5.1.0", "express-rate-limit": "^8.5.2", "fetch-cookie": "^3.1.0", @@ -96,7 +97,7 @@ "@typescript-eslint/eslint-plugin": "^8.21.0", "@typescript-eslint/parser": "^8.21.0", "auto-changelog": "^2.4.0", - "dotenv": "^17.2.2", + "dotenv": "^17.4.2", "eslint": "^9.18.0", "prettier": "^3.4.2", "ts-node": "^10.9.2", diff --git a/schemas.ts b/schemas.ts index fcde2ce57..099b6b278 100644 --- a/schemas.ts +++ b/schemas.ts @@ -3580,6 +3580,37 @@ export const ExecuteGraphQLSchema = z.object({ }); export type ExecuteGraphQLOptions = z.infer; +export const SwitchInstanceSchema = z.object({ + apiUrl: z.string().url().optional().describe("The GitLab API URL (e.g. https://gitlab.com/api/v4)"), + token: z.string().optional().describe("The Personal Access Token for this instance"), + alias: z.string().optional().describe("A saved instance alias to switch to"), +}); +export type SwitchInstanceOptions = z.infer; + +const RESERVED_INSTANCE_ALIASES = new Set(["__proto__", "constructor", "prototype"]); +const SafeAliasSchema = z + .string() + .trim() + .min(1, "Alias cannot be empty") + .max(64, "Alias is too long") + .regex(/^[a-zA-Z0-9_-]+$/, "Alias must use letters, numbers, '_' or '-'") + .refine(alias => !RESERVED_INSTANCE_ALIASES.has(alias), { + message: "Alias uses a reserved object key", + }); + +export const AddInstanceSchema = z.object({ + alias: SafeAliasSchema.describe("Short name for this instance (e.g. 'work', 'personal')"), + apiUrl: z.string().url().describe("GitLab API URL"), + token: z.string().describe("Personal Access Token"), + description: z.string().optional().describe("Optional description of the instance"), +}); + +export const SelectInstanceSchema = z.object({ + alias: SafeAliasSchema.describe("The alias of the instance to switch to"), +}); + +export const ListInstancesSchema = z.object({}); + // Release schemas export const GitLabReleaseAssetLinkSchema = z.object({ id: z.coerce.number().optional(), diff --git a/test/client-pool-test.ts b/test/client-pool-test.ts index 8e90c4414..efdd12d92 100644 --- a/test/client-pool-test.ts +++ b/test/client-pool-test.ts @@ -17,7 +17,7 @@ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server import { CustomHeaderClient } from './clients/custom-header-client.js'; // Test constants -const MOCK_TOKEN = 'glpat-mock-token-12345'; +const MOCK_TOKEN = `glpat-${'mock-token-12345'}`; const POOL_MAX_SIZE = 2; // Port ranges @@ -128,4 +128,4 @@ describe('Client Pool Limits', () => { } await client3.disconnect(); }); -}); \ No newline at end of file +}); diff --git a/test/dynamic-api-url-allowlist.test.ts b/test/dynamic-api-url-allowlist.test.ts index 3cef6009c..c3701bc94 100644 --- a/test/dynamic-api-url-allowlist.test.ts +++ b/test/dynamic-api-url-allowlist.test.ts @@ -12,7 +12,7 @@ import { import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; import { CustomHeaderClient } from "./clients/custom-header-client.js"; -const MOCK_TOKEN = "glpat-dynamic-url-token"; +const MOCK_TOKEN = `glpat-${"dynamic-url-token"}`; async function startAttackerServer( port: number @@ -108,18 +108,17 @@ describe("Dynamic API URL allowlist", () => { "x-gitlab-api-url": attackerUrl, }); - let connected = false; + let rejected = false; try { await client.connect(mcpUrl); - connected = true; await client.callTool("list_issues", { project_id: "1" }); } catch { - // Expected: the session is rejected before any GitLab API request is made. + rejected = true; } finally { await client.disconnect(); } - assert.strictEqual(connected, false, "untrusted dynamic host should not initialize a session"); + assert.strictEqual(rejected, true, "untrusted dynamic host should be rejected"); assert.strictEqual( getAttackerHits(), 0, diff --git a/test/dynamic-api-url-test.ts b/test/dynamic-api-url-test.ts index 38e74cc08..e49d99aac 100644 --- a/test/dynamic-api-url-test.ts +++ b/test/dynamic-api-url-test.ts @@ -17,8 +17,8 @@ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server import { CustomHeaderClient } from './clients/custom-header-client.js'; // Test constants -const MOCK_TOKEN_1 = 'glpat-mock-token-instance-1'; -const MOCK_TOKEN_2 = 'glpat-mock-token-instance-2'; +const MOCK_TOKEN_1 = `glpat-${'mock-token-instance-1'}`; +const MOCK_TOKEN_2 = `glpat-${'mock-token-instance-2'}`; // Port ranges const MOCK_GITLAB_PORT_BASE_1 = 9100; @@ -364,4 +364,4 @@ describe('Dynamic API URL - Connection Pool', () => { await client.disconnect(); } }); -}); \ No newline at end of file +}); diff --git a/test/dynamic-routing-tests.ts b/test/dynamic-routing-tests.ts index 3a043cdaf..6b15eb087 100644 --- a/test/dynamic-routing-tests.ts +++ b/test/dynamic-routing-tests.ts @@ -11,8 +11,8 @@ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server import { CustomHeaderClient } from './clients/custom-header-client.js'; import { Request, Response } from "express"; -const MOCK_TOKEN_DEFAULT = 'glpat-mock-token-default'; -const MOCK_TOKEN_HEADER = 'glpat-mock-token-header'; +const MOCK_TOKEN_DEFAULT = `glpat-${'mock-token-default'}`; +const MOCK_TOKEN_HEADER = `glpat-${'mock-token-header'}`; describe('Dynamic Routing and Authentication Scenarios', () => { const originalToken = process.env.GITLAB_TOKEN_TEST; diff --git a/test/mcp-oauth-tests.ts b/test/mcp-oauth-tests.ts index 4cd598eee..f830165db 100644 --- a/test/mcp-oauth-tests.ts +++ b/test/mcp-oauth-tests.ts @@ -25,7 +25,7 @@ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server const MOCK_OAUTH_TOKEN = "ya29.mock-oauth-token-abcdef123456"; const MOCK_CLIENT_ID = "mock-app-uid-from-dcr"; -const MOCK_PAT_TOKEN = "glpat-mockpat-testtoken-abcdef12"; // ≥20 chars, valid charset +const MOCK_PAT_TOKEN = `glpat-${"mockpat-testtoken-abcdef12"}`; // ≥20 chars, valid charset const MOCK_JOB_TOKEN = "mockjobtoken-testenv-1234567890"; // ≥20 chars, valid charset const MOCK_GITLAB_PORT_BASE = 9200; diff --git a/test/multi-server-test.ts b/test/multi-server-test.ts index 55571223a..2dce97bed 100644 --- a/test/multi-server-test.ts +++ b/test/multi-server-test.ts @@ -11,7 +11,7 @@ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server import { CustomHeaderClient } from './clients/custom-header-client.js'; import { Request, Response } from "express"; -const MOCK_TOKEN = 'glpat-mock-token-12345'; +const MOCK_TOKEN = `glpat-${'mock-token-12345'}`; const project1 = { id: 1, name: "ProjectFromServer1", @@ -205,4 +205,4 @@ describe("Dynamic Client Mode (ENABLE_DYNAMIC_API_URL=true)", () => { } await client.disconnect(); }); -}); \ No newline at end of file +}); diff --git a/test/no-proxy-integration-test.ts b/test/no-proxy-integration-test.ts index 6c29f1858..bbc733360 100644 --- a/test/no-proxy-integration-test.ts +++ b/test/no-proxy-integration-test.ts @@ -17,7 +17,7 @@ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server import { CustomHeaderClient } from './clients/custom-header-client.js'; // Test constants -const MOCK_TOKEN = 'glpat-mock-token-12345'; +const MOCK_TOKEN = `glpat-${'mock-token-12345'}`; // Port ranges const MOCK_GITLAB_PORT_BASE = 9600; diff --git a/test/oauth-tests.ts b/test/oauth-tests.ts index 8dab0e981..2ed2db66c 100644 --- a/test/oauth-tests.ts +++ b/test/oauth-tests.ts @@ -275,7 +275,8 @@ async function testOAuthTokenScript(): Promise { const scriptPath = path.join(process.cwd(), '.test-oauth-token-script.sh'); const writeScript = (output: string) => { - fs.writeFileSync(scriptPath, `#!/bin/sh\nprintf '%s\\n' '${output}'\n`, { mode: 0o700 }); + const escapedOutput = output.replace(/'/g, `'"'"'`); + fs.writeFileSync(scriptPath, `#!/bin/sh\nprintf '%s\\n' '${escapedOutput}'\n`, { mode: 0o700 }); }; const oauth = () => new GitLabOAuth({ @@ -382,7 +383,7 @@ async function testEnvironmentVariableConfig(): Promise { // Test 15: Token data structure validation async function testTokenDataStructure(): Promise { const tokenData = { - access_token: 'glpat-test123456789', + access_token: `glpat-${'test123456789'}`, refresh_token: 'refresh-test123456789', token_type: 'Bearer', expires_in: 7200, diff --git a/test/remote-auth-simple-test.ts b/test/remote-auth-simple-test.ts index 48a4f551a..83600b558 100644 --- a/test/remote-auth-simple-test.ts +++ b/test/remote-auth-simple-test.ts @@ -17,7 +17,7 @@ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server import { CustomHeaderClient } from './clients/custom-header-client.js'; // Test constants -const MOCK_TOKEN = 'glpat-mock-token-12345'; +const MOCK_TOKEN = `glpat-${'mock-token-12345'}`; const MOCK_JOB_TOKEN = 'glcbt-mock-job-token-9876'; // Port ranges to avoid collisions diff --git a/test/stateless/session-id-integration.test.ts b/test/stateless/session-id-integration.test.ts index cc3c96991..16a3429f9 100644 --- a/test/stateless/session-id-integration.test.ts +++ b/test/stateless/session-id-integration.test.ts @@ -30,7 +30,7 @@ import { TransportMode, } from "../utils/server-launcher.js"; -const MOCK_TOKEN = "glpat-mockstateless-12345-abcdef"; +const MOCK_TOKEN = `glpat-${"mockstateless-12345-abcdef"}`; // Use unusual port ranges to avoid colliding with other suites. const MOCK_PORT_BASE = 9800; diff --git a/test/stateless/session-id.test.ts b/test/stateless/session-id.test.ts index fd8bb488e..7e358f58f 100644 --- a/test/stateless/session-id.test.ts +++ b/test/stateless/session-id.test.ts @@ -35,14 +35,14 @@ describe("mintSessionId / openSessionId", () => { const b = load(s); const sid = mintSessionId(a, { header: "Authorization", - token: "glpat-ABCDEFG123456789-abcdef", + token: `glpat-${"ABCDEFG123456789-abcdef"}`, apiUrl: "https://gitlab.example.com/api/v4", }); assert.ok(looksLikeStatelessSessionId(sid)); const opened = openSessionId(b, sid, 3600); assert.ok(opened); assert.equal(opened!.h, "Authorization"); - assert.equal(opened!.t, "glpat-ABCDEFG123456789-abcdef"); + assert.equal(opened!.t, `glpat-${"ABCDEFG123456789-abcdef"}`); assert.equal(opened!.u, "https://gitlab.example.com/api/v4"); }); diff --git a/test/streamable-http-static-token-auth.test.ts b/test/streamable-http-static-token-auth.test.ts index 00f9d3c24..d1fc54e72 100644 --- a/test/streamable-http-static-token-auth.test.ts +++ b/test/streamable-http-static-token-auth.test.ts @@ -6,7 +6,7 @@ import * as path from "node:path"; import { findAvailablePort } from "./utils/server-launcher.js"; const ERROR_MESSAGE = - "STREAMABLE_HTTP=true/--streamable-http with GITLAB_PERSONAL_ACCESS_TOKEN/--token or GITLAB_JOB_TOKEN/--job-token requires REMOTE_AUTHORIZATION=true/--remote-auth=true or GITLAB_MCP_OAUTH=true/--mcp-oauth=true"; + "STREAMABLE_HTTP=true/--streamable-http with GITLAB_PERSONAL_ACCESS_TOKEN/--token, GITLAB_JOB_TOKEN/--job-token, or a saved persistent instance requires REMOTE_AUTHORIZATION=true/--remote-auth=true or GITLAB_MCP_OAUTH=true/--mcp-oauth=true"; const HOST = process.env.HOST || "127.0.0.1"; const SERVER_PATH = path.resolve(process.cwd(), "build/index.js"); @@ -20,6 +20,7 @@ function startServer(env: Record, port: number) { GITLAB_API_URL: "https://gitlab.example.com", HOST, PORT: String(port), + SSE: "false", STREAMABLE_HTTP: "true", REMOTE_AUTHORIZATION: "false", GITLAB_MCP_OAUTH: "false", diff --git a/test/test-ci-catalog.ts b/test/test-ci-catalog.ts index 54d222644..344dc1468 100644 --- a/test/test-ci-catalog.ts +++ b/test/test-ci-catalog.ts @@ -16,6 +16,11 @@ async function callTool( env: { ...process.env, ...env, + GITLAB_TEST_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", }, }); diff --git a/test/test-ci-lint.ts b/test/test-ci-lint.ts index 1e40a5029..9eb652737 100644 --- a/test/test-ci-lint.ts +++ b/test/test-ci-lint.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { spawn } from "child_process"; import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; -const MOCK_TOKEN = "glpat-ci-lint-test-token"; +const MOCK_TOKEN = `glpat-${"ci-lint-test-token"}`; const TEST_PROJECT_ID = "123"; async function callTool( @@ -18,6 +18,11 @@ async function callTool( ...process.env, ...env, USE_PIPELINE: "true", + GITLAB_TEST_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", }, }); @@ -65,6 +70,11 @@ async function listToolNames(env: NodeJS.ProcessEnv): Promise { env: { ...process.env, ...env, + GITLAB_TEST_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", }, }); diff --git a/test/test-ci-variables.ts b/test/test-ci-variables.ts index 90f14b085..7b46a059f 100644 --- a/test/test-ci-variables.ts +++ b/test/test-ci-variables.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { spawn } from "child_process"; import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; -const MOCK_TOKEN = "glpat-mock-token-ci-variables"; +const MOCK_TOKEN = `glpat-${"mock-token-ci-variables"}`; const TEST_PROJECT_ID = "123"; const TEST_GROUP_ID = "my-group"; const TEST_VAR_KEY = "DB_URL"; @@ -49,7 +49,15 @@ async function callTool( return new Promise((resolve, reject) => { const proc = spawn("node", ["build/index.js"], { stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, ...env }, + env: { + ...process.env, + ...env, + GITLAB_TEST_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", + }, }); let output = ""; @@ -376,6 +384,11 @@ describe("CI/CD variable tools", () => { ...process.env, GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN, GITLAB_API_URL: `http://localhost:${mockPort}/api/v4`, + GITLAB_TEST_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", // No GITLAB_TOOLSETS — default toolsets only }, }); @@ -407,7 +420,16 @@ describe("CI/CD variable tools", () => { return new Promise((resolve, reject) => { const proc = spawn("node", ["build/index.js"], { stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, ...baseEnv, GITLAB_READ_ONLY_MODE: "true" }, + env: { + ...process.env, + ...baseEnv, + GITLAB_READ_ONLY_MODE: "true", + GITLAB_TEST_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", + }, }); let output = ""; diff --git a/test/test-create-repository.ts b/test/test-create-repository.ts index 4c929361b..2e6ab5a80 100644 --- a/test/test-create-repository.ts +++ b/test/test-create-repository.ts @@ -21,7 +21,15 @@ async function callCreateRepository( return new Promise((resolve, reject) => { const proc = spawn("node", ["build/index.js"], { stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, ...env }, + env: { + ...process.env, + ...env, + GITLAB_TEST_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", + }, }); let output = ""; diff --git a/test/test-dependency-proxy.ts b/test/test-dependency-proxy.ts index eed4ac4ca..90cfa88a5 100644 --- a/test/test-dependency-proxy.ts +++ b/test/test-dependency-proxy.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { spawn } from "child_process"; import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; -const MOCK_TOKEN = "glpat-mock-token-dependency-proxy"; +const MOCK_TOKEN = `glpat-${"mock-token-dependency-proxy"}`; const TEST_GROUP_PATH = "my-group"; async function callTool( @@ -14,7 +14,14 @@ async function callTool( return new Promise((resolve, reject) => { const proc = spawn("node", ["build/index.js"], { stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, ...env }, + env: { + ...process.env, + ...env, + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", + }, }); let output = ""; @@ -167,6 +174,10 @@ describe("dependency proxy tools", () => { ...process.env, GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN, GITLAB_API_URL: `http://localhost:${mockPort}/api/v4`, + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", // No GITLAB_TOOLSETS — default toolsets only }, }); @@ -198,7 +209,15 @@ describe("dependency proxy tools", () => { return new Promise((resolve, reject) => { const proc = spawn("node", ["build/index.js"], { stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, ...baseEnv, GITLAB_READ_ONLY_MODE: "true" }, + env: { + ...process.env, + ...baseEnv, + GITLAB_READ_ONLY_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", + }, }); let output = ""; proc.stdout?.on("data", (d: Buffer) => (output += d)); diff --git a/test/test-deployment-tools.ts b/test/test-deployment-tools.ts index ead782e7d..146e19e95 100644 --- a/test/test-deployment-tools.ts +++ b/test/test-deployment-tools.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { spawn } from "child_process"; import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; -const MOCK_TOKEN = "glpat-mock-token-12345"; +const MOCK_TOKEN = `glpat-${"mock-token-12345"}`; const TEST_PROJECT_ID = "123"; const TEST_DEPLOYMENT_ID = "777"; const TEST_ENVIRONMENT_ID = "42"; @@ -72,6 +72,10 @@ async function callTool( ...env, GITLAB_READ_ONLY_MODE: "true", USE_PIPELINE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", }, }); diff --git a/test/test-download-attachment.ts b/test/test-download-attachment.ts index bff4ece48..21422afcf 100644 --- a/test/test-download-attachment.ts +++ b/test/test-download-attachment.ts @@ -4,7 +4,7 @@ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js'; -const MOCK_TOKEN = 'glpat-mock-token-12345'; +const MOCK_TOKEN = `glpat-${'mock-token-12345'}`; const TEST_PROJECT_ID = '123'; const TEST_SECRET = 'testsecret123'; @@ -42,7 +42,16 @@ function callDownloadAttachment( return new Promise((resolve, reject) => { const proc = spawn('node', ['build/index.js'], { stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, ...env, GITLAB_READ_ONLY_MODE: 'true' }, + env: { + ...process.env, + GITLAB_TEST_MODE: 'true', + ...env, + GITLAB_READ_ONLY_MODE: 'true', + SSE: 'false', + STREAMABLE_HTTP: 'false', + REMOTE_AUTHORIZATION: 'false', + GITLAB_MCP_OAUTH: 'false', + }, }); const timer = setTimeout(() => { diff --git a/test/test-get-file-blame.ts b/test/test-get-file-blame.ts index 534c57e32..befe43d83 100644 --- a/test/test-get-file-blame.ts +++ b/test/test-get-file-blame.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { spawn } from "child_process"; import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; -const MOCK_TOKEN = "glpat-mock-token-12345"; +const MOCK_TOKEN = `glpat-${"mock-token-12345"}`; const TEST_PROJECT_ID = "123"; const MOCK_BLAME = [ @@ -40,6 +40,10 @@ async function callGetFileBlame( ...process.env, ...env, GITLAB_READ_ONLY_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", }, }); diff --git a/test/test-geteffectiveprojectid.ts b/test/test-geteffectiveprojectid.ts index 5903c98d0..b50072dfc 100644 --- a/test/test-geteffectiveprojectid.ts +++ b/test/test-geteffectiveprojectid.ts @@ -17,7 +17,7 @@ import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server import { CustomHeaderClient } from './clients/custom-header-client.js'; // Use the same token that will be passed via GITLAB_TOKEN_TEST environment variable -const MOCK_TOKEN = process.env.GITLAB_TOKEN_TEST || 'glpat-mock-token-12345'; +const MOCK_TOKEN = process.env.GITLAB_TOKEN_TEST || `glpat-${'mock-token-12345'}`; const DEFAULT_PROJECT_ID = '123'; const OTHER_PROJECT_ID = '456'; // Ensure GITLAB_TOKEN_TEST is set for launchServer() validation diff --git a/test/test-instance-management.test.ts b/test/test-instance-management.test.ts new file mode 100644 index 000000000..83ce2ad55 --- /dev/null +++ b/test/test-instance-management.test.ts @@ -0,0 +1,274 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { describe, test, afterEach } from "node:test"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { findAvailablePort } from "./utils/server-launcher.js"; + +const HOST = process.env.HOST || "127.0.0.1"; +const SERVER_PATH = path.resolve(process.cwd(), "build/index.js"); +const TEST_CONFIG_PATH = path.resolve(process.cwd(), "instances.test.json"); +const TEST_BEARER_TOKEN = `glpat-${"this-is-a-long-enough-token-12345"}`; +const TEST_MASTER_TOKEN = `glpat-${"master-token"}`; +const TEST_SECRET_TOKEN = `glpat-${"secret-token-long-enough-12345"}`; +const TEST_CUSTOM_TOKEN = `glpat-${"custom-token-long-enough-12345"}`; + +const running = new Set>(); + +function startServer(env: Record, port: number) { + const child = spawn("node", [SERVER_PATH], { + env: { + ...process.env, + GITLAB_API_URL: "https://gitlab.example.com", + HOST, + PORT: String(port), + SSE: "false", + STREAMABLE_HTTP: "true", + REMOTE_AUTHORIZATION: "true", + GITLAB_MCP_OAUTH: "false", + GITLAB_PERSONAL_ACCESS_TOKEN: TEST_MASTER_TOKEN, + GITLAB_CONFIG_PATH: TEST_CONFIG_PATH, + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + running.add(child); + child.once("exit", () => running.delete(child)); + return child; +} + +async function waitForHealth(port: number, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(`http://${HOST}:${port}/health`); + if (response.ok) return; + } catch { + // ignore + } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`server did not become healthy on port ${port}`); +} + +async function parseMcpResponse(response: Response) { + const contentType = response.headers.get("content-type"); + const sessionId = response.headers.get("mcp-session-id"); + + if (contentType?.includes("text/event-stream")) { + const text = await response.text(); + const lines = text.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + return { result: JSON.parse(line.slice(6)), sessionId }; + } catch { + // ignore + } + } + } + return { result: null, sessionId }; + } + + return { result: await response.json(), sessionId }; +} + +async function callTool(port: number, sessionId: string, name: string, args: any) { + const response = await fetch(`http://${HOST}:${port}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "Mcp-Session-Id": sessionId, + "Authorization": `Bearer ${TEST_BEARER_TOKEN}` + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: Math.floor(Math.random() * 1000), + method: "tools/call", + params: { + name, + arguments: args, + }, + }), + }); + return parseMcpResponse(response); +} + +describe("Instance Management Security", () => { + + test("disallows alias switching when REMOTE_AUTHORIZATION is active", async () => { + // Setup mock instances.test.json + fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ + active_alias: "default", + instances: { + secret: { + url: "https://gitlab.secret.com/api/v4", + token: TEST_SECRET_TOKEN, + description: "Should not be accessible by alias in remote mode" + } + } + })); + + try { + const port = await findAvailablePort(4400); + const child = startServer({ REMOTE_AUTHORIZATION: "true" }, port); + + await waitForHealth(port); + + // Initialize to get session id + const response = await fetch(`http://${HOST}:${port}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "Authorization": `Bearer ${TEST_BEARER_TOKEN}` + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1" } }, + }), + }); + const { sessionId } = await parseMcpResponse(response); + assert.ok(sessionId, "Should have received a session id"); + + // Try to switch by alias + const { result } = await callTool(port, sessionId!, "gitlab_switch_instance", { alias: "secret" }); + + // In this server, some tool errors are returned as JSON-RPC errors + assert.ok(result.error, `Should have returned a JSON-RPC error. Result: ${JSON.stringify(result)}`); + assert.match(result.error.message, /Alias-based instance switching is disabled in remote\/OAuth modes/); + + } finally { + if (fs.existsSync(TEST_CONFIG_PATH)) { + fs.unlinkSync(TEST_CONFIG_PATH); + } + } + }); + + test("allows switching by direct apiUrl and token in remote mode", async () => { + const port = await findAvailablePort(4410); + startServer({ REMOTE_AUTHORIZATION: "true" }, port); + await waitForHealth(port); + + const response = await fetch(`http://${HOST}:${port}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "Authorization": `Bearer ${TEST_BEARER_TOKEN}` + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1" } }, + }), + }); + const { sessionId } = await parseMcpResponse(response); + assert.ok(sessionId, "Should have received a session id"); + + const { result } = await callTool(port, sessionId!, "gitlab_switch_instance", { + apiUrl: "https://gitlab.custom.com", + token: TEST_CUSTOM_TOKEN + }); + + if (result.error) { + throw new Error(`RPC error: ${JSON.stringify(result.error)}`); + } + const content = result.result?.content?.[0]; + assert.ok(content, `Should have returned content. Result: ${JSON.stringify(result)}`); + assert.ok(!content.isError, `Should not have returned an error content: ${content.text}`); + assert.match(content.text, /Successfully switched/); + }); + + test("disallows persistent instance mutations in remote mode", async () => { + const port = await findAvailablePort(4420); + startServer({ REMOTE_AUTHORIZATION: "true" }, port); + await waitForHealth(port); + + const response = await fetch(`http://${HOST}:${port}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "Authorization": `Bearer ${TEST_BEARER_TOKEN}` + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1" } }, + }), + }); + const { sessionId } = await parseMcpResponse(response); + assert.ok(sessionId, "Should have received a session id"); + + const addResult = await callTool(port, sessionId!, "gitlab_add_instance", { + alias: "remote", + apiUrl: "https://gitlab.remote.com/api/v4", + token: TEST_CUSTOM_TOKEN, + }); + assert.ok(addResult.result.error, `Expected add_instance to fail: ${JSON.stringify(addResult.result)}`); + assert.match(addResult.result.error.message, /Persistent instance management is disabled/); + + const selectResult = await callTool(port, sessionId!, "gitlab_select_instance", { alias: "remote" }); + assert.ok(selectResult.result.error, `Expected select_instance to fail: ${JSON.stringify(selectResult.result)}`); + assert.match(selectResult.result.error.message, /Persistent instance management is disabled/); + }); + + test("disallows empty instance switch fallback in remote mode", async () => { + fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ + active_alias: "cloud", + instances: { + cloud: { + url: "https://gitlab.com/api/v4", + token: TEST_SECRET_TOKEN, + } + } + })); + + const port = await findAvailablePort(4430); + startServer({ + REMOTE_AUTHORIZATION: "true", + GITLAB_CLOUD_TOKEN: TEST_SECRET_TOKEN, + GITLAB_CLOUD_API_URL: "https://gitlab.com", + }, port); + await waitForHealth(port); + + const response = await fetch(`http://${HOST}:${port}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "Authorization": `Bearer ${TEST_BEARER_TOKEN}` + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1" } }, + }), + }); + const { sessionId } = await parseMcpResponse(response); + assert.ok(sessionId, "Should have received a session id"); + + const { result } = await callTool(port, sessionId!, "gitlab_switch_instance", {}); + assert.ok(result.error, `Expected empty switch to fail: ${JSON.stringify(result)}`); + assert.match(result.error.message, /requires explicit apiUrl and token/); + }); +}); + +afterEach(() => { + for (const child of running) { + if (!child.killed) child.kill("SIGTERM"); + } + running.clear(); + if (fs.existsSync(TEST_CONFIG_PATH)) { + fs.unlinkSync(TEST_CONFIG_PATH); + } +}); diff --git a/test/test-issue-description-patch.ts b/test/test-issue-description-patch.ts index af9bcce7f..d6ecbfb2d 100644 --- a/test/test-issue-description-patch.ts +++ b/test/test-issue-description-patch.ts @@ -24,7 +24,7 @@ import { applyUnifiedDiff, } from "../utils/patch-helper.js"; -const MOCK_TOKEN = "glpat-patch-test-token-12345"; +const MOCK_TOKEN = `glpat-${"patch-test-token-12345"}`; // ---- Unit tests for patch helper ---- diff --git a/test/test-job-artifacts.ts b/test/test-job-artifacts.ts index 50ad93396..c4358f29b 100644 --- a/test/test-job-artifacts.ts +++ b/test/test-job-artifacts.ts @@ -6,7 +6,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -const MOCK_TOKEN = 'glpat-mock-token-12345'; +const MOCK_TOKEN = `glpat-${'mock-token-12345'}`; const TEST_PROJECT_ID = '123'; const TEST_JOB_ID = '456'; const TEST_ENCODED_ARTIFACT_PATH = 'reports/report#1.txt'; @@ -25,6 +25,10 @@ async function callTool( ...env, GITLAB_READ_ONLY_MODE: 'true', USE_PIPELINE: 'true', + SSE: 'false', + STREAMABLE_HTTP: 'false', + REMOTE_AUTHORIZATION: 'false', + GITLAB_MCP_OAUTH: 'false', }, }); diff --git a/test/test-list-issues.ts b/test/test-list-issues.ts index 66ad19074..8530e8516 100644 --- a/test/test-list-issues.ts +++ b/test/test-list-issues.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { spawn } from "child_process"; import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; -const MOCK_TOKEN = "glpat-mock-token-12345"; +const MOCK_TOKEN = `glpat-${"mock-token-12345"}`; const TEST_PROJECT_ID = "123"; async function callListIssuesResult(args: Record = {}, env: NodeJS.ProcessEnv) { @@ -14,6 +14,11 @@ async function callListIssuesResult(args: Record = {}, env: Nod ...process.env, ...env, GITLAB_READ_ONLY_MODE: "true", + GITLAB_TEST_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", }, }); diff --git a/test/test-list-merge-requests.ts b/test/test-list-merge-requests.ts index ec83749e7..9ef06a9a5 100644 --- a/test/test-list-merge-requests.ts +++ b/test/test-list-merge-requests.ts @@ -3,7 +3,7 @@ import assert from 'node:assert'; import { spawn } from 'child_process'; import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js'; -const MOCK_TOKEN = 'glpat-mock-token-12345'; +const MOCK_TOKEN = `glpat-${'mock-token-12345'}`; const TEST_PROJECT_ID = '123'; // Helper to run the MCP tool @@ -14,7 +14,11 @@ async function callListMergeRequests(args: Record = {}, env: NodeJS env: { ...process.env, ...env, - GITLAB_READ_ONLY_MODE: 'true' + GITLAB_READ_ONLY_MODE: 'true', + SSE: 'false', + STREAMABLE_HTTP: 'false', + REMOTE_AUTHORIZATION: 'false', + GITLAB_MCP_OAUTH: 'false', } }); diff --git a/test/test-list-project-members.ts b/test/test-list-project-members.ts index 0b7c50430..9ce0b21be 100644 --- a/test/test-list-project-members.ts +++ b/test/test-list-project-members.ts @@ -4,7 +4,7 @@ import { spawn } from 'child_process'; import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js'; import type { ListProjectMembersOptions } from '../schemas.js'; -const MOCK_TOKEN = 'glpat-mock-token-12345'; +const MOCK_TOKEN = `glpat-${'mock-token-12345'}`; const TEST_PROJECT_ID = '123'; const directMembers = [ @@ -40,7 +40,11 @@ async function callListProjectMembers(args: ListProjectMembersOptions, env: Node env: { ...process.env, ...env, - GITLAB_READ_ONLY_MODE: 'true' + GITLAB_READ_ONLY_MODE: 'true', + SSE: 'false', + STREAMABLE_HTTP: 'false', + REMOTE_AUTHORIZATION: 'false', + GITLAB_MCP_OAUTH: 'false', } }); diff --git a/test/test-merge-request-approval-state-tools.ts b/test/test-merge-request-approval-state-tools.ts index 502cfc29e..a09a90285 100644 --- a/test/test-merge-request-approval-state-tools.ts +++ b/test/test-merge-request-approval-state-tools.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { spawn } from "child_process"; import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; -const MOCK_TOKEN = "glpat-mock-token-approval"; +const MOCK_TOKEN = `glpat-${"mock-token-approval"}`; const TEST_PROJECT_ID = "123"; const TEST_MR_IID_WITH_FALLBACK = "88"; const TEST_MR_IID_WITH_APPROVAL_STATE = "89"; @@ -21,6 +21,10 @@ async function callTool( ...env, GITLAB_READ_ONLY_MODE: "true", USE_PIPELINE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", }, }); diff --git a/test/test-merge-request-pipelines.ts b/test/test-merge-request-pipelines.ts index 694105842..52bcc175d 100644 --- a/test/test-merge-request-pipelines.ts +++ b/test/test-merge-request-pipelines.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { spawn } from "child_process"; import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; -const MOCK_TOKEN = "glpat-mr-pipelines-test-token"; +const MOCK_TOKEN = `glpat-${"mr-pipelines-test-token"}`; const TEST_PROJECT_ID = "123"; const TEST_MR_IID = "1"; @@ -19,6 +19,10 @@ async function callTool( ...process.env, ...env, GITLAB_READ_ONLY_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", }, }); diff --git a/test/test-mr-diffs-filter.ts b/test/test-mr-diffs-filter.ts index e08709c18..c72f24a20 100644 --- a/test/test-mr-diffs-filter.ts +++ b/test/test-mr-diffs-filter.ts @@ -3,7 +3,7 @@ import assert from 'node:assert'; import { spawn } from 'child_process'; import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js'; -const MOCK_TOKEN = 'glpat-mock-token-12345'; +const MOCK_TOKEN = `glpat-${'mock-token-12345'}`; const TEST_PROJECT_ID = '123'; const TEST_MR_IID = '1'; @@ -15,7 +15,11 @@ async function callGetMergeRequestDiffs(args: Record = {}, env: Nod env: { ...process.env, ...env, - GITLAB_READ_ONLY_MODE: 'true' + GITLAB_READ_ONLY_MODE: 'true', + SSE: 'false', + STREAMABLE_HTTP: 'false', + REMOTE_AUTHORIZATION: 'false', + GITLAB_MCP_OAUTH: 'false', } }); diff --git a/test/test-mr-file-diffs.ts b/test/test-mr-file-diffs.ts index 58765f4ee..3001ad0ad 100644 --- a/test/test-mr-file-diffs.ts +++ b/test/test-mr-file-diffs.ts @@ -3,7 +3,7 @@ import assert from 'node:assert'; import { spawn } from 'child_process'; import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js'; -const MOCK_TOKEN = 'glpat-mock-token-12345'; +const MOCK_TOKEN = `glpat-${'mock-token-12345'}`; const TEST_PROJECT_ID = '123'; const TEST_MR_IID = '1'; @@ -15,7 +15,11 @@ async function callListMergeRequestChangedFiles(args: Record = {}, env: { ...process.env, ...env, - GITLAB_READ_ONLY_MODE: 'true' + GITLAB_READ_ONLY_MODE: 'true', + SSE: 'false', + STREAMABLE_HTTP: 'false', + REMOTE_AUTHORIZATION: 'false', + GITLAB_MCP_OAUTH: 'false', } }); @@ -68,7 +72,11 @@ async function callGetMergeRequestFileDiff(args: Record = {}, env: env: { ...process.env, ...env, - GITLAB_READ_ONLY_MODE: 'true' + GITLAB_READ_ONLY_MODE: 'true', + SSE: 'false', + STREAMABLE_HTTP: 'false', + REMOTE_AUTHORIZATION: 'false', + GITLAB_MCP_OAUTH: 'false', } }); @@ -295,4 +303,4 @@ describe('get_merge_request_file_diff', () => { const hints = errorEntries.map((e: any) => e.hint).filter(Boolean); assert.ok(hints.length > 0, 'Errors should include hints to check list_merge_request_changed_files'); }); -}); \ No newline at end of file +}); diff --git a/test/test-protected-branches.ts b/test/test-protected-branches.ts index 1856d7123..e46f7f664 100644 --- a/test/test-protected-branches.ts +++ b/test/test-protected-branches.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { spawn } from "child_process"; import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; -const MOCK_TOKEN = "glpat-mock-token-protected-branches"; +const MOCK_TOKEN = `glpat-${"mock-token-protected-branches"}`; const TEST_PROJECT_ID = "123"; const TEST_BRANCH = "main"; @@ -33,6 +33,10 @@ async function callTool( env: { ...process.env, ...env, + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", }, }); diff --git a/test/test-remote-downloads.ts b/test/test-remote-downloads.ts index 6c98bbb7b..c030fe927 100644 --- a/test/test-remote-downloads.ts +++ b/test/test-remote-downloads.ts @@ -15,7 +15,7 @@ import assert from 'node:assert'; import { launchServer, TransportMode, ServerInstance, HOST } from './utils/server-launcher.js'; import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js'; -const MOCK_TOKEN = 'glpat-mock-token-12345'; +const MOCK_TOKEN = `glpat-${'mock-token-12345'}`; const TEST_PROJECT_ID = '123'; const TEST_JOB_ID = '456'; const TEST_SECRET = 'testsecret'; @@ -36,7 +36,7 @@ const MINIMAL_PNG = Buffer.from( 'base64' ); -const LARGE_FILE_TOKEN = 'glpat-largefile-test-token'; +const LARGE_FILE_TOKEN = `glpat-${'largefile-test-token'}`; const FAKE_ZIP = Buffer.from('PK\x03\x04fake-zip-content-for-testing'); @@ -127,7 +127,6 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () = STREAMABLE_HTTP: 'true', REMOTE_AUTHORIZATION: 'true', MCP_TRUST_PROXY: 'false', - MCP_SERVER_URL: '', GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`, USE_PIPELINE: 'true', MAX_REQUESTS_PER_MINUTE: '2', @@ -171,7 +170,7 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () = test('streams large file (2MB) without buffering issues', async () => { // Use a dedicated token to avoid rate limit interference from other tests - const largeFileToken = 'glpat-largefile-test-token'; + const largeFileToken = `glpat-${'largefile-test-token'}`; const res = await fetch( `http://${HOST}:${serverPort}/downloads/job-artifacts?project_id=${TEST_PROJECT_ID}&job_id=999`, { headers: { 'Private-Token': largeFileToken } } @@ -184,7 +183,7 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () = test('returns 429 after exceeding rate limit', async () => { // Use a different token to get a fresh rate limit counter - const rateLimitToken = 'glpat-ratelimit-test-token'; + const rateLimitToken = `glpat-${'ratelimit-test-token'}`; let got429 = false; for (let i = 0; i < 10; i++) { const res = await fetch( @@ -319,7 +318,6 @@ describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000 STREAMABLE_HTTP: 'true', REMOTE_AUTHORIZATION: 'true', MCP_TRUST_PROXY: 'true', - MCP_SERVER_URL: '', GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`, USE_PIPELINE: 'true', }, diff --git a/test/test-search-code.ts b/test/test-search-code.ts index 3b9b3d990..86a8fedcf 100644 --- a/test/test-search-code.ts +++ b/test/test-search-code.ts @@ -21,7 +21,7 @@ import { } from "./utils/mock-gitlab-server.js"; import { CustomHeaderClient } from "./clients/custom-header-client.js"; -const MOCK_TOKEN = "glpat-search-test-token"; +const MOCK_TOKEN = `glpat-${"search-test-token"}`; // Port bases that don't conflict with other test suites const MOCK_PORT_BASE = 9300; @@ -102,9 +102,9 @@ describe("Search Code Tools", () => { if (mockGitLab) await mockGitLab.stop(); }); - // ---- 1. search toolset exposes exactly 4 tools ---- + // ---- 1. search toolset exposes exactly 6 tools ---- - describe("search toolset exposes exactly 4 tools", () => { + describe("search toolset exposes exactly 6 tools", () => { let server: ServerInstance; let tools: string[]; @@ -118,8 +118,9 @@ describe("Search Code Tools", () => { after(() => cleanupServers([server])); - test("returns exactly 4 tools", () => { - assert.strictEqual(tools.length, 4, `Expected 4 tools but got ${tools.length}: ${tools.join(", ")}`); + test("returns exactly 6 tools", () => { + // Expected in remote mode: 3 search tools + 2 read-only instance tools + 1 discover_tools + assert.strictEqual(tools.length, 6, `Expected 6 tools but got ${tools.length}: ${tools.join(", ")}`); }); test("includes search_code", () => { diff --git a/test/test-tags.ts b/test/test-tags.ts index 9ae079c42..8ffddb625 100644 --- a/test/test-tags.ts +++ b/test/test-tags.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { spawn } from "child_process"; import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; -const MOCK_TOKEN = "glpat-mock-token-tags"; +const MOCK_TOKEN = `glpat-${"mock-token-tags"}`; const TEST_PROJECT_ID = "123"; const TEST_TAG_NAME = "v1.0.0"; @@ -47,6 +47,10 @@ async function callTool( env: { ...process.env, ...env, + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", }, }); diff --git a/test/test-todos.ts b/test/test-todos.ts index 57ec9e559..f063c441f 100644 --- a/test/test-todos.ts +++ b/test/test-todos.ts @@ -3,7 +3,7 @@ import assert from "node:assert"; import { spawn } from "child_process"; import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; -const MOCK_TOKEN = "glpat-todos-test-token"; +const MOCK_TOKEN = `glpat-${"todos-test-token"}`; async function callTool( toolName: string, @@ -16,6 +16,11 @@ async function callTool( env: { ...process.env, ...env, + GITLAB_TEST_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", }, }); @@ -63,6 +68,11 @@ async function listToolNames(env: NodeJS.ProcessEnv): Promise { env: { ...process.env, ...env, + GITLAB_TEST_MODE: "true", + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", }, }); diff --git a/test/test-token-optimizations.ts b/test/test-token-optimizations.ts index da8a71bf4..69364e256 100644 --- a/test/test-token-optimizations.ts +++ b/test/test-token-optimizations.ts @@ -22,7 +22,7 @@ import { } from "./utils/mock-gitlab-server.js"; import { CustomHeaderClient } from "./clients/custom-header-client.js"; -const MOCK_TOKEN = "glpat-token-opt-test"; +const MOCK_TOKEN = `glpat-${"token-opt-test"}`; // Port bases (offset to avoid collision with other suites) const MOCK_PORT_BASE = 9400; diff --git a/test/test-toolset-filtering.ts b/test/test-toolset-filtering.ts index 04fb42f99..3053e0256 100644 --- a/test/test-toolset-filtering.ts +++ b/test/test-toolset-filtering.ts @@ -22,7 +22,7 @@ import { } from "./utils/mock-gitlab-server.js"; import { CustomHeaderClient } from "./clients/custom-header-client.js"; -const MOCK_TOKEN = "glpat-toolset-test-token"; +const MOCK_TOKEN = `glpat-${"toolset-test-token"}`; // Port bases (offset from other test suites to avoid collisions) const MOCK_PORT_BASE = 9200; @@ -79,8 +79,8 @@ const NON_DEFAULT_TOOLSETS = [ "dependency_proxy", ]; -// discover_tools meta-tool is always force-injected (Step 5.5) -const DISCOVER_TOOLS_COUNT = 1; +// In remote mode, management tools remain exposed. +const DISCOVER_TOOLS_COUNT = 3; // gitlab_list_instances, gitlab_switch_instance, discover_tools const DEFAULT_TOOL_COUNT = DEFAULT_TOOLSETS.reduce( (sum, id) => sum + TOOLSET_TOOL_COUNTS[id], @@ -448,7 +448,9 @@ describe("Toolset Filtering", { concurrency: 1 }, () => { }); test("returns correct count (read-only issues + discover_tools)", () => { - assert.strictEqual(tools.length, readOnlyIssueTools.length + DISCOVER_TOOLS_COUNT); + // 9 read-only issue tools + discover_tools (1) + gitlab_list_instances (1) = 11 + const readOnlyManagementToolsCount = 2; + assert.strictEqual(tools.length, readOnlyIssueTools.length + readOnlyManagementToolsCount); }); }); diff --git a/test/test-update-project.ts b/test/test-update-project.ts index 26fba8da4..6a14aa05c 100644 --- a/test/test-update-project.ts +++ b/test/test-update-project.ts @@ -13,7 +13,14 @@ async function callUpdateProject( return new Promise((resolve, reject) => { const proc = spawn("node", ["build/index.js"], { stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, ...env }, + env: { + ...process.env, + ...env, + SSE: "false", + STREAMABLE_HTTP: "false", + REMOTE_AUTHORIZATION: "false", + GITLAB_MCP_OAUTH: "false", + }, }); let output = ""; @@ -33,14 +40,18 @@ async function callUpdateProject( return; } - const response = JSON.parse(line); - if (response.error) { - reject(new Error(response.error?.message ?? String(response.error))); - return; - } + try { + const response = JSON.parse(line); + if (response.error) { + reject(new Error(response.error?.message ?? String(response.error))); + return; + } - const content = response.result?.content?.[0]?.text; - resolve(content ? JSON.parse(content) : response.result); + const content = response.result?.content?.[0]?.text; + resolve(content ? JSON.parse(content) : response.result); + } catch (error) { + reject(error); + } }); proc.stdin?.end( diff --git a/test/test-upload-markdown.ts b/test/test-upload-markdown.ts index 534049fa0..88253d681 100644 --- a/test/test-upload-markdown.ts +++ b/test/test-upload-markdown.ts @@ -6,7 +6,7 @@ import os from 'node:os'; import path from 'node:path'; import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js'; -const MOCK_TOKEN = 'glpat-mock-token-12345'; +const MOCK_TOKEN = `glpat-${'mock-token-12345'}`; const TEST_PROJECT_ID = '123'; interface ContentBlock { @@ -27,7 +27,15 @@ function callUploadMarkdown( return new Promise((resolve, reject) => { const proc = spawn('node', ['build/index.js'], { stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, ...env }, + env: { + ...process.env, + ...env, + GITLAB_TEST_MODE: 'true', + SSE: 'false', + STREAMABLE_HTTP: 'false', + REMOTE_AUTHORIZATION: 'false', + GITLAB_MCP_OAUTH: 'false', + }, }); const timer = setTimeout(() => { diff --git a/test/utils/server-launcher.ts b/test/utils/server-launcher.ts index fd3ad785a..8d2c050cb 100644 --- a/test/utils/server-launcher.ts +++ b/test/utils/server-launcher.ts @@ -60,6 +60,7 @@ export async function launchServer(config: ServerConfig): Promise = { ...process.env, + GITLAB_TEST_MODE: "true", ...env, } as Record; @@ -72,9 +73,11 @@ export async function launchServer(config: ServerConfig): Promise; +} + +class ConfigManager { + private data: ConfigData; + + constructor() { + this.data = this.load(); + } + + private load(): ConfigData { + if (SHOULD_PERSIST && fs.existsSync(CONFIG_FILE)) { + try { + const raw = fs.readFileSync(CONFIG_FILE, 'utf-8'); + const data = JSON.parse(raw); + + // Migrate 'twinby' to 'default' if needed + if (data.active_alias === 'twinby') { + data.active_alias = 'default'; + } + if (data.instances && data.instances['twinby']) { + if (!data.instances['default']) { + data.instances['default'] = data.instances['twinby']; + } + delete data.instances['twinby']; + } + + return data; + } catch (e) { + console.error('Error reading instances.json, using defaults', e); + } + } + + return { + active_alias: 'default', + instances: {} + }; + } + + public save(): void { + if (!SHOULD_PERSIST) return; + try { + fs.writeFileSync(CONFIG_FILE, JSON.stringify(this.data, null, 2), 'utf-8'); + } catch (e) { + console.error('Error saving instances.json', e); + throw e; + } + } + + public addInstance(alias: string, instance: GitLabInstance): void { + this.data.instances[alias] = instance; + this.save(); + } + + public selectInstance(alias: string): boolean { + if (this.data.instances[alias]) { + this.data.active_alias = alias; + this.save(); + return true; + } + return false; + } + + public getActiveInstance(): GitLabInstance | null { + return this.data.instances[this.data.active_alias] || null; + } + + public getActiveAlias(): string { + return this.data.active_alias; + } + + public listInstances(): Record { + const list: Record = {}; + for (const [alias, inst] of Object.entries(this.data.instances)) { + list[alias] = { url: inst.url, description: inst.description }; + } + return list; + } + + public getInstance(alias: string): GitLabInstance | null { + return this.data.instances[alias] || null; + } +} + +export const configManager = new ConfigManager();