diff --git a/index.ts b/index.ts index 98c48df3..c9703b2f 100644 --- a/index.ts +++ b/index.ts @@ -117,6 +117,7 @@ import { CreateProjectMilestoneSchema, CreateRepositoryOptionsSchema, CreateRepositorySchema, + CreateGroupSchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteDraftNoteSchema, @@ -184,6 +185,7 @@ import { GitLabDraftNoteSchema, type GitLabFork, GitLabForkSchema, + GitLabGroupSchema, type GitLabIssue, type GitLabIssueLink, GitLabIssueLinkSchema, @@ -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); @@ -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`); + + const body: Record = { + 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); diff --git a/schemas.ts b/schemas.ts index 836d73f4..9764fc44 100644 --- a/schemas.ts +++ b/schemas.ts @@ -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 diff --git a/test/test-geteffectiveprojectid.ts b/test/test-geteffectiveprojectid.ts index c64dfde3..9eb367b0 100644 --- a/test/test-geteffectiveprojectid.ts +++ b/test/test-geteffectiveprojectid.ts @@ -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'; @@ -57,6 +58,7 @@ 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', @@ -64,10 +66,10 @@ describe('getEffectiveProjectId - No 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(`Default Project: ${DEFAULT_PROJECT_ID}`); @@ -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`); @@ -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}`); @@ -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, @@ -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}`); @@ -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`); }); @@ -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}`); @@ -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}`); }); @@ -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 diff --git a/test/test-toolset-filtering.ts b/test/test-toolset-filtering.ts index 5a361b1d..1742490c 100644 --- a/test/test-toolset-filtering.ts +++ b/test/test-toolset-filtering.ts @@ -46,6 +46,7 @@ const TOOLSET_TOOL_COUNTS: Record = { search: 3, workitems: 18, webhooks: 3, + groups: 1, }; const LEGACY_PIPELINE_TOOL_COUNT = TOOLSET_TOOL_COUNTS.pipelines + TOOLSET_TOOL_COUNTS.ci; @@ -59,6 +60,7 @@ const DEFAULT_TOOLSETS = [ "labels", "ci", "users", + "groups", ]; const NON_DEFAULT_TOOLSETS = [ @@ -102,6 +104,7 @@ const TOOLSET_SAMPLE_TOOLS: Record = { 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 --- diff --git a/tools/registry.ts b/tools/registry.ts index 997cd833..404ddfbf 100644 --- a/tools/registry.ts +++ b/tools/registry.ts @@ -14,6 +14,7 @@ import { ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, + CreateGroupSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, @@ -228,6 +229,11 @@ export const allTools = [ description: "Create a new GitLab project", inputSchema: toJSONSchema(CreateRepositorySchema), }, + { + name: "create_group", + description: "Create new group or subgroup", + inputSchema: toJSONSchema(CreateGroupSchema), + }, { name: "get_file_contents", description: "Get contents of a file or directory from a GitLab project", @@ -537,12 +543,12 @@ export const allTools = [ }, { name: "list_namespaces", - description: "List all namespaces available to the current user", + description: "List all namespaces (users and groups) available to the current user. Filter by kind='group' for groups only.", inputSchema: toJSONSchema(ListNamespacesSchema), }, { name: "get_namespace", - description: "Get details of a namespace by ID or path", + description: "Get details of a namespace (user or group) by ID or path. Groups are namespaces with kind='group'.", inputSchema: toJSONSchema(GetNamespaceSchema), }, { @@ -1252,6 +1258,7 @@ export type ToolsetId = | "projects" | "labels" | "ci" + | "groups" | "pipelines" | "milestones" | "wiki" @@ -1400,6 +1407,11 @@ export const TOOLSET_DEFINITIONS: readonly ToolsetDefinition[] = [ isDefault: true, tools: new Set(["validate_ci_lint", "validate_project_ci_lint"]), }, + { + id: "groups", + isDefault: true, + tools: new Set(["create_group"]), + }, { id: "pipelines", isDefault: false,