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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ docs/plans/
# OpenWolf local context (session notes, anatomy, memory)
.wolf/

# Persistent config for multiple instances
instances.json
Comment thread
coderabbitai[bot] marked this conversation as resolved.
instances.test.json

# MkDocs build artifacts
site/
.venv-docs/
361 changes: 302 additions & 59 deletions index.ts

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3580,6 +3580,37 @@ export const ExecuteGraphQLSchema = z.object({
});
export type ExecuteGraphQLOptions = z.infer<typeof ExecuteGraphQLSchema>;

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<typeof SwitchInstanceSchema>;

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"),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const ListInstancesSchema = z.object({});

// Release schemas
export const GitLabReleaseAssetLinkSchema = z.object({
id: z.coerce.number().optional(),
Expand Down
4 changes: 2 additions & 2 deletions test/client-pool-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -128,4 +128,4 @@ describe('Client Pool Limits', () => {
}
await client3.disconnect();
});
});
});
9 changes: 4 additions & 5 deletions test/dynamic-api-url-allowlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions test/dynamic-api-url-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -364,4 +364,4 @@ describe('Dynamic API URL - Connection Pool', () => {
await client.disconnect();
}
});
});
});
4 changes: 2 additions & 2 deletions test/dynamic-routing-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion test/mcp-oauth-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions test/multi-server-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -205,4 +205,4 @@ describe("Dynamic Client Mode (ENABLE_DYNAMIC_API_URL=true)", () => {
}
await client.disconnect();
});
});
});
2 changes: 1 addition & 1 deletion test/no-proxy-integration-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions test/oauth-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@ async function testOAuthTokenScript(): Promise<void> {
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({
Expand Down Expand Up @@ -382,7 +383,7 @@ async function testEnvironmentVariableConfig(): Promise<void> {
// Test 15: Token data structure validation
async function testTokenDataStructure(): Promise<void> {
const tokenData = {
access_token: 'glpat-test123456789',
access_token: `glpat-${'test123456789'}`,
refresh_token: 'refresh-test123456789',
token_type: 'Bearer',
expires_in: 7200,
Expand Down
2 changes: 1 addition & 1 deletion test/remote-auth-simple-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/stateless/session-id-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions test/stateless/session-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
3 changes: 2 additions & 1 deletion test/streamable-http-static-token-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -20,6 +20,7 @@ function startServer(env: Record<string, string>, 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",
Expand Down
5 changes: 5 additions & 0 deletions test/test-ci-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
});

Expand Down
12 changes: 11 additions & 1 deletion test/test-ci-lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
},
});

Expand Down Expand Up @@ -65,6 +70,11 @@ async function listToolNames(env: NodeJS.ProcessEnv): Promise<string[]> {
env: {
...process.env,
...env,
GITLAB_TEST_MODE: "true",
SSE: "false",
STREAMABLE_HTTP: "false",
REMOTE_AUTHORIZATION: "false",
GITLAB_MCP_OAUTH: "false",
},
});

Expand Down
28 changes: 25 additions & 3 deletions test/test-ci-variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -49,7 +49,15 @@ async function callTool(
return new Promise<any>((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 = "";
Expand Down Expand Up @@ -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
},
});
Expand Down Expand Up @@ -407,7 +420,16 @@ describe("CI/CD variable tools", () => {
return new Promise<void>((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 = "";
Expand Down
10 changes: 9 additions & 1 deletion test/test-create-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";
Expand Down
Loading
Loading