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
35 changes: 34 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import {
CreateProjectMilestoneSchema,
CreateRepositoryOptionsSchema,
CreateRepositorySchema,
CreateGroupSchema,
CreateWikiPageSchema,
CreateGroupWikiPageSchema,
DeleteDraftNoteSchema,
Expand Down Expand Up @@ -184,6 +185,7 @@ import {
GitLabDraftNoteSchema,
type GitLabFork,
GitLabForkSchema,
GitLabGroupSchema,
type GitLabIssue,
type GitLabIssueLink,
GitLabIssueLinkSchema,
Expand Down Expand Up @@ -8455,7 +8457,7 @@ async function handleToolCall(params: any) {

case "create_repository": {
if (GITLAB_PROJECT_ID) {
throw new Error("Direct project ID is set. So fork_repository is not allowed");
throw new Error("Direct project ID is set. So create_repository is not allowed");
}
const args = CreateRepositorySchema.parse(params.arguments);
const repository = await createRepository(args);
Expand All @@ -8464,6 +8466,37 @@ async function handleToolCall(params: any) {
};
}

case "create_group": {
if (GITLAB_PROJECT_ID) {
throw new Error("Direct project ID is set. So create_group is not allowed");
}
const args = CreateGroupSchema.parse(params.arguments);
const url = new URL(`${getEffectiveApiUrl()}/groups`);
Comment thread
Killusions marked this conversation as resolved.

const body: Record<string, unknown> = {
name: args.name,
path: args.path,
};
if (args.description) body.description = args.description;
if (args.visibility) body.visibility = args.visibility;
if (args.parent_id) body.parent_id = args.parent_id;

const response = await fetch(url.toString(), {
...getFetchConfig(),
method: "POST",
headers: { ...getFetchConfig().headers, "Content-Type": "application/json" },
body: JSON.stringify(body),
});

await handleGitLabError(response);
const data = await response.json();
const group = GitLabGroupSchema.parse(data);

return {
content: [{ type: "text", text: JSON.stringify(group, null, 2) }],
};
}

case "get_file_contents": {
const args = GetFileContentsSchema.parse(params.arguments);
const contents = await getFileContents(args.project_id, args.file_path, args.ref);
Expand Down
35 changes: 35 additions & 0 deletions schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,41 @@ export const GitLabUsersResponseSchema = z.record(
.nullable()
);

// Group related schemas
export const CreateGroupSchema = z.object({
name: z.string().describe("The name of the group"),
path: z.string().describe("The path of the group"),
description: z.string().optional().describe("The group's description"),
visibility: z.enum(["private", "internal", "public"]).optional().describe("The group's visibility level"),
parent_id: z.coerce.number().optional().describe("The parent group ID for creating a subgroup"),
});

export const GitLabGroupSchema = z.object({
id: z.coerce.string(),
name: z.string(),
path: z.string(),
description: z.string().nullable(),
visibility: z.string().optional(),
share_with_group_lock: z.boolean().optional(),
require_two_factor_authentication: z.boolean().optional(),
two_factor_grace_period: z.number().optional(),
project_creation_level: z.string().optional(),
auto_devops_enabled: z.boolean().nullable().optional(),
subgroup_creation_level: z.string().optional(),
emails_disabled: z.boolean().nullable().optional(),
mentions_disabled: z.boolean().nullable().optional(),
lfs_enabled: z.boolean().nullable().optional(),
avatar_url: z.string().nullable().optional(),
web_url: z.string(),
request_access_enabled: z.boolean().nullable().optional(),
full_name: z.string(),
full_path: z.string(),
file_template_project_id: z.number().nullable().optional(),
parent_id: z.coerce.string().nullable().optional(),
created_at: z.string().optional(),
statistics: z.any().optional(),
});

// Namespace related schemas

// Base schema for project-related operations
Expand Down
135 changes: 109 additions & 26 deletions test/test-geteffectiveprojectid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@

import { describe, test, before, after } from 'node:test';
import assert from 'node:assert';
import {
launchServer,
findAvailablePort,
cleanupServers,
ServerInstance,
import {
launchServer,
findAvailablePort,
cleanupServers,
ServerInstance,
TransportMode,
HOST
HOST
} from './utils/server-launcher.js';
import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
import { StreamableHTTPTestClient } from './clients/streamable-http-client.js';
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';
Expand Down Expand Up @@ -57,17 +58,18 @@ describe('getEffectiveProjectId - No GITLAB_ALLOWED_PROJECT_IDS', () => {
timeout: 5000,
env: {
STREAMABLE_HTTP: 'true',
REMOTE_AUTHORIZATION: 'true',
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
GITLAB_READ_ONLY_MODE: 'true',
}
});
servers.push(server);
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;

client = new StreamableHTTPTestClient();
await client.connect(mcpUrl);

console.log(`Mock GitLab: ${mockGitLabUrl}`);
console.log(`MCP Server: ${mcpUrl}`);
console.log(`Default Project: ${DEFAULT_PROJECT_ID}`);
Expand All @@ -88,12 +90,12 @@ describe('getEffectiveProjectId - No GITLAB_ALLOWED_PROJECT_IDS', () => {
const result = await client.callTool('get_project', {
project_id: ''
});

assert.ok(result.content, 'Should have content');
const content = result.content[0];
assert.ok('text' in content, 'Content should have text');
const project = JSON.parse(content.text);

// The mock server should receive a request for the default project
assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use GITLAB_PROJECT_ID as default');
console.log(` ✓ Used default project ${DEFAULT_PROJECT_ID} when no project_id provided`);
Expand All @@ -104,12 +106,12 @@ describe('getEffectiveProjectId - No GITLAB_ALLOWED_PROJECT_IDS', () => {
const result = await client.callTool('get_project', {
project_id: OTHER_PROJECT_ID
});

assert.ok(result.content, 'Should have content');
const content = result.content[0];
assert.ok('text' in content, 'Content should have text');
const project = JSON.parse(content.text);

// Should use the passed project_id, not GITLAB_PROJECT_ID
assert.strictEqual(project.id.toString(), OTHER_PROJECT_ID, 'Should use passed project_id');
console.log(` ✓ Used passed project_id ${OTHER_PROJECT_ID} instead of default ${DEFAULT_PROJECT_ID}`);
Expand Down Expand Up @@ -139,7 +141,7 @@ describe('getEffectiveProjectId - With single GITLAB_ALLOWED_PROJECT_IDS', () =>
port: mcpPort,
timeout: 5000,
env: {
STREAMABLE_HTTP: 'true',
REMOTE_AUTHORIZATION: 'true',
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
GITLAB_ALLOWED_PROJECT_IDS: DEFAULT_PROJECT_ID,
Expand All @@ -148,10 +150,10 @@ describe('getEffectiveProjectId - With single GITLAB_ALLOWED_PROJECT_IDS', () =>
});
servers.push(server);
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;

client = new StreamableHTTPTestClient();
await client.connect(mcpUrl);

console.log(`Mock GitLab: ${mockGitLabUrl}`);
console.log(`MCP Server: ${mcpUrl}`);
console.log(`Allowed Project: ${DEFAULT_PROJECT_ID}`);
Expand All @@ -171,12 +173,12 @@ describe('getEffectiveProjectId - With single GITLAB_ALLOWED_PROJECT_IDS', () =>
const result = await client.callTool('get_project', {
project_id: ''
});

assert.ok(result.content, 'Should have content');
const content = result.content[0];
assert.ok('text' in content, 'Content should have text');
const project = JSON.parse(content.text);

assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use allowed project as default');
console.log(` ✓ Used allowed project ${DEFAULT_PROJECT_ID} as default`);
});
Expand Down Expand Up @@ -218,18 +220,17 @@ describe('getEffectiveProjectId - With multiple GITLAB_ALLOWED_PROJECT_IDS', ()
port: mcpPort,
timeout: 5000,
env: {
STREAMABLE_HTTP: 'true',
REMOTE_AUTHORIZATION: 'true',
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
GITLAB_ALLOWED_PROJECT_IDS: `${DEFAULT_PROJECT_ID},${OTHER_PROJECT_ID}`,
GITLAB_READ_ONLY_MODE: 'true',
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
}
});
servers.push(server);
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;

client = new StreamableHTTPTestClient();
await client.connect(mcpUrl);

console.log(`Mock GitLab: ${mockGitLabUrl}`);
console.log(`MCP Server: ${mcpUrl}`);
console.log(`Allowed Projects: ${DEFAULT_PROJECT_ID},${OTHER_PROJECT_ID}`);
Expand Down Expand Up @@ -262,12 +263,12 @@ describe('getEffectiveProjectId - With multiple GITLAB_ALLOWED_PROJECT_IDS', ()
const result = await client.callTool('get_project', {
project_id: DEFAULT_PROJECT_ID
});

assert.ok(result.content, 'Should have content');
const content = result.content[0];
assert.ok('text' in content, 'Content should have text');
const project = JSON.parse(content.text);

assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should allow first project');
console.log(` ✓ Allowed access to first project ${DEFAULT_PROJECT_ID}`);
});
Expand All @@ -276,15 +277,97 @@ describe('getEffectiveProjectId - With multiple GITLAB_ALLOWED_PROJECT_IDS', ()
const result = await client.callTool('get_project', {
project_id: OTHER_PROJECT_ID
});

assert.ok(result.content, 'Should have content');
const content = result.content[0];
assert.ok('text' in content, 'Content should have text');
const project = JSON.parse(content.text);

assert.strictEqual(project.id.toString(), OTHER_PROJECT_ID, 'Should allow second project');
console.log(` ✓ Allowed access to second project ${OTHER_PROJECT_ID}`);
});
});

describe('GITLAB_PROJECT_ID guards repository and group mutators', () => {
let mcpUrl: string;
let mockGitLab: MockGitLabServer;
let servers: ServerInstance[] = [];
let client: CustomHeaderClient;

before(async () => {
const mockPort = await findMockServerPort(9400);
mockGitLab = new MockGitLabServer({
port: mockPort,
validTokens: [MOCK_TOKEN]
});
await mockGitLab.start();
const mockGitLabUrl = mockGitLab.getUrl();

const mcpPort = await findAvailablePort(3400);
const server = await launchServer({
mode: TransportMode.STREAMABLE_HTTP,
port: mcpPort,
timeout: 5000,
env: {
REMOTE_AUTHORIZATION: 'true',
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
GITLAB_PROJECT_ID: DEFAULT_PROJECT_ID,
GITLAB_READ_ONLY_MODE: 'true',
}
});
servers.push(server);
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;

client = new CustomHeaderClient({
authorization: `Bearer ${MOCK_TOKEN}`,
});
await client.connect(mcpUrl);
});

after(async () => {
if (client) await client.disconnect();
cleanupServers(servers);
if (mockGitLab) await mockGitLab.stop();
});

test('should reject create_repository when GITLAB_PROJECT_ID is set', async () => {
try {
await client.callTool('create_repository', { name: 'test-repo' });
assert.fail('Should have rejected create_repository');
} catch (error) {
assert.ok(error instanceof Error);
assert.ok(error.message.includes('create_repository is not allowed'), 'Should mention create_repository');
}
});

test('should reject fork_repository when GITLAB_PROJECT_ID is set', async () => {
try {
await client.callTool('fork_repository', { project_id: '999' });
assert.fail('Should have rejected fork_repository');
} catch (error) {
assert.ok(error instanceof Error);
assert.ok(error.message.includes('fork_repository is not allowed'), 'Should mention fork_repository');
}
});

test('should reject create_group when GITLAB_PROJECT_ID is set', async () => {
try {
await client.callTool('create_group', { name: 'test-group', path: 'test-group' });
assert.fail('Should have rejected create_group');
} catch (error) {
assert.ok(error instanceof Error);
assert.ok(error.message.includes('create_group is not allowed'), 'Should mention create_group');
}
});

test('should allow get_project (non-mutator) when GITLAB_PROJECT_ID is set', async () => {
const result = await client.callTool('get_project', { project_id: '' });
assert.ok(result.content, 'Should have content');
const content = result.content[0];
assert.ok('text' in content, 'Content should have text');
const project = JSON.parse(content.text);
assert.strictEqual(project.id.toString(), DEFAULT_PROJECT_ID, 'Should use default project');
});
});

}); // end wrapper describe
3 changes: 3 additions & 0 deletions test/test-toolset-filtering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const TOOLSET_TOOL_COUNTS: Record<string, number> = {
search: 3,
workitems: 18,
webhooks: 3,
groups: 1,
};

const LEGACY_PIPELINE_TOOL_COUNT = TOOLSET_TOOL_COUNTS.pipelines + TOOLSET_TOOL_COUNTS.ci;
Expand All @@ -59,6 +60,7 @@ const DEFAULT_TOOLSETS = [
"labels",
"ci",
"users",
"groups",
];

const NON_DEFAULT_TOOLSETS = [
Expand Down Expand Up @@ -102,6 +104,7 @@ const TOOLSET_SAMPLE_TOOLS: Record<string, string[]> = {
users: ["get_users", "upload_markdown", "download_attachment"],
search: ["search_code", "search_project_code", "search_group_code"],
webhooks: ["list_webhooks", "list_webhook_events", "get_webhook_event"],
groups: ["create_group"],
};

// --- Helpers ---
Expand Down
Loading
Loading