Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ docs/plans/
.claude/worktrees/
# OpenWolf local context (session notes, anatomy, memory)
.wolf/

# Persistent config for multiple instances
instances.json
Comment thread
coderabbitai[bot] marked this conversation as resolved.
322 changes: 265 additions & 57 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 @@ -71,6 +71,7 @@
"@modelcontextprotocol/sdk": "^1.24.2",
"@types/node-fetch": "^2.6.12",
"diff": "^9.0.0",
"dotenv": "^17.4.2",
"express": "^5.1.0",
"fetch-cookie": "^3.1.0",
"form-data": "^4.0.0",
Expand All @@ -93,7 +94,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
20 changes: 20 additions & 0 deletions schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3350,6 +3350,26 @@ 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>;

export const AddInstanceSchema = z.object({
alias: z.string().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: z.string().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
2 changes: 1 addition & 1 deletion test/test-download-attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ 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' },
});

const timer = setTimeout(() => {
Expand Down
193 changes: 193 additions & 0 deletions test/test-instance-management.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
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 running = new Set<ReturnType<typeof spawn>>();

function startServer(env: Record<string, string>, port: number) {
const child = spawn("node", [SERVER_PATH], {
env: {
...process.env,
GITLAB_API_URL: "https://gitlab.example.com",
HOST,
PORT: String(port),
STREAMABLE_HTTP: "true",
REMOTE_AUTHORIZATION: "true",
GITLAB_MCP_OAUTH: "false",
GITLAB_PERSONAL_ACCESS_TOKEN: "glpat-master-token",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 glpat-this-is-a-long-enough-token-12345"
},
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: "glpat-secret-token-long-enough-12345",
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 glpat-this-is-a-long-enough-token-12345"
},
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 glpat-this-is-a-long-enough-token-12345"
},
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: "glpat-custom-token-long-enough-12345"
});

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/);
});
});

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);
}
});
3 changes: 2 additions & 1 deletion test/test-search-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ 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(", ")}`);
// Expected: 3 search tools + 4 instance tools + 1 discover_tools
assert.strictEqual(tools.length, 8, `Expected 8 tools but got ${tools.length}: ${tools.join(", ")}`);
});

test("includes search_code", () => {
Expand Down
8 changes: 5 additions & 3 deletions test/test-toolset-filtering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ const NON_DEFAULT_TOOLSETS = [
"dependency_proxy",
];

// discover_tools meta-tool is always force-injected (Step 5.5)
const DISCOVER_TOOLS_COUNT = 1;
// Instance management tools (Step 5.5) + discover_tools meta-tool are always force-injected
const DISCOVER_TOOLS_COUNT = 5; // gitlab_list_instances, gitlab_add_instance, gitlab_select_instance, gitlab_switch_instance, discover_tools

const DEFAULT_TOOL_COUNT = DEFAULT_TOOLSETS.reduce(
(sum, id) => sum + TOOLSET_TOOL_COUNTS[id],
Expand Down Expand Up @@ -447,7 +447,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) + gitlab_switch_instance (1) = 12
const readOnlyManagementToolsCount = 3;
assert.strictEqual(tools.length, readOnlyIssueTools.length + readOnlyManagementToolsCount);
});
});

Expand Down
1 change: 1 addition & 0 deletions test/utils/server-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export async function launchServer(config: ServerConfig): Promise<ServerInstance

const serverEnv: Record<string, string> = {
...process.env,
GITLAB_TEST_MODE: "true",
...env,
} as Record<string, string>;

Expand Down
30 changes: 30 additions & 0 deletions tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ import {
SearchGroupCodeSchema,
SearchProjectCodeSchema,
SearchRepositoriesSchema,
SelectInstanceSchema,
ListInstancesSchema,
AddInstanceSchema,
SwitchInstanceSchema,
UnapproveMergeRequestSchema,
UpdateDraftNoteSchema,
UpdateGroupWikiPageSchema,
Expand Down Expand Up @@ -217,6 +221,26 @@ const IS_REMOTE = SSE || STREAMABLE_HTTP;

// Define all available tools
export const allTools = [
{
name: "gitlab_list_instances",
description: "List all saved GitLab instance aliases and see which one is currently active.",
inputSchema: toJSONSchema(ListInstancesSchema),
},
{
name: "gitlab_add_instance",
description: "Save a new GitLab instance configuration (URL and Token) with a friendly alias for future switching.",
inputSchema: toJSONSchema(AddInstanceSchema),
},
{
name: "gitlab_select_instance",
description: "Switch to a saved GitLab instance by its alias. This change is persistent across server restarts.",
inputSchema: toJSONSchema(SelectInstanceSchema),
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
{
name: "gitlab_switch_instance",
description: "Switch to a different GitLab instance for the current session. Use this to switch between GitLab Cloud (https://gitlab.com/api/v4) and Self-Hosted instances. All subsequent tool calls in this chat session will use the new instance and token until switched again. If apiUrl and token are omitted, the server will try to load Cloud credentials from its own internal configuration (e.g. .env).",
inputSchema: toJSONSchema(SwitchInstanceSchema),
},
{
name: "merge_merge_request",
description: "Merge a merge request",
Expand Down Expand Up @@ -1261,6 +1285,8 @@ export const allTools = [

// Define which tools are read-only
export const readOnlyTools = new Set([
"gitlab_list_instances",
"gitlab_switch_instance",
"discover_tools",
"health_check",
"search_repositories",
Expand Down Expand Up @@ -1603,6 +1629,10 @@ export const TOOLSET_DEFINITIONS: readonly ToolsetDefinition[] = [
id: "projects",
isDefault: true,
tools: new Set([
"gitlab_list_instances",
"gitlab_add_instance",
"gitlab_select_instance",
"gitlab_switch_instance",
"get_project",
"list_projects",
"list_project_members",
Expand Down
Loading