diff --git a/index.ts b/index.ts index 8317ae2..e107803 100644 --- a/index.ts +++ b/index.ts @@ -24,6 +24,9 @@ import { GitLabSearchResponseSchema, GitLabTreeSchema, GitLabCommitSchema, + GitLabNamespaceSchema, + GitLabNamespaceExistsResponseSchema, + GitLabProjectSchema, CreateRepositoryOptionsSchema, CreateIssueOptionsSchema, CreateMergeRequestOptionsSchema, @@ -50,6 +53,11 @@ import { GetIssueLinkSchema, CreateIssueLinkSchema, DeleteIssueLinkSchema, + ListNamespacesSchema, + GetNamespaceSchema, + VerifyNamespaceSchema, + GetProjectSchema, + ListProjectsSchema, CreateNoteSchema, type GitLabFork, type GitLabReference, @@ -64,6 +72,9 @@ import { type FileOperation, type GitLabMergeRequestDiff, type GitLabIssueLink, + type GitLabNamespace, + type GitLabNamespaceExistsResponse, + type GitLabProject, } from "./schemas.js"; /** @@ -1057,6 +1068,158 @@ async function createNote( return await response.json(); } +/** + * List all namespaces + * 사용 가능한 모든 네임스페이스 목록 조회 + * + * @param {Object} options - Options for listing namespaces + * @param {string} [options.search] - Search query to filter namespaces + * @param {boolean} [options.owned_only] - Only return namespaces owned by the authenticated user + * @param {boolean} [options.top_level_only] - Only return top-level namespaces + * @returns {Promise} List of namespaces + */ +async function listNamespaces(options: { + search?: string; + owned_only?: boolean; + top_level_only?: boolean; +}): Promise { + const url = new URL(`${GITLAB_API_URL}/namespaces`); + + if (options.search) { + url.searchParams.append("search", options.search); + } + + if (options.owned_only) { + url.searchParams.append("owned_only", "true"); + } + + if (options.top_level_only) { + url.searchParams.append("top_level_only", "true"); + } + + const response = await fetch(url.toString(), { + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabNamespaceSchema).parse(data); +} + +/** + * Get details on a namespace + * 네임스페이스 상세 정보 조회 + * + * @param {string} id - The ID or URL-encoded path of the namespace + * @returns {Promise} The namespace details + */ +async function getNamespace(id: string): Promise { + const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(id)}`); + + const response = await fetch(url.toString(), { + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabNamespaceSchema.parse(data); +} + +/** + * Verify if a namespace exists + * 네임스페이스 존재 여부 확인 + * + * @param {string} namespacePath - The path of the namespace to check + * @param {number} [parentId] - The ID of the parent namespace + * @returns {Promise} The verification result + */ +async function verifyNamespaceExistence( + namespacePath: string, + parentId?: number +): Promise { + const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(namespacePath)}/exists`); + + if (parentId) { + url.searchParams.append("parent_id", parentId.toString()); + } + + const response = await fetch(url.toString(), { + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabNamespaceExistsResponseSchema.parse(data); +} + +/** + * Get a single project + * 단일 프로젝트 조회 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {Object} options - Options for getting project details + * @param {boolean} [options.license] - Include project license data + * @param {boolean} [options.statistics] - Include project statistics + * @param {boolean} [options.with_custom_attributes] - Include custom attributes in response + * @returns {Promise} Project details + */ +async function getProject( + projectId: string, + options: { + license?: boolean; + statistics?: boolean; + with_custom_attributes?: boolean; + } = {} +): Promise { + const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}`); + + if (options.license) { + url.searchParams.append("license", "true"); + } + + if (options.statistics) { + url.searchParams.append("statistics", "true"); + } + + if (options.with_custom_attributes) { + url.searchParams.append("with_custom_attributes", "true"); + } + + const response = await fetch(url.toString(), { + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabRepositorySchema.parse(data); +} + +/** + * List projects + * 프로젝트 목록 조회 + * + * @param {Object} options - Options for listing projects + * @returns {Promise} List of projects + */ +async function listProjects(options: z.infer = {}): Promise { + const url = new URL(`${GITLAB_API_URL}/projects`); + + // Add all the query parameters from options + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.append(key, value.toString()); + } + }); + + const response = await fetch(url.toString(), { + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabRepositorySchema).parse(data); +} + server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ @@ -1168,6 +1331,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { description: "Delete an issue link", inputSchema: zodToJsonSchema(DeleteIssueLinkSchema), }, + { + name: "list_namespaces", + description: "List all namespaces available to the current user", + inputSchema: zodToJsonSchema(ListNamespacesSchema), + }, + { + name: "get_namespace", + description: "Get details of a namespace by ID or path", + inputSchema: zodToJsonSchema(GetNamespaceSchema), + }, + { + name: "verify_namespace", + description: "Verify if a namespace path exists", + inputSchema: zodToJsonSchema(VerifyNamespaceSchema), + }, + { + name: "get_project", + description: "Get details of a specific project", + inputSchema: zodToJsonSchema(GetProjectSchema), + }, + { + name: "list_projects", + description: "List projects accessible by the current user", + inputSchema: zodToJsonSchema(ListProjectsSchema), + }, ], }; }); @@ -1339,6 +1527,111 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { }; } + case "list_namespaces": { + const args = ListNamespacesSchema.parse(request.params.arguments); + const url = new URL(`${GITLAB_API_URL}/namespaces`); + + if (args.search) { + url.searchParams.append("search", args.search); + } + if (args.page) { + url.searchParams.append("page", args.page.toString()); + } + if (args.per_page) { + url.searchParams.append("per_page", args.per_page.toString()); + } + if (args.owned) { + url.searchParams.append("owned", args.owned.toString()); + } + + const response = await fetch(url.toString(), { + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + const namespaces = z.array(GitLabNamespaceSchema).parse(data); + + return { + content: [{ type: "text", text: JSON.stringify(namespaces, null, 2) }], + }; + } + + case "get_namespace": { + const args = GetNamespaceSchema.parse(request.params.arguments); + const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(args.namespace_id)}`); + + const response = await fetch(url.toString(), { + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + const namespace = GitLabNamespaceSchema.parse(data); + + return { + content: [{ type: "text", text: JSON.stringify(namespace, null, 2) }], + }; + } + + case "verify_namespace": { + const args = VerifyNamespaceSchema.parse(request.params.arguments); + const url = new URL(`${GITLAB_API_URL}/namespaces/${encodeURIComponent(args.path)}/exists`); + + const response = await fetch(url.toString(), { + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + const namespaceExists = GitLabNamespaceExistsResponseSchema.parse(data); + + return { + content: [{ type: "text", text: JSON.stringify(namespaceExists, null, 2) }], + }; + } + + case "get_project": { + const args = GetProjectSchema.parse(request.params.arguments); + const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(args.project_id)}`); + + const response = await fetch(url.toString(), { + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + const project = GitLabProjectSchema.parse(data); + + return { + content: [{ type: "text", text: JSON.stringify(project, null, 2) }], + }; + } + + case "list_projects": { + const args = ListProjectsSchema.parse(request.params.arguments); + const url = new URL(`${GITLAB_API_URL}/projects`); + + // Add query parameters for filtering + Object.entries(args).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.append(key, value.toString()); + } + }); + + const response = await fetch(url.toString(), { + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + const projects = z.array(GitLabProjectSchema).parse(data); + + return { + content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], + }; + } + case "create_note": { const args = CreateNoteSchema.parse(request.params.arguments); const { project_id, noteable_type, noteable_iid, body } = args; diff --git a/package.json b/package.json index d5cd55b..e3271df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zereight/mcp-gitlab", - "version": "1.0.16", + "version": "1.0.17", "description": "MCP server for using the GitLab API", "license": "MIT", "author": "zereight", diff --git a/schemas.ts b/schemas.ts index cf576f4..2727644 100644 --- a/schemas.ts +++ b/schemas.ts @@ -7,6 +7,33 @@ export const GitLabAuthorSchema = z.object({ date: z.string(), }); +// Namespace related schemas +export const GitLabNamespaceSchema = z.object({ + id: z.number(), + name: z.string(), + path: z.string(), + kind: z.enum(["user", "group"]), + full_path: z.string(), + parent_id: z.number().nullable(), + avatar_url: z.string().nullable(), + web_url: z.string(), + members_count_with_descendants: z.number().optional(), + billable_members_count: z.number().optional(), + max_seats_used: z.number().optional(), + seats_in_use: z.number().optional(), + plan: z.string().optional(), + end_date: z.string().nullable().optional(), + trial_ends_on: z.string().nullable().optional(), + trial: z.boolean().optional(), + root_repository_size: z.number().optional(), + projects_count: z.number().optional(), +}); + +export const GitLabNamespaceExistsResponseSchema = z.object({ + exists: z.boolean(), + suggests: z.array(z.string()).optional(), +}); + // Repository related schemas export const GitLabOwnerSchema = z.object({ username: z.string(), // Changed from login to match GitLab API @@ -31,8 +58,53 @@ export const GitLabRepositorySchema = z.object({ created_at: z.string().optional(), last_activity_at: z.string().optional(), default_branch: z.string().optional(), + namespace: z.object({ + id: z.number(), + name: z.string(), + path: z.string(), + kind: z.string(), + full_path: z.string(), + avatar_url: z.string().nullable().optional(), + web_url: z.string().optional(), + }).optional(), + readme_url: z.string().optional().nullable(), + topics: z.array(z.string()).optional(), + tag_list: z.array(z.string()).optional(), // deprecated but still present + open_issues_count: z.number().optional(), + archived: z.boolean().optional(), + forks_count: z.number().optional(), + star_count: z.number().optional(), + permissions: z.object({ + project_access: z.object({ + access_level: z.number(), + notification_level: z.number().optional(), + }).optional().nullable(), + group_access: z.object({ + access_level: z.number(), + notification_level: z.number().optional(), + }).optional().nullable(), + }).optional(), + container_registry_enabled: z.boolean().optional(), + container_registry_access_level: z.string().optional(), + issues_enabled: z.boolean().optional(), + merge_requests_enabled: z.boolean().optional(), + wiki_enabled: z.boolean().optional(), + jobs_enabled: z.boolean().optional(), + snippets_enabled: z.boolean().optional(), + can_create_merge_request_in: z.boolean().optional(), + resolve_outdated_diff_discussions: z.boolean().optional(), + shared_runners_enabled: z.boolean().optional(), + shared_with_groups: z.array(z.object({ + group_id: z.number(), + group_name: z.string(), + group_full_path: z.string(), + group_access_level: z.number(), + })).optional(), }); +// Project schema (extended from repository schema) +export const GitLabProjectSchema = GitLabRepositorySchema; + // File content schemas export const GitLabFileContentSchema = z.object({ file_name: z.string(), // Changed from name to match GitLab API @@ -151,22 +223,6 @@ export const GitLabSearchResponseSchema = z.object({ items: z.array(GitLabRepositorySchema), }); -// Fork related schemas -export const GitLabForkParentSchema = z.object({ - name: z.string(), - path_with_namespace: z.string(), // Changed from full_name to match GitLab API - owner: z.object({ - username: z.string(), // Changed from login to match GitLab API - id: z.number(), - avatar_url: z.string(), - }).optional(), // Made optional to handle cases where GitLab API doesn't include it - web_url: z.string(), // Changed from html_url to match GitLab API -}); - -export const GitLabForkSchema = GitLabRepositorySchema.extend({ - forked_from_project: GitLabForkParentSchema.optional(), // Made optional to handle cases where GitLab API doesn't include it -}); - // Issue related schemas export const GitLabLabelSchema = z.object({ id: z.number(), @@ -224,52 +280,20 @@ export const GitLabIssueSchema = z.object({ weight: z.number().nullable().optional(), }); -// Issues API operation schemas -export const ListIssuesSchema = z.object({ - project_id: z.string().describe("Project ID or URL-encoded path"), - assignee_id: z.number().optional().describe("Return issues assigned to the given user ID"), - assignee_username: z.string().optional().describe("Return issues assigned to the given username"), - author_id: z.number().optional().describe("Return issues created by the given user ID"), - author_username: z.string().optional().describe("Return issues created by the given username"), - confidential: z.boolean().optional().describe("Filter confidential or public issues"), - created_after: z.string().optional().describe("Return issues created after the given time"), - created_before: z.string().optional().describe("Return issues created before the given time"), - due_date: z.string().optional().describe("Return issues that have the due date"), - label_name: z.array(z.string()).optional().describe("Array of label names"), - milestone: z.string().optional().describe("Milestone title"), - scope: z.enum(['created-by-me', 'assigned-to-me', 'all']).optional().describe("Return issues from a specific scope"), - search: z.string().optional().describe("Search for specific terms"), - state: z.enum(['opened', 'closed', 'all']).optional().describe("Return issues with a specific state"), - updated_after: z.string().optional().describe("Return issues updated after the given time"), - updated_before: z.string().optional().describe("Return issues updated before the given time"), - with_labels_details: z.boolean().optional().describe("Return more details for each label"), - page: z.number().optional().describe("Page number for pagination"), - per_page: z.number().optional().describe("Number of items per page"), +// Fork related schemas +export const GitLabForkParentSchema = z.object({ + name: z.string(), + path_with_namespace: z.string(), // Changed from full_name to match GitLab API + owner: z.object({ + username: z.string(), // Changed from login to match GitLab API + id: z.number(), + avatar_url: z.string(), + }).optional(), // Made optional to handle cases where GitLab API doesn't include it + web_url: z.string(), // Changed from html_url to match GitLab API }); -export const GetIssueSchema = z.object({ - project_id: z.string().describe("Project ID or URL-encoded path"), - issue_iid: z.number().describe("The internal ID of the project issue"), -}); - -export const UpdateIssueSchema = z.object({ - project_id: z.string().describe("Project ID or URL-encoded path"), - issue_iid: z.number().describe("The internal ID of the project issue"), - title: z.string().optional().describe("The title of the issue"), - description: z.string().optional().describe("The description of the issue"), - assignee_ids: z.array(z.number()).optional().describe("Array of user IDs to assign issue to"), - confidential: z.boolean().optional().describe("Set the issue to be confidential"), - discussion_locked: z.boolean().optional().describe("Flag to lock discussions"), - due_date: z.string().optional().describe("Date the issue is due (YYYY-MM-DD)"), - labels: z.array(z.string()).optional().describe("Array of label names"), - milestone_id: z.number().optional().describe("Milestone ID to assign"), - state_event: z.enum(['close', 'reopen']).optional().describe("Update issue state (close/reopen)"), - weight: z.number().optional().describe("Weight of the issue (0-9)"), -}); - -export const DeleteIssueSchema = z.object({ - project_id: z.string().describe("Project ID or URL-encoded path"), - issue_iid: z.number().describe("The internal ID of the project issue"), +export const GitLabForkSchema = GitLabRepositorySchema.extend({ + forked_from_project: GitLabForkParentSchema.optional(), // Made optional to handle cases where GitLab API doesn't include it }); // Merge Request related schemas (equivalent to Pull Request) @@ -470,6 +494,54 @@ export const CreateNoteSchema = z.object({ body: z.string().describe("Note content"), }); +// Issues API operation schemas +export const ListIssuesSchema = z.object({ + project_id: z.string().describe("Project ID or URL-encoded path"), + assignee_id: z.number().optional().describe("Return issues assigned to the given user ID"), + assignee_username: z.string().optional().describe("Return issues assigned to the given username"), + author_id: z.number().optional().describe("Return issues created by the given user ID"), + author_username: z.string().optional().describe("Return issues created by the given username"), + confidential: z.boolean().optional().describe("Filter confidential or public issues"), + created_after: z.string().optional().describe("Return issues created after the given time"), + created_before: z.string().optional().describe("Return issues created before the given time"), + due_date: z.string().optional().describe("Return issues that have the due date"), + label_name: z.array(z.string()).optional().describe("Array of label names"), + milestone: z.string().optional().describe("Milestone title"), + scope: z.enum(['created-by-me', 'assigned-to-me', 'all']).optional().describe("Return issues from a specific scope"), + search: z.string().optional().describe("Search for specific terms"), + state: z.enum(['opened', 'closed', 'all']).optional().describe("Return issues with a specific state"), + updated_after: z.string().optional().describe("Return issues updated after the given time"), + updated_before: z.string().optional().describe("Return issues updated before the given time"), + with_labels_details: z.boolean().optional().describe("Return more details for each label"), + page: z.number().optional().describe("Page number for pagination"), + per_page: z.number().optional().describe("Number of items per page"), +}); + +export const GetIssueSchema = z.object({ + project_id: z.string().describe("Project ID or URL-encoded path"), + issue_iid: z.number().describe("The internal ID of the project issue"), +}); + +export const UpdateIssueSchema = z.object({ + project_id: z.string().describe("Project ID or URL-encoded path"), + issue_iid: z.number().describe("The internal ID of the project issue"), + title: z.string().optional().describe("The title of the issue"), + description: z.string().optional().describe("The description of the issue"), + assignee_ids: z.array(z.number()).optional().describe("Array of user IDs to assign issue to"), + confidential: z.boolean().optional().describe("Set the issue to be confidential"), + discussion_locked: z.boolean().optional().describe("Flag to lock discussions"), + due_date: z.string().optional().describe("Date the issue is due (YYYY-MM-DD)"), + labels: z.array(z.string()).optional().describe("Array of label names"), + milestone_id: z.number().optional().describe("Milestone ID to assign"), + state_event: z.enum(['close', 'reopen']).optional().describe("Update issue state (close/reopen)"), + weight: z.number().optional().describe("Weight of the issue (0-9)"), +}); + +export const DeleteIssueSchema = z.object({ + project_id: z.string().describe("Project ID or URL-encoded path"), + issue_iid: z.number().describe("The internal ID of the project issue"), +}); + // Issue links related schemas export const GitLabIssueLinkSchema = z.object({ id: z.number(), @@ -505,6 +577,43 @@ export const DeleteIssueLinkSchema = z.object({ issue_link_id: z.number().describe("The ID of an issue relationship"), }); +// Namespace API operation schemas +export const ListNamespacesSchema = z.object({ + search: z.string().optional().describe("Search term for namespaces"), + page: z.number().optional().describe("Page number for pagination"), + per_page: z.number().optional().describe("Number of items per page"), + owned: z.boolean().optional().describe("Filter for namespaces owned by current user"), +}); + +export const GetNamespaceSchema = z.object({ + namespace_id: z.string().describe("Namespace ID or full path"), +}); + +export const VerifyNamespaceSchema = z.object({ + path: z.string().describe("Namespace path to verify"), +}); + +// Project API operation schemas +export const GetProjectSchema = z.object({ + project_id: z.string().describe("Project ID or URL-encoded path"), +}); + +export const ListProjectsSchema = z.object({ + search: z.string().optional().describe("Search term for projects"), + page: z.number().optional().describe("Page number for pagination"), + per_page: z.number().optional().describe("Number of items per page"), + owned: z.boolean().optional().describe("Filter for projects owned by current user"), + membership: z.boolean().optional().describe("Filter for projects where current user is a member"), + simple: z.boolean().optional().describe("Return only limited fields"), + archived: z.boolean().optional().describe("Filter for archived projects"), + visibility: z.enum(["public", "internal", "private"]).optional().describe("Filter by project visibility"), + order_by: z.enum(["id", "name", "path", "created_at", "updated_at", "last_activity_at"]).optional().describe("Return projects ordered by field"), + sort: z.enum(["asc", "desc"]).optional().describe("Return projects sorted in ascending or descending order"), + with_issues_enabled: z.boolean().optional().describe("Filter projects with issues feature enabled"), + with_merge_requests_enabled: z.boolean().optional().describe("Filter projects with merge requests feature enabled"), + min_access_level: z.number().optional().describe("Filter by minimum access level"), +}); + // Export types export type GitLabAuthor = z.infer; export type GitLabFork = z.infer; @@ -512,28 +621,21 @@ export type GitLabIssue = z.infer; export type GitLabMergeRequest = z.infer; export type GitLabRepository = z.infer; export type GitLabFileContent = z.infer; -export type GitLabDirectoryContent = z.infer< - typeof GitLabDirectoryContentSchema ->; +export type GitLabDirectoryContent = z.infer; export type GitLabContent = z.infer; export type FileOperation = z.infer; export type GitLabTree = z.infer; export type GitLabCommit = z.infer; export type GitLabReference = z.infer; -export type CreateRepositoryOptions = z.infer< - typeof CreateRepositoryOptionsSchema ->; +export type CreateRepositoryOptions = z.infer; export type CreateIssueOptions = z.infer; -export type CreateMergeRequestOptions = z.infer< - typeof CreateMergeRequestOptionsSchema ->; +export type CreateMergeRequestOptions = z.infer; export type CreateBranchOptions = z.infer; -export type GitLabCreateUpdateFileResponse = z.infer< - typeof GitLabCreateUpdateFileResponseSchema ->; +export type GitLabCreateUpdateFileResponse = z.infer; export type GitLabSearchResponse = z.infer; -export type GitLabMergeRequestDiff = z.infer< - typeof GitLabMergeRequestDiffSchema ->; +export type GitLabMergeRequestDiff = z.infer; export type CreateNoteOptions = z.infer; export type GitLabIssueLink = z.infer; +export type GitLabNamespace = z.infer; +export type GitLabNamespaceExistsResponse = z.infer; +export type GitLabProject = z.infer;