From 0b14c8e630cd54fda11361d9cb360109ff65ab4a Mon Sep 17 00:00:00 2001 From: a Date: Tue, 1 Jul 2025 20:13:20 -0500 Subject: [PATCH 01/24] noot --- agent.ts | 146 ++ config.ts | 39 + index.ts | 4308 +------------------------------------------------- mcpserver.ts | 4141 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 4329 insertions(+), 4305 deletions(-) create mode 100644 agent.ts create mode 100644 config.ts create mode 100644 mcpserver.ts diff --git a/agent.ts b/agent.ts new file mode 100644 index 0000000..8041097 --- /dev/null +++ b/agent.ts @@ -0,0 +1,146 @@ +import { config } from "./config.js"; +import nodeFetch from "node-fetch"; +import fetchCookie from "fetch-cookie"; +import { CookieJar, parse as parseCookie } from "tough-cookie"; +import { SocksProxyAgent } from "socks-proxy-agent"; +import { HttpsProxyAgent } from "https-proxy-agent"; +import { HttpProxyAgent } from "http-proxy-agent"; +import { Agent } from "http"; +import { Agent as HttpsAgent } from "https"; +import fs from "fs"; +import path from "path"; + +let sslOptions = undefined; +if (config.NODE_TLS_REJECT_UNAUTHORIZED === "0") { + sslOptions = { rejectUnauthorized: false }; +} else if (config.GITLAB_CA_CERT_PATH) { + const ca = fs.readFileSync(config.GITLAB_CA_CERT_PATH); + sslOptions = { ca }; +} + +// Configure proxy agents if proxies are set +let httpAgent: Agent | undefined = undefined; +let httpsAgent: Agent | undefined = undefined; + +if (config.HTTP_PROXY) { + if (config.HTTP_PROXY.startsWith("socks")) { + httpAgent = new SocksProxyAgent(config.HTTP_PROXY); + } else { + httpAgent = new HttpProxyAgent(config.HTTP_PROXY); + } +} +if (config.HTTPS_PROXY) { + if (config.HTTPS_PROXY.startsWith("socks")) { + httpsAgent = new SocksProxyAgent(config.HTTPS_PROXY); + } else { + httpsAgent = new HttpsProxyAgent(config.HTTPS_PROXY, sslOptions); + } +} +httpsAgent = httpsAgent || new HttpsAgent(sslOptions); +httpAgent = httpAgent || new Agent(); + +// Create cookie jar with clean Netscape file parsing +const createCookieJar = (): CookieJar | null => { + if (!config.GITLAB_AUTH_COOKIE_PATH) return null; + + try { + const cookiePath = config.GITLAB_AUTH_COOKIE_PATH.startsWith("~/") + ? path.join(process.env.HOME || "", config.GITLAB_AUTH_COOKIE_PATH.slice(2)) + : config.GITLAB_AUTH_COOKIE_PATH; + + const jar = new CookieJar(); + const cookieContent = fs.readFileSync(cookiePath, "utf8"); + + cookieContent.split("\n").forEach(line => { + // Handle #HttpOnly_ prefix + if (line.startsWith("#HttpOnly_")) { + line = line.slice(10); + } + // Skip comments and empty lines + if (line.startsWith("#") || !line.trim()) { + return; + } + + // Parse Netscape format: domain, flag, path, secure, expires, name, value + const parts = line.split("\t"); + if (parts.length >= 7) { + const [domain, , path, secure, expires, name, value] = parts; + + // Build cookie string in standard format + const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secure === "TRUE" ? "; Secure" : ""}${expires !== "0" ? `; Expires=${new Date(parseInt(expires) * 1000).toUTCString()}` : ""}`; + + // Use tough-cookie's parse function for robust parsing + const cookie = parseCookie(cookieStr); + if (cookie) { + const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`; + jar.setCookieSync(cookie, url); + } + } + }); + + return jar; + } catch (error) { + console.error("Error loading cookie file:", error); + return null; + } +}; + +// Initialize cookie jar and fetch +const cookieJar = createCookieJar(); + +// Ensure session is established for the current request +export async function ensureSessionForRequest(): Promise { + if (!cookieJar || !config.GITLAB_AUTH_COOKIE_PATH) return; + + // Extract the base URL from GITLAB_API_URL + const apiUrl = new URL(config.GITLAB_API_URL); + const baseUrl = `${apiUrl.protocol}//${apiUrl.hostname}`; + + // Check if we already have GitLab session cookies + const gitlabCookies = cookieJar.getCookiesSync(baseUrl); + const hasSessionCookie = gitlabCookies.some(cookie => + cookie.key === '_gitlab_session' || cookie.key === 'remember_user_token' + ); + + if (!hasSessionCookie) { + try { + // Establish session with a lightweight request + await fetch(`${config.GITLAB_API_URL}/user`, { + ...DEFAULT_FETCH_CONFIG, + redirect: 'follow' + }).catch(() => { + // Ignore errors - the important thing is that cookies get set during redirects + }); + + // Small delay to ensure cookies are fully processed + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (error) { + // Ignore session establishment errors + } + } +} + + +export const fetch = cookieJar ? fetchCookie(nodeFetch, cookieJar) : nodeFetch; +// Modify DEFAULT_HEADERS to include agent configuration +export const DEFAULT_HEADERS: Record = { + Accept: "application/json", + "Content-Type": "application/json", +}; + +if (config.IS_OLD) { + DEFAULT_HEADERS["Private-Token"] = `${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; +} else { + DEFAULT_HEADERS["Authorization"] = `Bearer ${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; +} + +export const DEFAULT_FETCH_CONFIG = { + headers: DEFAULT_HEADERS, + agent: (parsedUrl: URL) => { + if (parsedUrl.protocol === "https:") { + return httpsAgent; + } + return httpAgent; + }, +}; + diff --git a/config.ts b/config.ts new file mode 100644 index 0000000..356a919 --- /dev/null +++ b/config.ts @@ -0,0 +1,39 @@ +export const config = { + GITLAB_PERSONAL_ACCESS_TOKEN : process.env.GITLAB_PERSONAL_ACCESS_TOKEN, + GITLAB_AUTH_COOKIE_PATH : process.env.GITLAB_AUTH_COOKIE_PATH, + IS_OLD : process.env.GITLAB_IS_OLD === "true", + GITLAB_READ_ONLY_MODE : process.env.GITLAB_READ_ONLY_MODE === "true", + USE_GITLAB_WIKI : process.env.USE_GITLAB_WIKI === "true", + USE_MILESTONE : process.env.USE_MILESTONE === "true", + USE_PIPELINE : process.env.USE_PIPELINE === "true", + // Add proxy configuration + HTTP_PROXY : process.env.HTTP_PROXY, + HTTPS_PROXY : process.env.HTTPS_PROXY, + NODE_TLS_REJECT_UNAUTHORIZED : process.env.NODE_TLS_REJECT_UNAUTHORIZED, + GITLAB_CA_CERT_PATH : process.env.GITLAB_CA_CERT_PATH, + // Use the normalizeGitLabApiUrl function to handle various URL formats + GITLAB_API_URL: normalizeGitLabApiUrl(process.env.GITLAB_API_URL || ""), + GITLAB_PROJECT_ID: process.env.GITLAB_PROJECT_ID, +} +/** + * Smart URL handling for GitLab API + * + * @param {string | undefined} url - Input GitLab API URL + * @returns {string} Normalized GitLab API URL with /api/v4 path + */ +function normalizeGitLabApiUrl(url?: string): string { + if (!url) { + return "https://gitlab.com/api/v4"; + } + + // Remove trailing slash if present + let normalizedUrl = url.endsWith("/") ? url.slice(0, -1) : url; + + // Check if URL already has /api/v4 + if (!normalizedUrl.endsWith("/api/v4") && !normalizedUrl.endsWith("/api/v4/")) { + // Append /api/v4 if not already present + normalizedUrl = `${normalizedUrl}/api/v4`; + } + + return normalizedUrl; +} diff --git a/index.ts b/index.ts index ed98e4e..834009a 100644 --- a/index.ts +++ b/index.ts @@ -1,4319 +1,17 @@ #!/usr/bin/env node -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; -import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import nodeFetch from "node-fetch"; -import fetchCookie from "fetch-cookie"; -import { CookieJar, parse as parseCookie } from "tough-cookie"; -import { SocksProxyAgent } from "socks-proxy-agent"; -import { HttpsProxyAgent } from "https-proxy-agent"; -import { HttpProxyAgent } from "http-proxy-agent"; -import { z } from "zod"; -import { zodToJsonSchema } from "zod-to-json-schema"; -import { fileURLToPath } from "url"; -import { dirname } from "path"; -import fs from "fs"; -import path from "path"; import express, { Request, Response } from "express"; // Add type imports for proxy agents import { Agent } from "http"; import { Agent as HttpsAgent } from "https"; import { URL } from "url"; -import { - GitLabForkSchema, - GitLabReferenceSchema, - GitLabRepositorySchema, - GitLabIssueSchema, - GitLabMergeRequestSchema, - GitLabContentSchema, - GitLabCreateUpdateFileResponseSchema, - GitLabSearchResponseSchema, - GitLabTreeSchema, - GitLabCommitSchema, - GitLabNamespaceSchema, - GitLabNamespaceExistsResponseSchema, - GitLabProjectSchema, - GitLabLabelSchema, - GitLabUserSchema, - GitLabUsersResponseSchema, - GetUsersSchema, - CreateRepositoryOptionsSchema, - CreateIssueOptionsSchema, - CreateMergeRequestOptionsSchema, - CreateBranchOptionsSchema, - CreateOrUpdateFileSchema, - SearchRepositoriesSchema, - CreateRepositorySchema, - GetFileContentsSchema, - PushFilesSchema, - CreateIssueSchema, - CreateMergeRequestSchema, - ForkRepositorySchema, - CreateBranchSchema, - GitLabDiffSchema, - GetMergeRequestSchema, - GetMergeRequestDiffsSchema, - UpdateMergeRequestSchema, - ListIssuesSchema, - GetIssueSchema, - UpdateIssueSchema, - DeleteIssueSchema, - GitLabIssueLinkSchema, - GitLabIssueWithLinkDetailsSchema, - ListIssueLinksSchema, - ListIssueDiscussionsSchema, - GetIssueLinkSchema, - CreateIssueLinkSchema, - DeleteIssueLinkSchema, - ListNamespacesSchema, - GetNamespaceSchema, - VerifyNamespaceSchema, - GetProjectSchema, - ListProjectsSchema, - ListLabelsSchema, - GetLabelSchema, - CreateLabelSchema, - UpdateLabelSchema, - DeleteLabelSchema, - CreateNoteSchema, - CreateMergeRequestThreadSchema, - ListGroupProjectsSchema, - ListWikiPagesSchema, - GetWikiPageSchema, - CreateWikiPageSchema, - UpdateWikiPageSchema, - DeleteWikiPageSchema, - GitLabWikiPageSchema, - GetRepositoryTreeSchema, - GitLabTreeItemSchema, - GitLabPipelineSchema, - GetPipelineSchema, - ListPipelinesSchema, - ListPipelineJobsSchema, - CreatePipelineSchema, - RetryPipelineSchema, - CancelPipelineSchema, - // pipeline job schemas - GetPipelineJobOutputSchema, - GitLabPipelineJobSchema, - // Discussion Schemas - GitLabDiscussionNoteSchema, // Added - GitLabDiscussionSchema, - PaginatedDiscussionsResponseSchema, - UpdateMergeRequestNoteSchema, // Added - CreateMergeRequestNoteSchema, // Added - ListMergeRequestDiscussionsSchema, - type GitLabFork, - type GitLabReference, - type GitLabRepository, - type GitLabIssue, - type GitLabMergeRequest, - type GitLabContent, - type GitLabCreateUpdateFileResponse, - type GitLabSearchResponse, - type GitLabTree, - type GitLabCommit, - type FileOperation, - type GitLabMergeRequestDiff, - type GitLabIssueLink, - type GitLabIssueWithLinkDetails, - type GitLabNamespace, - type GitLabNamespaceExistsResponse, - type GitLabProject, - type GitLabLabel, - type GitLabUser, - type GitLabUsersResponse, - type GitLabPipeline, - type ListPipelinesOptions, - type GetPipelineOptions, - type ListPipelineJobsOptions, - type CreatePipelineOptions, - type RetryPipelineOptions, - type CancelPipelineOptions, - type GitLabPipelineJob, - type GitLabMilestones, - type ListProjectMilestonesOptions, - type GetProjectMilestoneOptions, - type CreateProjectMilestoneOptions, - type EditProjectMilestoneOptions, - type DeleteProjectMilestoneOptions, - type GetMilestoneIssuesOptions, - type GetMilestoneMergeRequestsOptions, - type PromoteProjectMilestoneOptions, - type GetMilestoneBurndownEventsOptions, - // Discussion Types - type GitLabDiscussionNote, - type GitLabDiscussion, - type PaginatedDiscussionsResponse, - type PaginationOptions, - type MergeRequestThreadPosition, - type GetWikiPageOptions, - type CreateWikiPageOptions, - type UpdateWikiPageOptions, - type DeleteWikiPageOptions, - type GitLabWikiPage, - type GitLabTreeItem, - type GetRepositoryTreeOptions, - UpdateIssueNoteSchema, - CreateIssueNoteSchema, - ListMergeRequestsSchema, - GitLabMilestonesSchema, - ListProjectMilestonesSchema, - GetProjectMilestoneSchema, - CreateProjectMilestoneSchema, - EditProjectMilestoneSchema, - DeleteProjectMilestoneSchema, - GetMilestoneIssuesSchema, - GetMilestoneMergeRequestsSchema, - PromoteProjectMilestoneSchema, - GetMilestoneBurndownEventsSchema, - GitLabCompareResult, - GitLabCompareResultSchema, - GetBranchDiffsSchema, - ListWikiPagesOptions, - ListCommitsSchema, - GetCommitSchema, - GetCommitDiffSchema, - type ListCommitsOptions, - type GetCommitOptions, - type GetCommitDiffOptions, - ListMergeRequestDiffsSchema, -} from "./schemas.js"; +import {mcpserver} from "./mcpserver.js"; -/** - * Read version from package.json - */ -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const packageJsonPath = path.resolve(__dirname, "../package.json"); -let SERVER_VERSION = "unknown"; -try { - if (fs.existsSync(packageJsonPath)) { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - SERVER_VERSION = packageJson.version || SERVER_VERSION; - } -} catch (error) { - // Warning: Could not read version from package.json - silently continue -} - -const server = new Server( - { - name: "better-gitlab-mcp-server", - version: SERVER_VERSION, - }, - { - capabilities: { - tools: {}, - }, - } -); - -const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN; -const GITLAB_AUTH_COOKIE_PATH = process.env.GITLAB_AUTH_COOKIE_PATH; -const IS_OLD = process.env.GITLAB_IS_OLD === "true"; -const GITLAB_READ_ONLY_MODE = process.env.GITLAB_READ_ONLY_MODE === "true"; -const USE_GITLAB_WIKI = process.env.USE_GITLAB_WIKI === "true"; -const USE_MILESTONE = process.env.USE_MILESTONE === "true"; -const USE_PIPELINE = process.env.USE_PIPELINE === "true"; const SSE = process.env.SSE === "true"; -// Add proxy configuration -const HTTP_PROXY = process.env.HTTP_PROXY; -const HTTPS_PROXY = process.env.HTTPS_PROXY; -const NODE_TLS_REJECT_UNAUTHORIZED = process.env.NODE_TLS_REJECT_UNAUTHORIZED; -const GITLAB_CA_CERT_PATH = process.env.GITLAB_CA_CERT_PATH; - -let sslOptions = undefined; -if (NODE_TLS_REJECT_UNAUTHORIZED === "0") { - sslOptions = { rejectUnauthorized: false }; -} else if (GITLAB_CA_CERT_PATH) { - const ca = fs.readFileSync(GITLAB_CA_CERT_PATH); - sslOptions = { ca }; -} - -// Configure proxy agents if proxies are set -let httpAgent: Agent | undefined = undefined; -let httpsAgent: Agent | undefined = undefined; - -if (HTTP_PROXY) { - if (HTTP_PROXY.startsWith("socks")) { - httpAgent = new SocksProxyAgent(HTTP_PROXY); - } else { - httpAgent = new HttpProxyAgent(HTTP_PROXY); - } -} -if (HTTPS_PROXY) { - if (HTTPS_PROXY.startsWith("socks")) { - httpsAgent = new SocksProxyAgent(HTTPS_PROXY); - } else { - httpsAgent = new HttpsProxyAgent(HTTPS_PROXY, sslOptions); - } -} -httpsAgent = httpsAgent || new HttpsAgent(sslOptions); -httpAgent = httpAgent || new Agent(); - -// Create cookie jar with clean Netscape file parsing -const createCookieJar = (): CookieJar | null => { - if (!GITLAB_AUTH_COOKIE_PATH) return null; - - try { - const cookiePath = GITLAB_AUTH_COOKIE_PATH.startsWith("~/") - ? path.join(process.env.HOME || "", GITLAB_AUTH_COOKIE_PATH.slice(2)) - : GITLAB_AUTH_COOKIE_PATH; - - const jar = new CookieJar(); - const cookieContent = fs.readFileSync(cookiePath, "utf8"); - - cookieContent.split("\n").forEach(line => { - // Handle #HttpOnly_ prefix - if (line.startsWith("#HttpOnly_")) { - line = line.slice(10); - } - // Skip comments and empty lines - if (line.startsWith("#") || !line.trim()) { - return; - } - - // Parse Netscape format: domain, flag, path, secure, expires, name, value - const parts = line.split("\t"); - if (parts.length >= 7) { - const [domain, , path, secure, expires, name, value] = parts; - - // Build cookie string in standard format - const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secure === "TRUE" ? "; Secure" : ""}${expires !== "0" ? `; Expires=${new Date(parseInt(expires) * 1000).toUTCString()}` : ""}`; - - // Use tough-cookie's parse function for robust parsing - const cookie = parseCookie(cookieStr); - if (cookie) { - const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`; - jar.setCookieSync(cookie, url); - } - } - }); - - return jar; - } catch (error) { - console.error("Error loading cookie file:", error); - return null; - } -}; - -// Initialize cookie jar and fetch -const cookieJar = createCookieJar(); -const fetch = cookieJar ? fetchCookie(nodeFetch, cookieJar) : nodeFetch; - -// Ensure session is established for the current request -async function ensureSessionForRequest(): Promise { - if (!cookieJar || !GITLAB_AUTH_COOKIE_PATH) return; - - // Extract the base URL from GITLAB_API_URL - const apiUrl = new URL(GITLAB_API_URL); - const baseUrl = `${apiUrl.protocol}//${apiUrl.hostname}`; - - // Check if we already have GitLab session cookies - const gitlabCookies = cookieJar.getCookiesSync(baseUrl); - const hasSessionCookie = gitlabCookies.some(cookie => - cookie.key === '_gitlab_session' || cookie.key === 'remember_user_token' - ); - - if (!hasSessionCookie) { - try { - // Establish session with a lightweight request - await fetch(`${GITLAB_API_URL}/user`, { - ...DEFAULT_FETCH_CONFIG, - redirect: 'follow' - }).catch(() => { - // Ignore errors - the important thing is that cookies get set during redirects - }); - - // Small delay to ensure cookies are fully processed - await new Promise(resolve => setTimeout(resolve, 100)); - } catch (error) { - // Ignore session establishment errors - } - } -} - -// Modify DEFAULT_HEADERS to include agent configuration -const DEFAULT_HEADERS: Record = { - Accept: "application/json", - "Content-Type": "application/json", -}; -if (IS_OLD) { - DEFAULT_HEADERS["Private-Token"] = `${GITLAB_PERSONAL_ACCESS_TOKEN}`; -} else { - DEFAULT_HEADERS["Authorization"] = `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`; -} - -// Create a default fetch configuration object that includes proxy agents if set -const DEFAULT_FETCH_CONFIG = { - headers: DEFAULT_HEADERS, - agent: (parsedUrl: URL) => { - if (parsedUrl.protocol === "https:") { - return httpsAgent; - } - return httpAgent; - }, -}; - -// Define all available tools -const allTools = [ - { - name: "create_or_update_file", - description: "Create or update a single file in a GitLab project", - inputSchema: zodToJsonSchema(CreateOrUpdateFileSchema), - }, - { - name: "search_repositories", - description: "Search for GitLab projects", - inputSchema: zodToJsonSchema(SearchRepositoriesSchema), - }, - { - name: "create_repository", - description: "Create a new GitLab project", - inputSchema: zodToJsonSchema(CreateRepositorySchema), - }, - { - name: "get_file_contents", - description: "Get the contents of a file or directory from a GitLab project", - inputSchema: zodToJsonSchema(GetFileContentsSchema), - }, - { - name: "push_files", - description: "Push multiple files to a GitLab project in a single commit", - inputSchema: zodToJsonSchema(PushFilesSchema), - }, - { - name: "create_issue", - description: "Create a new issue in a GitLab project", - inputSchema: zodToJsonSchema(CreateIssueSchema), - }, - { - name: "create_merge_request", - description: "Create a new merge request in a GitLab project", - inputSchema: zodToJsonSchema(CreateMergeRequestSchema), - }, - { - name: "fork_repository", - description: "Fork a GitLab project to your account or specified namespace", - inputSchema: zodToJsonSchema(ForkRepositorySchema), - }, - { - name: "create_branch", - description: "Create a new branch in a GitLab project", - inputSchema: zodToJsonSchema(CreateBranchSchema), - }, - { - name: "get_merge_request", - description: - "Get details of a merge request (Either mergeRequestIid or branchName must be provided)", - inputSchema: zodToJsonSchema(GetMergeRequestSchema), - }, - { - name: "get_merge_request_diffs", - description: - "Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)", - inputSchema: zodToJsonSchema(GetMergeRequestDiffsSchema), - }, - { - name: "list_merge_request_diffs", - description: - "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)", - inputSchema: zodToJsonSchema(ListMergeRequestDiffsSchema), - }, - { - name: "get_branch_diffs", - description: "Get the changes/diffs between two branches or commits in a GitLab project", - inputSchema: zodToJsonSchema(GetBranchDiffsSchema), - }, - { - name: "update_merge_request", - description: "Update a merge request (Either mergeRequestIid or branchName must be provided)", - inputSchema: zodToJsonSchema(UpdateMergeRequestSchema), - }, - { - name: "create_note", - description: "Create a new note (comment) to an issue or merge request", - inputSchema: zodToJsonSchema(CreateNoteSchema), - }, - { - name: "create_merge_request_thread", - description: "Create a new thread on a merge request", - inputSchema: zodToJsonSchema(CreateMergeRequestThreadSchema), - }, - { - name: "mr_discussions", - description: "List discussion items for a merge request", - inputSchema: zodToJsonSchema(ListMergeRequestDiscussionsSchema), - }, - { - name: "update_merge_request_note", - description: "Modify an existing merge request thread note", - inputSchema: zodToJsonSchema(UpdateMergeRequestNoteSchema), - }, - { - name: "create_merge_request_note", - description: "Add a new note to an existing merge request thread", - inputSchema: zodToJsonSchema(CreateMergeRequestNoteSchema), - }, - { - name: "update_issue_note", - description: "Modify an existing issue thread note", - inputSchema: zodToJsonSchema(UpdateIssueNoteSchema), - }, - { - name: "create_issue_note", - description: "Add a new note to an existing issue thread", - inputSchema: zodToJsonSchema(CreateIssueNoteSchema), - }, - { - name: "list_issues", - description: "List issues in a GitLab project with filtering options", - inputSchema: zodToJsonSchema(ListIssuesSchema), - }, - { - name: "get_issue", - description: "Get details of a specific issue in a GitLab project", - inputSchema: zodToJsonSchema(GetIssueSchema), - }, - { - name: "update_issue", - description: "Update an issue in a GitLab project", - inputSchema: zodToJsonSchema(UpdateIssueSchema), - }, - { - name: "delete_issue", - description: "Delete an issue from a GitLab project", - inputSchema: zodToJsonSchema(DeleteIssueSchema), - }, - { - name: "list_issue_links", - description: "List all issue links for a specific issue", - inputSchema: zodToJsonSchema(ListIssueLinksSchema), - }, - { - name: "list_issue_discussions", - description: "List discussions for an issue in a GitLab project", - inputSchema: zodToJsonSchema(ListIssueDiscussionsSchema), - }, - { - name: "get_issue_link", - description: "Get a specific issue link", - inputSchema: zodToJsonSchema(GetIssueLinkSchema), - }, - { - name: "create_issue_link", - description: "Create an issue link between two issues", - inputSchema: zodToJsonSchema(CreateIssueLinkSchema), - }, - { - name: "delete_issue_link", - 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), - }, - { - name: "list_labels", - description: "List labels for a project", - inputSchema: zodToJsonSchema(ListLabelsSchema), - }, - { - name: "get_label", - description: "Get a single label from a project", - inputSchema: zodToJsonSchema(GetLabelSchema), - }, - { - name: "create_label", - description: "Create a new label in a project", - inputSchema: zodToJsonSchema(CreateLabelSchema), - }, - { - name: "update_label", - description: "Update an existing label in a project", - inputSchema: zodToJsonSchema(UpdateLabelSchema), - }, - { - name: "delete_label", - description: "Delete a label from a project", - inputSchema: zodToJsonSchema(DeleteLabelSchema), - }, - { - name: "list_group_projects", - description: "List projects in a GitLab group with filtering options", - inputSchema: zodToJsonSchema(ListGroupProjectsSchema), - }, - { - name: "list_wiki_pages", - description: "List wiki pages in a GitLab project", - inputSchema: zodToJsonSchema(ListWikiPagesSchema), - }, - { - name: "get_wiki_page", - description: "Get details of a specific wiki page", - inputSchema: zodToJsonSchema(GetWikiPageSchema), - }, - { - name: "create_wiki_page", - description: "Create a new wiki page in a GitLab project", - inputSchema: zodToJsonSchema(CreateWikiPageSchema), - }, - { - name: "update_wiki_page", - description: "Update an existing wiki page in a GitLab project", - inputSchema: zodToJsonSchema(UpdateWikiPageSchema), - }, - { - name: "delete_wiki_page", - description: "Delete a wiki page from a GitLab project", - inputSchema: zodToJsonSchema(DeleteWikiPageSchema), - }, - { - name: "get_repository_tree", - description: "Get the repository tree for a GitLab project (list files and directories)", - inputSchema: zodToJsonSchema(GetRepositoryTreeSchema), - }, - { - name: "list_pipelines", - description: "List pipelines in a GitLab project with filtering options", - inputSchema: zodToJsonSchema(ListPipelinesSchema), - }, - { - name: "get_pipeline", - description: "Get details of a specific pipeline in a GitLab project", - inputSchema: zodToJsonSchema(GetPipelineSchema), - }, - { - name: "list_pipeline_jobs", - description: "List all jobs in a specific pipeline", - inputSchema: zodToJsonSchema(ListPipelineJobsSchema), - }, - { - name: "get_pipeline_job", - description: "Get details of a GitLab pipeline job number", - inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), - }, - { - name: "get_pipeline_job_output", - description: "Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage", - inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), - }, - { - name: "create_pipeline", - description: "Create a new pipeline for a branch or tag", - inputSchema: zodToJsonSchema(CreatePipelineSchema), - }, - { - name: "retry_pipeline", - description: "Retry a failed or canceled pipeline", - inputSchema: zodToJsonSchema(RetryPipelineSchema), - }, - { - name: "cancel_pipeline", - description: "Cancel a running pipeline", - inputSchema: zodToJsonSchema(CancelPipelineSchema), - }, - { - name: "list_merge_requests", - description: "List merge requests in a GitLab project with filtering options", - inputSchema: zodToJsonSchema(ListMergeRequestsSchema), - }, - { - name: "list_milestones", - description: "List milestones in a GitLab project with filtering options", - inputSchema: zodToJsonSchema(ListProjectMilestonesSchema), - }, - { - name: "get_milestone", - description: "Get details of a specific milestone", - inputSchema: zodToJsonSchema(GetProjectMilestoneSchema), - }, - { - name: "create_milestone", - description: "Create a new milestone in a GitLab project", - inputSchema: zodToJsonSchema(CreateProjectMilestoneSchema), - }, - { - name: "edit_milestone", - description: "Edit an existing milestone in a GitLab project", - inputSchema: zodToJsonSchema(EditProjectMilestoneSchema), - }, - { - name: "delete_milestone", - description: "Delete a milestone from a GitLab project", - inputSchema: zodToJsonSchema(DeleteProjectMilestoneSchema), - }, - { - name: "get_milestone_issue", - description: "Get issues associated with a specific milestone", - inputSchema: zodToJsonSchema(GetMilestoneIssuesSchema), - }, - { - name: "get_milestone_merge_requests", - description: "Get merge requests associated with a specific milestone", - inputSchema: zodToJsonSchema(GetMilestoneMergeRequestsSchema), - }, - { - name: "promote_milestone", - description: "Promote a milestone to the next stage", - inputSchema: zodToJsonSchema(PromoteProjectMilestoneSchema), - }, - { - name: "get_milestone_burndown_events", - description: "Get burndown events for a specific milestone", - inputSchema: zodToJsonSchema(GetMilestoneBurndownEventsSchema), - }, - { - name: "get_users", - description: "Get GitLab user details by usernames", - inputSchema: zodToJsonSchema(GetUsersSchema), - }, - { - name: "list_commits", - description: "List repository commits with filtering options", - inputSchema: zodToJsonSchema(ListCommitsSchema), - }, - { - name: "get_commit", - description: "Get details of a specific commit", - inputSchema: zodToJsonSchema(GetCommitSchema), - }, - { - name: "get_commit_diff", - description: "Get changes/diffs of a specific commit", - inputSchema: zodToJsonSchema(GetCommitDiffSchema), - }, -]; - -// Define which tools are read-only -const readOnlyTools = [ - "search_repositories", - "get_file_contents", - "get_merge_request", - "get_merge_request_diffs", - "get_branch_diffs", - "mr_discussions", - "list_issues", - "list_merge_requests", - "get_issue", - "list_issue_links", - "list_issue_discussions", - "get_issue_link", - "list_namespaces", - "get_namespace", - "verify_namespace", - "get_project", - "get_pipeline", - "list_pipelines", - "list_pipeline_jobs", - "get_pipeline_job", - "get_pipeline_job_output", - "list_projects", - "list_labels", - "get_label", - "list_group_projects", - "get_repository_tree", - "list_milestones", - "get_milestone", - "get_milestone_issue", - "get_milestone_merge_requests", - "get_milestone_burndown_events", - "list_wiki_pages", - "get_wiki_page", - "get_users", - "list_commits", - "get_commit", - "get_commit_diff", -]; - -// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI -const wikiToolNames = [ - "list_wiki_pages", - "get_wiki_page", - "create_wiki_page", - "update_wiki_page", - "delete_wiki_page", - "upload_wiki_attachment", -]; - -// Define which tools are related to milestones and can be toggled by USE_MILESTONE -const milestoneToolNames = [ - "list_milestones", - "get_milestone", - "create_milestone", - "edit_milestone", - "delete_milestone", - "get_milestone_issue", - "get_milestone_merge_requests", - "promote_milestone", - "get_milestone_burndown_events", -]; - -// Define which tools are related to pipelines and can be toggled by USE_PIPELINE -const pipelineToolNames = [ - "list_pipelines", - "get_pipeline", - "list_pipeline_jobs", - "get_pipeline_job", - "get_pipeline_job_output", - "create_pipeline", - "retry_pipeline", - "cancel_pipeline", -]; - -/** - * Smart URL handling for GitLab API - * - * @param {string | undefined} url - Input GitLab API URL - * @returns {string} Normalized GitLab API URL with /api/v4 path - */ -function normalizeGitLabApiUrl(url?: string): string { - if (!url) { - return "https://gitlab.com/api/v4"; - } - - // Remove trailing slash if present - let normalizedUrl = url.endsWith("/") ? url.slice(0, -1) : url; - - // Check if URL already has /api/v4 - if (!normalizedUrl.endsWith("/api/v4") && !normalizedUrl.endsWith("/api/v4/")) { - // Append /api/v4 if not already present - normalizedUrl = `${normalizedUrl}/api/v4`; - } - - return normalizedUrl; -} - -// Use the normalizeGitLabApiUrl function to handle various URL formats -const GITLAB_API_URL = normalizeGitLabApiUrl(process.env.GITLAB_API_URL || ""); -const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID; - -if (!GITLAB_PERSONAL_ACCESS_TOKEN) { - console.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set"); - process.exit(1); -} - -/** - * Utility function for handling GitLab API errors - * API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors) - * - * @param {import("node-fetch").Response} response - The response from GitLab API - * @throws {Error} Throws an error with response details if the request failed - */ -async function handleGitLabError(response: import("node-fetch").Response): Promise { - if (!response.ok) { - const errorBody = await response.text(); - // Check specifically for Rate Limit error - if (response.status === 403 && errorBody.includes("User API Key Rate limit exceeded")) { - console.error("GitLab API Rate Limit Exceeded:", errorBody); - console.log("User API Key Rate limit exceeded. Please try again later."); - throw new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`); - } else { - // Handle other API errors - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - } -} - -/** - * @param {string} projectId - The project ID parameter passed to the function - * @returns {string} The project ID to use for the API call - */ -function getEffectiveProjectId(projectId: string): string { - return GITLAB_PROJECT_ID || projectId; -} - -/** - * Create a fork of a GitLab project - * 프로젝트 포크 생성 (Create a project fork) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} [namespace] - The namespace to fork the project to - * @returns {Promise} The created fork - */ -async function forkProject(projectId: string, namespace?: string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/fork`); - - if (namespace) { - url.searchParams.append("namespace", namespace); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - }); - - // 이미 존재하는 프로젝트인 경우 처리 - if (response.status === 409) { - throw new Error("Project already exists in the target namespace"); - } - - await handleGitLabError(response); - const data = await response.json(); - return GitLabForkSchema.parse(data); -} - -/** - * Create a new branch in a GitLab project - * 새로운 브랜치 생성 (Create a new branch) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {z.infer} options - Branch creation options - * @returns {Promise} The created branch reference - */ -async function createBranch( - projectId: string, - options: z.infer -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/branches` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - branch: options.name, - ref: options.ref, - }), - }); - - await handleGitLabError(response); - return GitLabReferenceSchema.parse(await response.json()); -} - -/** - * Get the default branch for a GitLab project - * 프로젝트의 기본 브랜치 조회 (Get the default branch of a project) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @returns {Promise} The name of the default branch - */ -async function getDefaultBranchRef(projectId: string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const project = GitLabRepositorySchema.parse(await response.json()); - return project.default_branch ?? "main"; -} - -/** - * Get the contents of a file from a GitLab project - * 파일 내용 조회 (Get file contents) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} filePath - The path of the file to get - * @param {string} [ref] - The name of the branch, tag or commit - * @returns {Promise} The file content - */ -async function getFileContents( - projectId: string, - filePath: string, - ref?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const encodedPath = encodeURIComponent(filePath); - - // ref가 없는 경우 default branch를 가져옴 - if (!ref) { - ref = await getDefaultBranchRef(projectId); - } - - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/files/${encodedPath}` - ); - - url.searchParams.append("ref", ref); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - // 파일을 찾을 수 없는 경우 처리 - if (response.status === 404) { - throw new Error(`File not found: ${filePath}`); - } - - await handleGitLabError(response); - const data = await response.json(); - const parsedData = GitLabContentSchema.parse(data); - - // Base64로 인코딩된 파일 내용을 UTF-8로 디코딩 - if (!Array.isArray(parsedData) && parsedData.content) { - parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8"); - parsedData.encoding = "utf8"; - } - - return parsedData; -} - -/** - * Create a new issue in a GitLab project - * 이슈 생성 (Create an issue) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {z.infer} options - Issue creation options - * @returns {Promise} The created issue - */ -async function createIssue( - projectId: string, - options: z.infer -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - title: options.title, - description: options.description, - assignee_ids: options.assignee_ids, - milestone_id: options.milestone_id, - labels: options.labels?.join(","), - }), - }); - - // 잘못된 요청 처리 - if (response.status === 400) { - const errorBody = await response.text(); - throw new Error(`Invalid request: ${errorBody}`); - } - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueSchema.parse(data); -} - -/** - * List issues in a GitLab project - * 프로젝트의 이슈 목록 조회 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {Object} options - Options for listing issues - * @returns {Promise} List of issues - */ -async function listIssues( - projectId: string, - options: Omit, "project_id"> = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`); - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - const keys = ["labels", "assignee_username"]; - if (keys.includes(key)) { - if (Array.isArray(value)) { - // Handle array of labels - value.forEach(label => { - url.searchParams.append(`${key}[]`, label.toString()); - }); - } else { - url.searchParams.append(`${key}[]`, value.toString()); - } - } else { - url.searchParams.append(key, value.toString()); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabIssueSchema).parse(data); -} - -/** - * List merge requests in a GitLab project with optional filtering - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {Object} options - Optional filtering parameters - * @returns {Promise} List of merge requests - */ -async function listMergeRequests( - projectId: string, - options: Omit, "project_id"> = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests`); - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (key === "labels" && Array.isArray(value)) { - // Handle array of labels - url.searchParams.append(key, value.join(",")); - } else { - url.searchParams.append(key, value.toString()); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabMergeRequestSchema).parse(data); -} - -/** - * Get a single issue from a GitLab project - * 단일 이슈 조회 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @returns {Promise} The issue - */ -async function getIssue(projectId: string, issueIid: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueSchema.parse(data); -} - -/** - * Update an issue in a GitLab project - * 이슈 업데이트 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {Object} options - Update options for the issue - * @returns {Promise} The updated issue - */ -async function updateIssue( - projectId: string, - issueIid: number, - options: Omit, "project_id" | "issue_iid"> -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}` - ); - - // Convert labels array to comma-separated string if present - const body: Record = { ...options }; - if (body.labels && Array.isArray(body.labels)) { - body.labels = body.labels.join(","); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(body), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueSchema.parse(data); -} - -/** - * Delete an issue from a GitLab project - * 이슈 삭제 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @returns {Promise} - */ -async function deleteIssue(projectId: string, issueIid: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - }); - - await handleGitLabError(response); -} - -/** - * List all issue links for a specific issue - * 이슈 관계 목록 조회 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @returns {Promise} List of issues with link details - */ -async function listIssueLinks( - projectId: string, - issueIid: number -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/links` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabIssueWithLinkDetailsSchema).parse(data); -} - -/** - * Get a specific issue link - * 특정 이슈 관계 조회 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {number} issueLinkId - The ID of the issue link - * @returns {Promise} The issue link - */ -async function getIssueLink( - projectId: string, - issueIid: number, - issueLinkId: number -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/issues/${issueIid}/links/${issueLinkId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueLinkSchema.parse(data); -} - -/** - * Create an issue link between two issues - * 이슈 관계 생성 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {string} targetProjectId - The ID or URL-encoded path of the target project - * @param {number} targetIssueIid - The internal ID of the target project issue - * @param {string} linkType - The type of the relation (relates_to, blocks, is_blocked_by) - * @returns {Promise} The created issue link - */ -async function createIssueLink( - projectId: string, - issueIid: number, - targetProjectId: string, - targetIssueIid: number, - linkType: "relates_to" | "blocks" | "is_blocked_by" = "relates_to" -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - targetProjectId = decodeURIComponent(targetProjectId); // Decode target project ID as well - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/links` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - target_project_id: targetProjectId, - target_issue_iid: targetIssueIid, - link_type: linkType, - }), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueLinkSchema.parse(data); -} - -/** - * Delete an issue link - * 이슈 관계 삭제 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {number} issueLinkId - The ID of the issue link - * @returns {Promise} - */ -async function deleteIssueLink( - projectId: string, - issueIid: number, - issueLinkId: number -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/issues/${issueIid}/links/${issueLinkId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - }); - - await handleGitLabError(response); -} - -/** - * Create a new merge request in a GitLab project - * 병합 요청 생성 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {z.infer} options - Merge request creation options - * @returns {Promise} The created merge request - */ -async function createMergeRequest( - projectId: string, - options: z.infer -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - title: options.title, - description: options.description, - source_branch: options.source_branch, - target_branch: options.target_branch, - assignee_ids: options.assignee_ids, - reviewer_ids: options.reviewer_ids, - labels: options.labels?.join(","), - allow_collaboration: options.allow_collaboration, - draft: options.draft, - remove_source_branch: options.remove_source_branch, - squash: options.squash, - }), - }); - - if (response.status === 400) { - const errorBody = await response.text(); - throw new Error(`Invalid request: ${errorBody}`); - } - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabMergeRequestSchema.parse(data); -} - -/** - * Shared helper function for listing discussions - * 토론 목록 조회를 위한 공유 헬퍼 함수 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {"issues" | "merge_requests"} resourceType - The type of resource (issues or merge_requests) - * @param {number} resourceIid - The IID of the issue or merge request - * @param {PaginationOptions} options - Pagination and sorting options - * @returns {Promise} Paginated list of discussions - */ -async function listDiscussions( - projectId: string, - resourceType: "issues" | "merge_requests", - resourceIid: number, - options: PaginationOptions = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/${resourceType}/${resourceIid}/discussions` - ); - - // Add query parameters for pagination and sorting - if (options.page) { - url.searchParams.append("page", options.page.toString()); - } - if (options.per_page) { - url.searchParams.append("per_page", options.per_page.toString()); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const discussions = await response.json(); - - // Extract pagination headers - const pagination = { - x_next_page: response.headers.get("x-next-page") - ? parseInt(response.headers.get("x-next-page")!) - : null, - x_page: response.headers.get("x-page") ? parseInt(response.headers.get("x-page")!) : undefined, - x_per_page: response.headers.get("x-per-page") - ? parseInt(response.headers.get("x-per-page")!) - : undefined, - x_prev_page: response.headers.get("x-prev-page") - ? parseInt(response.headers.get("x-prev-page")!) - : null, - x_total: response.headers.get("x-total") ? parseInt(response.headers.get("x-total")!) : null, - x_total_pages: response.headers.get("x-total-pages") - ? parseInt(response.headers.get("x-total-pages")!) - : null, - }; - - return PaginatedDiscussionsResponseSchema.parse({ - items: discussions, - pagination: pagination, - }); -} - -/** - * List merge request discussion items - * 병합 요청 토론 목록 조회 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The IID of a merge request - * @param {DiscussionPaginationOptions} options - Pagination and sorting options - * @returns {Promise} List of discussions - */ -async function listMergeRequestDiscussions( - projectId: string, - mergeRequestIid: number, - options: PaginationOptions = {} -): Promise { - return listDiscussions(projectId, "merge_requests", mergeRequestIid, options); -} - -/** - * List discussions for an issue - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {DiscussionPaginationOptions} options - Pagination and sorting options - * @returns {Promise} List of issue discussions - */ -async function listIssueDiscussions( - projectId: string, - issueIid: number, - options: PaginationOptions = {} -): Promise { - return listDiscussions(projectId, "issues", issueIid, options); -} - -/** - * Modify an existing merge request thread note - * 병합 요청 토론 노트 수정 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The IID of a merge request - * @param {string} discussionId - The ID of a thread - * @param {number} noteId - The ID of a thread note - * @param {string} body - The new content of the note - * @param {boolean} [resolved] - Resolve/unresolve state - * @returns {Promise} The updated note - */ -async function updateMergeRequestNote( - projectId: string, - mergeRequestIid: number, - discussionId: string, - noteId: number, - body?: string, - resolved?: boolean -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes/${noteId}` - ); - - // Only one of body or resolved can be sent according to GitLab API - const payload: { body?: string; resolved?: boolean } = {}; - if (body !== undefined) { - payload.body = body; - } else if (resolved !== undefined) { - payload.resolved = resolved; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionNoteSchema.parse(data); -} - -/** - * Update an issue discussion note - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The IID of an issue - * @param {string} discussionId - The ID of a thread - * @param {number} noteId - The ID of a thread note - * @param {string} body - The new content of the note - * @returns {Promise} The updated note - */ -async function updateIssueNote( - projectId: string, - issueIid: number, - discussionId: string, - noteId: number, - body: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/issues/${issueIid}/discussions/${discussionId}/notes/${noteId}` - ); - - const payload = { body }; - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionNoteSchema.parse(data); -} - -/** - * Create a note in an issue discussion - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The IID of an issue - * @param {string} discussionId - The ID of a thread - * @param {string} body - The content of the new note - * @param {string} [createdAt] - The creation date of the note (ISO 8601 format) - * @returns {Promise} The created note - */ -async function createIssueNote( - projectId: string, - issueIid: number, - discussionId: string, - body: string, - createdAt?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/issues/${issueIid}/discussions/${discussionId}/notes` - ); - - const payload: { body: string; created_at?: string } = { body }; - if (createdAt) { - payload.created_at = createdAt; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionNoteSchema.parse(data); -} - -/** - * Add a new note to an existing merge request thread - * 기존 병합 요청 스레드에 새 노트 추가 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The IID of a merge request - * @param {string} discussionId - The ID of a thread - * @param {string} body - The content of the new note - * @param {string} [createdAt] - The creation date of the note (ISO 8601 format) - * @returns {Promise} The created note - */ -async function createMergeRequestNote( - projectId: string, - mergeRequestIid: number, - discussionId: string, - body: string, - createdAt?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes` - ); - - const payload: { body: string; created_at?: string } = { body }; - if (createdAt) { - payload.created_at = createdAt; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionNoteSchema.parse(data); -} - -/** - * Create or update a file in a GitLab project - * 파일 생성 또는 업데이트 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} filePath - The path of the file to create or update - * @param {string} content - The content of the file - * @param {string} commitMessage - The commit message - * @param {string} branch - The branch name - * @param {string} [previousPath] - The previous path of the file in case of rename - * @returns {Promise} The file update response - */ -async function createOrUpdateFile( - projectId: string, - filePath: string, - content: string, - commitMessage: string, - branch: string, - previousPath?: string, - last_commit_id?: string, - commit_id?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const encodedPath = encodeURIComponent(filePath); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodedPath}` - ); - - const body: Record = { - branch, - content, - commit_message: commitMessage, - encoding: "text", - ...(previousPath ? { previous_path: previousPath } : {}), - }; - - // Check if file exists - let method = "POST"; - try { - // Get file contents to check existence and retrieve commit IDs - const fileData = await getFileContents(projectId, filePath, branch); - method = "PUT"; - - // If fileData is not an array, it's a file content object with commit IDs - if (!Array.isArray(fileData)) { - // Use commit IDs from the file data if not provided in parameters - if (!commit_id && fileData.commit_id) { - body.commit_id = fileData.commit_id; - } else if (commit_id) { - body.commit_id = commit_id; - } - - if (!last_commit_id && fileData.last_commit_id) { - body.last_commit_id = fileData.last_commit_id; - } else if (last_commit_id) { - body.last_commit_id = last_commit_id; - } - } - } catch (error) { - if (!(error instanceof Error && error.message.includes("File not found"))) { - throw error; - } - // File doesn't exist, use POST - no need for commit IDs for new files - // But still use any provided as parameters if they exist - if (commit_id) { - body.commit_id = commit_id; - } - if (last_commit_id) { - body.last_commit_id = last_commit_id; - } - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabCreateUpdateFileResponseSchema.parse(data); -} - -/** - * Create a tree structure in a GitLab project repository - * 저장소에 트리 구조 생성 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {FileOperation[]} files - Array of file operations - * @param {string} [ref] - The name of the branch, tag or commit - * @returns {Promise} The created tree - */ -async function createTree( - projectId: string, - files: FileOperation[], - ref?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/tree` - ); - - if (ref) { - url.searchParams.append("ref", ref); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - files: files.map(file => ({ - file_path: file.path, - content: file.content, - encoding: "text", - })), - }), - }); - - if (response.status === 400) { - const errorBody = await response.text(); - throw new Error(`Invalid request: ${errorBody}`); - } - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabTreeSchema.parse(data); -} - -/** - * Create a commit in a GitLab project repository - * 저장소에 커밋 생성 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} message - The commit message - * @param {string} branch - The branch name - * @param {FileOperation[]} actions - Array of file operations for the commit - * @returns {Promise} The created commit - */ -async function createCommit( - projectId: string, - message: string, - branch: string, - actions: FileOperation[] -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - branch, - commit_message: message, - actions: actions.map(action => ({ - action: "create", - file_path: action.path, - content: action.content, - encoding: "text", - })), - }), - }); - - if (response.status === 400) { - const errorBody = await response.text(); - throw new Error(`Invalid request: ${errorBody}`); - } - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabCommitSchema.parse(data); -} - -/** - * Search for GitLab projects - * 프로젝트 검색 - * - * @param {string} query - The search query - * @param {number} [page=1] - The page number - * @param {number} [perPage=20] - Number of items per page - * @returns {Promise} The search results - */ -async function searchProjects( - query: string, - page: number = 1, - perPage: number = 20 -): Promise { - const url = new URL(`${GITLAB_API_URL}/projects`); - url.searchParams.append("search", query); - url.searchParams.append("page", page.toString()); - url.searchParams.append("per_page", perPage.toString()); - url.searchParams.append("order_by", "id"); - url.searchParams.append("sort", "desc"); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const projects = (await response.json()) as GitLabRepository[]; - const totalCount = response.headers.get("x-total"); - const totalPages = response.headers.get("x-total-pages"); - - // GitLab API doesn't return these headers for results > 10,000 - const count = totalCount ? parseInt(totalCount) : projects.length; - - return GitLabSearchResponseSchema.parse({ - count, - total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage), - current_page: page, - items: projects, - }); -} - -/** - * Create a new GitLab repository - * 새 저장소 생성 - * - * @param {z.infer} options - Repository creation options - * @returns {Promise} The created repository - */ -async function createRepository( - options: z.infer -): Promise { - const response = await fetch(`${GITLAB_API_URL}/projects`, { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - name: options.name, - description: options.description, - visibility: options.visibility, - initialize_with_readme: options.initialize_with_readme, - default_branch: "main", - path: options.name.toLowerCase().replace(/\s+/g, "-"), - }), - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabRepositorySchema.parse(data); -} - -/** - * Get merge request details - * MR 조회 함수 (Function to retrieve merge request) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request (Optional) - * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Optional) - * @returns {Promise} The merge request details - */ -async function getMergeRequest( - projectId: string, - mergeRequestIid?: number, - branchName?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - let url: URL; - - if (mergeRequestIid) { - url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}` - ); - } else if (branchName) { - url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests?source_branch=${encodeURIComponent(branchName)}` - ); - } else { - throw new Error("Either mergeRequestIid or branchName must be provided"); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const data = await response.json(); - - // If response is an array (Comes from branchName search), return the first item if exist - if (Array.isArray(data) && data.length > 0) { - return GitLabMergeRequestSchema.parse(data[0]); - } - - return GitLabMergeRequestSchema.parse(data); -} - -/** - * Get merge request changes/diffs - * MR 변경사항 조회 함수 (Function to retrieve merge request changes) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request (Either mergeRequestIid or branchName must be provided) - * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Either mergeRequestIid or branchName must be provided) - * @param {string} [view] - The view type for the diff (inline or parallel) - * @returns {Promise} The merge request diffs - */ -async function getMergeRequestDiffs( - projectId: string, - mergeRequestIid?: number, - branchName?: string, - view?: "inline" | "parallel" -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - if (!mergeRequestIid && !branchName) { - throw new Error("Either mergeRequestIid or branchName must be provided"); - } - - if (branchName && !mergeRequestIid) { - const mergeRequest = await getMergeRequest(projectId, undefined, branchName); - mergeRequestIid = mergeRequest.iid; - } - - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/changes` - ); - - if (view) { - url.searchParams.append("view", view); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = (await response.json()) as { changes: unknown }; - return z.array(GitLabDiffSchema).parse(data.changes); -} - -/** - * Get merge request changes with detailed information including commits, diff_refs, and more - * 마지막으로 추가된 상세한 MR 변경사항 조회 함수 (Detailed merge request changes retrieval function) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request (Either mergeRequestIid or branchName must be provided) - * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Either mergeRequestIid or branchName must be provided) - * @param {boolean} [unidiff] - Return diff in unidiff format - * @returns {Promise} The complete merge request changes response - */ -async function listMergeRequestDiffs( - projectId: string, - mergeRequestIid?: number, - branchName?: string, - page?: number, - perPage?: number, - unidiff?: boolean -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - if (!mergeRequestIid && !branchName) { - throw new Error("Either mergeRequestIid or branchName must be provided"); - } - - if (branchName && !mergeRequestIid) { - const mergeRequest = await getMergeRequest(projectId, undefined, branchName); - mergeRequestIid = mergeRequest.iid; - } - - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/diffs` - ); - - if (page) { - url.searchParams.append("page", page.toString()); - } - - if (perPage) { - url.searchParams.append("per_page", perPage.toString()); - } - - if (unidiff) { - url.searchParams.append("unidiff", "true"); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - return await response.json(); // Return full response including commits, diff_refs, changes, etc. -} - -/** - * Get branch comparison diffs - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} from - The branch name or commit SHA to compare from - * @param {string} to - The branch name or commit SHA to compare to - * @param {boolean} [straight] - Comparison method: false for '...' (default), true for '--' - * @returns {Promise} Branch comparison results - */ -async function getBranchDiffs( - projectId: string, - from: string, - to: string, - straight?: boolean -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/compare` - ); - - url.searchParams.append("from", from); - url.searchParams.append("to", to); - - if (straight !== undefined) { - url.searchParams.append("straight", straight.toString()); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabCompareResultSchema.parse(data); -} - -/** - * Update a merge request - * MR 업데이트 함수 (Function to update merge request) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request (Optional) - * @param {string} branchName - The name of the branch to search for merge request by branch name (Optional) - * @param {Object} options - The update options - * @returns {Promise} The updated merge request - */ -async function updateMergeRequest( - projectId: string, - options: Omit< - z.infer, - "project_id" | "merge_request_iid" | "source_branch" - >, - mergeRequestIid?: number, - branchName?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - if (!mergeRequestIid && !branchName) { - throw new Error("Either mergeRequestIid or branchName must be provided"); - } - - if (branchName && !mergeRequestIid) { - const mergeRequest = await getMergeRequest(projectId, undefined, branchName); - mergeRequestIid = mergeRequest.iid; - } - - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(options), - }); - - await handleGitLabError(response); - return GitLabMergeRequestSchema.parse(await response.json()); -} - -/** - * Create a new note (comment) on an issue or merge request - * 📦 새로운 함수: createNote - 이슈 또는 병합 요청에 노트(댓글)를 추가하는 함수 - * (New function: createNote - Function to add a note (comment) to an issue or merge request) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {"issue" | "merge_request"} noteableType - The type of the item to add a note to (issue or merge_request) - * @param {number} noteableIid - The internal ID of the issue or merge request - * @param {string} body - The content of the note - * @returns {Promise} The created note - */ -async function createNote( - projectId: string, - noteableType: "issue" | "merge_request", // 'issue' 또는 'merge_request' 타입 명시 - noteableIid: number, - body: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능 - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ body }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); - } - - return await response.json(); -} - -/** - * Create a new thread on a merge request - * 📦 새로운 함수: createMergeRequestThread - 병합 요청에 새로운 스레드(토론)를 생성하는 함수 - * (New function: createMergeRequestThread - Function to create a new thread (discussion) on a merge request) - * - * This function provides more capabilities than createNote, including the ability to: - * - Create diff notes (comments on specific lines of code) - * - Specify exact positions for comments - * - Set creation timestamps - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request - * @param {string} body - The content of the thread - * @param {MergeRequestThreadPosition} [position] - Position information for diff notes - * @param {string} [createdAt] - ISO 8601 formatted creation date - * @returns {Promise} The created discussion thread - */ -async function createMergeRequestThread( - projectId: string, - mergeRequestIid: number, - body: string, - position?: MergeRequestThreadPosition, - createdAt?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/discussions` - ); - - const payload: Record = { body }; - - // Add optional parameters if provided - if (position) { - payload.position = position; - } - - if (createdAt) { - payload.created_at = createdAt; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionSchema.parse(data); -} - -/** - * 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(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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 { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(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(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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 { - // Construct the query parameters - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(options)) { - if (value !== undefined && value !== null) { - if (typeof value === "boolean") { - params.append(key, value ? "true" : "false"); - } else { - params.append(key, String(value)); - } - } - } - - // Make the API request - const response = await fetch(`${GITLAB_API_URL}/projects?${params.toString()}`, { - ...DEFAULT_FETCH_CONFIG, - }); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return z.array(GitLabProjectSchema).parse(data); -} - -/** - * List labels for a project - * - * @param projectId The ID or URL-encoded path of the project - * @param options Optional parameters for listing labels - * @returns Array of GitLab labels - */ -async function listLabels( - projectId: string, - options: Omit, "project_id"> = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // Construct the URL with project path - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels`); - - // Add query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (typeof value === "boolean") { - url.searchParams.append(key, value ? "true" : "false"); - } else { - url.searchParams.append(key, String(value)); - } - } - }); - - // Make the API request - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return data as GitLabLabel[]; -} - -/** - * Get a single label from a project - * - * @param projectId The ID or URL-encoded path of the project - * @param labelId The ID or name of the label - * @param includeAncestorGroups Whether to include ancestor groups - * @returns GitLab label - */ -async function getLabel( - projectId: string, - labelId: number | string, - includeAncestorGroups?: boolean -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/labels/${encodeURIComponent(String(labelId))}` - ); - - // Add query parameters - if (includeAncestorGroups !== undefined) { - url.searchParams.append("include_ancestor_groups", includeAncestorGroups ? "true" : "false"); - } - - // Make the API request - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return data as GitLabLabel; -} - -/** - * Create a new label in a project - * - * @param projectId The ID or URL-encoded path of the project - * @param options Options for creating the label - * @returns Created GitLab label - */ -async function createLabel( - projectId: string, - options: Omit, "project_id"> -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // Make the API request - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels`, - { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(options), - } - ); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return data as GitLabLabel; -} - -/** - * Update an existing label in a project - * - * @param projectId The ID or URL-encoded path of the project - * @param labelId The ID or name of the label to update - * @param options Options for updating the label - * @returns Updated GitLab label - */ -async function updateLabel( - projectId: string, - labelId: number | string, - options: Omit, "project_id" | "label_id"> -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // Make the API request - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/labels/${encodeURIComponent(String(labelId))}`, - { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(options), - } - ); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return data as GitLabLabel; -} - -/** - * Delete a label from a project - * - * @param projectId The ID or URL-encoded path of the project - * @param labelId The ID or name of the label to delete - */ -async function deleteLabel(projectId: string, labelId: number | string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // Make the API request - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/labels/${encodeURIComponent(String(labelId))}`, - { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - } - ); - - // Handle errors - await handleGitLabError(response); -} - -/** - * List all projects in a GitLab group - * - * @param {z.infer} options - Options for listing group projects - * @returns {Promise} Array of projects in the group - */ -async function listGroupProjects( - options: z.infer -): Promise { - const url = new URL(`${GITLAB_API_URL}/groups/${encodeURIComponent(options.group_id)}/projects`); - - // Add optional parameters to URL - if (options.include_subgroups) url.searchParams.append("include_subgroups", "true"); - if (options.search) url.searchParams.append("search", options.search); - if (options.order_by) url.searchParams.append("order_by", options.order_by); - if (options.sort) url.searchParams.append("sort", options.sort); - if (options.page) url.searchParams.append("page", options.page.toString()); - if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); - if (options.archived !== undefined) - url.searchParams.append("archived", options.archived.toString()); - if (options.visibility) url.searchParams.append("visibility", options.visibility); - if (options.with_issues_enabled !== undefined) - url.searchParams.append("with_issues_enabled", options.with_issues_enabled.toString()); - if (options.with_merge_requests_enabled !== undefined) - url.searchParams.append( - "with_merge_requests_enabled", - options.with_merge_requests_enabled.toString() - ); - if (options.min_access_level !== undefined) - url.searchParams.append("min_access_level", options.min_access_level.toString()); - if (options.with_programming_language) - url.searchParams.append("with_programming_language", options.with_programming_language); - if (options.starred !== undefined) url.searchParams.append("starred", options.starred.toString()); - if (options.statistics !== undefined) - url.searchParams.append("statistics", options.statistics.toString()); - if (options.with_custom_attributes !== undefined) - url.searchParams.append("with_custom_attributes", options.with_custom_attributes.toString()); - if (options.with_security_reports !== undefined) - url.searchParams.append("with_security_reports", options.with_security_reports.toString()); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const projects = await response.json(); - return GitLabProjectSchema.array().parse(projects); -} - -// Wiki API helper functions -/** - * List wiki pages in a project - */ -async function listWikiPages( - projectId: string, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis`); - if (options.page) url.searchParams.append("page", options.page.toString()); - if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); - if (options.with_content) - url.searchParams.append("with_content", options.with_content.toString()); - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabWikiPageSchema.array().parse(data); -} - -/** - * Get a specific wiki page - */ -async function getWikiPage(projectId: string, slug: string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, - { ...DEFAULT_FETCH_CONFIG } - ); - await handleGitLabError(response); - const data = await response.json(); - return GitLabWikiPageSchema.parse(data); -} - -/** - * Create a new wiki page - */ -async function createWikiPage( - projectId: string, - title: string, - content: string, - format?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const body: Record = { title, content }; - if (format) body.format = format; - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis`, - { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(body), - } - ); - await handleGitLabError(response); - const data = await response.json(); - return GitLabWikiPageSchema.parse(data); -} - -/** - * Update an existing wiki page - */ -async function updateWikiPage( - projectId: string, - slug: string, - title?: string, - content?: string, - format?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const body: Record = {}; - if (title) body.title = title; - if (content) body.content = content; - if (format) body.format = format; - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, - { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(body), - } - ); - await handleGitLabError(response); - const data = await response.json(); - return GitLabWikiPageSchema.parse(data); -} - -/** - * Delete a wiki page - */ -async function deleteWikiPage(projectId: string, slug: string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, - { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - } - ); - await handleGitLabError(response); -} - -/** - * List pipelines in a GitLab project - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {ListPipelinesOptions} options - Options for filtering pipelines - * @returns {Promise} List of pipelines - */ -async function listPipelines( - projectId: string, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines`); - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - url.searchParams.append(key, value.toString()); - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabPipelineSchema).parse(data); -} - -/** - * Get details of a specific pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline - * @returns {Promise} Pipeline details - */ -async function getPipeline(projectId: string, pipelineId: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (response.status === 404) { - throw new Error(`Pipeline not found`); - } - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineSchema.parse(data); -} - -/** - * List all jobs in a specific pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline - * @param {Object} options - Options for filtering jobs - * @returns {Promise} List of pipeline jobs - */ -async function listPipelineJobs( - projectId: string, - pipelineId: number, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/jobs` - ); - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (typeof value === "boolean") { - url.searchParams.append(key, value ? "true" : "false"); - } else { - url.searchParams.append(key, value.toString()); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (response.status === 404) { - throw new Error(`Pipeline not found`); - } - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabPipelineJobSchema).parse(data); -} -async function getPipelineJob(projectId: string, jobId: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (response.status === 404) { - throw new Error(`Job not found`); - } - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineJobSchema.parse(data); -} - -/** - * Get the output/trace of a pipeline job - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} jobId - The ID of the job - * @param {number} limit - Maximum number of lines to return from the end (default: 1000) - * @param {number} offset - Number of lines to skip from the end (default: 0) - * @returns {Promise} The job output/trace - */ -async function getPipelineJobOutput(projectId: string, jobId: number, limit?: number, offset?: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/trace` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - headers: { - ...DEFAULT_HEADERS, - Accept: "text/plain", // Override Accept header to get plain text - }, - }); - - if (response.status === 404) { - throw new Error(`Job trace not found or job is not finished yet`); - } - - await handleGitLabError(response); - const fullTrace = await response.text(); - - // Apply client-side pagination to limit context window usage - if (limit !== undefined || offset !== undefined) { - const lines = fullTrace.split('\n'); - const startOffset = offset || 0; - const maxLines = limit || 1000; - - // Return lines from the end, skipping offset lines and limiting to maxLines - const startIndex = Math.max(0, lines.length - startOffset - maxLines); - const endIndex = lines.length - startOffset; - - const selectedLines = lines.slice(startIndex, endIndex); - const result = selectedLines.join('\n'); - - // Add metadata about truncation - if (startIndex > 0 || endIndex < lines.length) { - const totalLines = lines.length; - const shownLines = selectedLines.length; - const skippedFromStart = startIndex; - const skippedFromEnd = startOffset; - - return `[Log truncated: showing ${shownLines} of ${totalLines} lines, skipped ${skippedFromStart} from start, ${skippedFromEnd} from end]\n\n${result}`; - } - - return result; - } - - return fullTrace; -} - -/** - * Create a new pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} ref - The branch or tag to run the pipeline on - * @param {Array} variables - Optional variables for the pipeline - * @returns {Promise} The created pipeline - */ -async function createPipeline( - projectId: string, - ref: string, - variables?: Array<{ key: string; value: string }> -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipeline`); - - const body: any = { ref }; - if (variables && variables.length > 0) { - body.variables = variables.reduce( - (acc, { key, value }) => { - acc[key] = value; - return acc; - }, - {} as Record - ); - } - - const response = await fetch(url.toString(), { - method: "POST", - headers: DEFAULT_HEADERS, - body: JSON.stringify(body), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineSchema.parse(data); -} - -/** - * Retry a pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline to retry - * @returns {Promise} The retried pipeline - */ -async function retryPipeline(projectId: string, pipelineId: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry` - ); - - const response = await fetch(url.toString(), { - method: "POST", - headers: DEFAULT_HEADERS, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineSchema.parse(data); -} - -/** - * Cancel a pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline to cancel - * @returns {Promise} The canceled pipeline - */ -async function cancelPipeline(projectId: string, pipelineId: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel` - ); - - const response = await fetch(url.toString(), { - method: "POST", - headers: DEFAULT_HEADERS, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineSchema.parse(data); -} - -/** - * Get the repository tree for a project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {GetRepositoryTreeOptions} options - Options for the tree - * @returns {Promise} - */ -async function getRepositoryTree(options: GetRepositoryTreeOptions): Promise { - options.project_id = decodeURIComponent(options.project_id); // Decode project_id within options - const queryParams = new URLSearchParams(); - if (options.path) queryParams.append("path", options.path); - if (options.ref) queryParams.append("ref", options.ref); - if (options.recursive) queryParams.append("recursive", "true"); - if (options.per_page) queryParams.append("per_page", options.per_page.toString()); - if (options.page_token) queryParams.append("page_token", options.page_token); - if (options.pagination) queryParams.append("pagination", options.pagination); - - const headers: Record = { - "Content-Type": "application/json", - }; - if (IS_OLD) { - headers["Private-Token"] = `${GITLAB_PERSONAL_ACCESS_TOKEN}`; - } else { - headers["Authorization"] = `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`; - } - const response = await fetch( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - options.project_id - )}/repository/tree?${queryParams.toString()}`, - { - headers, - } - ); - - if (response.status === 404) { - throw new Error("Repository or path not found"); - } - - if (!response.ok) { - throw new Error(`Failed to get repository tree: ${response.statusText}`); - } - - const data = await response.json(); - return z.array(GitLabTreeItemSchema).parse(data); -} - -/** - * List project milestones in a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {Object} options - Options for listing milestones - * @returns {Promise} List of milestones - */ -async function listProjectMilestones( - projectId: string, - options: Omit, "project_id"> -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones`); - - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (key === "iids" && Array.isArray(value) && value.length > 0) { - value.forEach(iid => { - url.searchParams.append("iids[]", iid.toString()); - }); - } else if (value !== undefined) { - url.searchParams.append(key, value.toString()); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabMilestonesSchema).parse(data); -} - -/** - * Get a single milestone in a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} Milestone details - */ -async function getProjectMilestone( - projectId: string, - milestoneId: number -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabMilestonesSchema.parse(data); -} - -/** - * Create a new milestone in a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {Object} options - Options for creating a milestone - * @returns {Promise} Created milestone - */ -async function createProjectMilestone( - projectId: string, - options: Omit, "project_id"> -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(options), - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabMilestonesSchema.parse(data); -} - -/** - * Edit an existing milestone in a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @param {Object} options - Options for editing a milestone - * @returns {Promise} Updated milestone - */ -async function editProjectMilestone( - projectId: string, - milestoneId: number, - options: Omit, "project_id" | "milestone_id"> -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(options), - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabMilestonesSchema.parse(data); -} - -/** - * Delete a milestone from a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} - */ -async function deleteProjectMilestone(projectId: string, milestoneId: number): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - }); - await handleGitLabError(response); -} - -/** - * Get all issues assigned to a single milestone - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} List of issues - */ -async function getMilestoneIssues(projectId: string, milestoneId: number): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/issues` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabIssueSchema).parse(data); -} - -/** - * Get all merge requests assigned to a single milestone - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} List of merge requests - */ -async function getMilestoneMergeRequests( - projectId: string, - milestoneId: number -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/milestones/${milestoneId}/merge_requests` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabMergeRequestSchema).parse(data); -} - -/** - * Promote a project milestone to a group milestone - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} Promoted milestone - */ -async function promoteProjectMilestone( - projectId: string, - milestoneId: number -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/promote` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabMilestonesSchema.parse(data); -} - -/** - * Get all burndown chart events for a single milestone - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} Burndown chart events - */ -async function getMilestoneBurndownEvents(projectId: string, milestoneId: number): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/milestones/${milestoneId}/burndown_events` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return data as any[]; -} - -/** - * Get a single user from GitLab - * - * @param {string} username - The username to look up - * @returns {Promise} The user data or null if not found - */ -async function getUser(username: string): Promise { - try { - const url = new URL(`${GITLAB_API_URL}/users`); - url.searchParams.append("username", username); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const users = await response.json(); - - // GitLab returns an array of users that match the username - if (Array.isArray(users) && users.length > 0) { - // Find exact match for username (case-sensitive) - const exactMatch = users.find(user => user.username === username); - if (exactMatch) { - return GitLabUserSchema.parse(exactMatch); - } - } - - // No matching user found - return null; - } catch (error) { - console.error(`Error fetching user by username '${username}':`, error); - return null; - } -} - -/** - * Get multiple users from GitLab - * - * @param {string[]} usernames - Array of usernames to look up - * @returns {Promise} Object with usernames as keys and user objects or null as values - */ -async function getUsers(usernames: string[]): Promise { - const users: Record = {}; - - // Process usernames sequentially to avoid rate limiting - for (const username of usernames) { - try { - const user = await getUser(username); - users[username] = user; - } catch (error) { - console.error(`Error processing username '${username}':`, error); - users[username] = null; - } - } - - return GitLabUsersResponseSchema.parse(users); -} - -/** - * List repository commits - * 저장소 커밋 목록 조회 - * - * @param {string} projectId - Project ID or URL-encoded path - * @param {ListCommitsOptions} options - List commits options - * @returns {Promise} List of commits - */ -async function listCommits( - projectId: string, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits` - ); - - // Add query parameters - if (options.ref_name) url.searchParams.append("ref_name", options.ref_name); - if (options.since) url.searchParams.append("since", options.since); - if (options.until) url.searchParams.append("until", options.until); - if (options.path) url.searchParams.append("path", options.path); - if (options.author) url.searchParams.append("author", options.author); - if (options.all) url.searchParams.append("all", options.all.toString()); - if (options.with_stats) url.searchParams.append("with_stats", options.with_stats.toString()); - if (options.first_parent) url.searchParams.append("first_parent", options.first_parent.toString()); - if (options.order) url.searchParams.append("order", options.order); - if (options.trailers) url.searchParams.append("trailers", options.trailers.toString()); - if (options.page) url.searchParams.append("page", options.page.toString()); - if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const data = await response.json(); - return z.array(GitLabCommitSchema).parse(data); -} - -/** - * Get a single commit - * 단일 커밋 정보 조회 - * - * @param {string} projectId - Project ID or URL-encoded path - * @param {string} sha - The commit hash or name of a repository branch or tag - * @param {boolean} [stats] - Include commit stats - * @returns {Promise} The commit details - */ -async function getCommit( - projectId: string, - sha: string, - stats?: boolean -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}` - ); - - if (stats) { - url.searchParams.append("stats", "true"); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const data = await response.json(); - return GitLabCommitSchema.parse(data); -} - -/** - * Get commit diff - * 커밋 변경사항 조회 - * - * @param {string} projectId - Project ID or URL-encoded path - * @param {string} sha - The commit hash or name of a repository branch or tag - * @returns {Promise} The commit diffs - */ -async function getCommitDiff( - projectId: string, - sha: string -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}/diff` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const data = await response.json(); - return z.array(GitLabDiffSchema).parse(data); -} - -server.setRequestHandler(ListToolsRequestSchema, async () => { - // Apply read-only filter first - const tools0 = GITLAB_READ_ONLY_MODE - ? allTools.filter(tool => readOnlyTools.includes(tool.name)) - : allTools; - // Toggle wiki tools by USE_GITLAB_WIKI flag - const tools1 = USE_GITLAB_WIKI - ? tools0 - : tools0.filter(tool => !wikiToolNames.includes(tool.name)); - // Toggle milestone tools by USE_MILESTONE flag - const tools2 = USE_MILESTONE - ? tools1 - : tools1.filter(tool => !milestoneToolNames.includes(tool.name)); - // Toggle pipeline tools by USE_PIPELINE flag - let tools = USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.includes(tool.name)); - - // <<< START: Gemini 호환성을 위해 $schema 제거 >>> - tools = tools.map(tool => { - // inputSchema가 존재하고 객체인지 확인 - if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) { - // $schema 키가 존재하면 삭제 - if ("$schema" in tool.inputSchema) { - // 불변성을 위해 새로운 객체 생성 (선택적이지만 권장) - const modifiedSchema = { ...tool.inputSchema }; - delete modifiedSchema.$schema; - return { ...tool, inputSchema: modifiedSchema }; - } - } - // 변경이 필요 없으면 그대로 반환 - return tool; - }); - // <<< END: Gemini 호환성을 위해 $schema 제거 >>> - - return { - tools, // $schema가 제거된 도구 목록 반환 - }; -}); - -server.setRequestHandler(CallToolRequestSchema, async request => { - try { - if (!request.params.arguments) { - throw new Error("Arguments are required"); - } - - // Ensure session is established for every request if cookie authentication is enabled - if (GITLAB_AUTH_COOKIE_PATH) { - await ensureSessionForRequest(); - } - - switch (request.params.name) { - case "fork_repository": { - if (GITLAB_PROJECT_ID) { - throw new Error("Direct project ID is set. So fork_repository is not allowed"); - } - const forkArgs = ForkRepositorySchema.parse(request.params.arguments); - try { - const forkedProject = await forkProject(forkArgs.project_id, forkArgs.namespace); - return { - content: [{ type: "text", text: JSON.stringify(forkedProject, null, 2) }], - }; - } catch (forkError) { - console.error("Error forking repository:", forkError); - let forkErrorMessage = "Failed to fork repository"; - if (forkError instanceof Error) { - forkErrorMessage = `${forkErrorMessage}: ${forkError.message}`; - } - return { - content: [ - { - type: "text", - text: JSON.stringify({ error: forkErrorMessage }, null, 2), - }, - ], - }; - } - } - - case "create_branch": { - const args = CreateBranchSchema.parse(request.params.arguments); - let ref = args.ref; - if (!ref) { - ref = await getDefaultBranchRef(args.project_id); - } - - const branch = await createBranch(args.project_id, { - name: args.branch, - ref, - }); - - return { - content: [{ type: "text", text: JSON.stringify(branch, null, 2) }], - }; - } - - case "get_branch_diffs": { - const args = GetBranchDiffsSchema.parse(request.params.arguments); - const diffResp = await getBranchDiffs(args.project_id, args.from, args.to, args.straight); - - if (args.excluded_file_patterns?.length) { - const regexPatterns = args.excluded_file_patterns.map(pattern => new RegExp(pattern)); - - // Helper function to check if a path matches any regex pattern - const matchesAnyPattern = (path: string): boolean => { - if (!path) return false; - return regexPatterns.some(regex => regex.test(path)); - }; - - // Filter out files that match any of the regex patterns on new files - diffResp.diffs = diffResp.diffs.filter(diff => !matchesAnyPattern(diff.new_path)); - } - return { - content: [{ type: "text", text: JSON.stringify(diffResp, null, 2) }], - }; - } - - case "search_repositories": { - const args = SearchRepositoriesSchema.parse(request.params.arguments); - const results = await searchProjects(args.search, args.page, args.per_page); - return { - content: [{ type: "text", text: JSON.stringify(results, null, 2) }], - }; - } - - case "create_repository": { - if (GITLAB_PROJECT_ID) { - throw new Error("Direct project ID is set. So fork_repository is not allowed"); - } - const args = CreateRepositorySchema.parse(request.params.arguments); - const repository = await createRepository(args); - return { - content: [{ type: "text", text: JSON.stringify(repository, null, 2) }], - }; - } - - case "get_file_contents": { - const args = GetFileContentsSchema.parse(request.params.arguments); - const contents = await getFileContents(args.project_id, args.file_path, args.ref); - return { - content: [{ type: "text", text: JSON.stringify(contents, null, 2) }], - }; - } - - case "create_or_update_file": { - const args = CreateOrUpdateFileSchema.parse(request.params.arguments); - const result = await createOrUpdateFile( - args.project_id, - args.file_path, - args.content, - args.commit_message, - args.branch, - args.previous_path, - args.last_commit_id, - args.commit_id - ); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "push_files": { - const args = PushFilesSchema.parse(request.params.arguments); - const result = await createCommit( - args.project_id, - args.commit_message, - args.branch, - args.files.map(f => ({ path: f.file_path, content: f.content })) - ); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "create_issue": { - const args = CreateIssueSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const issue = await createIssue(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], - }; - } - - case "create_merge_request": { - const args = CreateMergeRequestSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const mergeRequest = await createMergeRequest(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], - }; - } - - case "update_merge_request_note": { - const args = UpdateMergeRequestNoteSchema.parse(request.params.arguments); - const note = await updateMergeRequestNote( - args.project_id, - args.merge_request_iid, - args.discussion_id, - args.note_id, - args.body, // Now optional - args.resolved // Now one of body or resolved must be provided, not both - ); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } - - case "create_merge_request_note": { - const args = CreateMergeRequestNoteSchema.parse(request.params.arguments); - const note = await createMergeRequestNote( - args.project_id, - args.merge_request_iid, - args.discussion_id, - args.body, - args.created_at - ); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } - - case "update_issue_note": { - const args = UpdateIssueNoteSchema.parse(request.params.arguments); - const note = await updateIssueNote( - args.project_id, - args.issue_iid, - args.discussion_id, - args.note_id, - args.body - ); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } - - case "create_issue_note": { - const args = CreateIssueNoteSchema.parse(request.params.arguments); - const note = await createIssueNote( - args.project_id, - args.issue_iid, - args.discussion_id, - args.body, - args.created_at - ); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } - - case "get_merge_request": { - const args = GetMergeRequestSchema.parse(request.params.arguments); - const mergeRequest = await getMergeRequest( - args.project_id, - args.merge_request_iid, - args.source_branch - ); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], - }; - } - - case "get_merge_request_diffs": { - const args = GetMergeRequestDiffsSchema.parse(request.params.arguments); - const diffs = await getMergeRequestDiffs( - args.project_id, - args.merge_request_iid, - args.source_branch, - args.view - ); - return { - content: [{ type: "text", text: JSON.stringify(diffs, null, 2) }], - }; - } - - case "list_merge_request_diffs": { - const args = ListMergeRequestDiffsSchema.parse(request.params.arguments); - const changes = await listMergeRequestDiffs( - args.project_id, - args.merge_request_iid, - args.source_branch, - args.page, - args.per_page, - args.unidiff - ); - return { - content: [{ type: "text", text: JSON.stringify(changes, null, 2) }], - }; - } - - case "update_merge_request": { - const args = UpdateMergeRequestSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, source_branch, ...options } = args; - const mergeRequest = await updateMergeRequest( - project_id, - options, - merge_request_iid, - source_branch - ); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], - }; - } - - case "mr_discussions": { - const args = ListMergeRequestDiscussionsSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, ...options } = args; - const discussions = await listMergeRequestDiscussions( - project_id, - merge_request_iid, - options - ); - return { - content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], - }; - } - - 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(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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 projects = await listProjects(args); - - return { - content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], - }; - } - - case "get_users": { - const args = GetUsersSchema.parse(request.params.arguments); - const usersMap = await getUsers(args.usernames); - - return { - content: [{ type: "text", text: JSON.stringify(usersMap, null, 2) }], - }; - } - - case "create_note": { - const args = CreateNoteSchema.parse(request.params.arguments); - const { project_id, noteable_type, noteable_iid, body } = args; - - const note = await createNote(project_id, noteable_type, noteable_iid, body); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } - - case "create_merge_request_thread": { - const args = CreateMergeRequestThreadSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, body, position, created_at } = args; - - const thread = await createMergeRequestThread( - project_id, - merge_request_iid, - body, - position, - created_at - ); - return { - content: [{ type: "text", text: JSON.stringify(thread, null, 2) }], - }; - } - - case "list_issues": { - const args = ListIssuesSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const issues = await listIssues(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(issues, null, 2) }], - }; - } - - case "get_issue": { - const args = GetIssueSchema.parse(request.params.arguments); - const issue = await getIssue(args.project_id, args.issue_iid); - return { - content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], - }; - } - - case "update_issue": { - const args = UpdateIssueSchema.parse(request.params.arguments); - const { project_id, issue_iid, ...options } = args; - const issue = await updateIssue(project_id, issue_iid, options); - return { - content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], - }; - } - - case "delete_issue": { - const args = DeleteIssueSchema.parse(request.params.arguments); - await deleteIssue(args.project_id, args.issue_iid); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { status: "success", message: "Issue deleted successfully" }, - null, - 2 - ), - }, - ], - }; - } - - case "list_issue_links": { - const args = ListIssueLinksSchema.parse(request.params.arguments); - const links = await listIssueLinks(args.project_id, args.issue_iid); - return { - content: [{ type: "text", text: JSON.stringify(links, null, 2) }], - }; - } - - case "list_issue_discussions": { - const args = ListIssueDiscussionsSchema.parse(request.params.arguments); - const { project_id, issue_iid, ...options } = args; - - const discussions = await listIssueDiscussions(project_id, issue_iid, options); - return { - content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], - }; - } - - case "get_issue_link": { - const args = GetIssueLinkSchema.parse(request.params.arguments); - const link = await getIssueLink(args.project_id, args.issue_iid, args.issue_link_id); - return { - content: [{ type: "text", text: JSON.stringify(link, null, 2) }], - }; - } - - case "create_issue_link": { - const args = CreateIssueLinkSchema.parse(request.params.arguments); - const link = await createIssueLink( - args.project_id, - args.issue_iid, - args.target_project_id, - args.target_issue_iid, - args.link_type - ); - return { - content: [{ type: "text", text: JSON.stringify(link, null, 2) }], - }; - } - - case "delete_issue_link": { - const args = DeleteIssueLinkSchema.parse(request.params.arguments); - await deleteIssueLink(args.project_id, args.issue_iid, args.issue_link_id); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - status: "success", - message: "Issue link deleted successfully", - }, - null, - 2 - ), - }, - ], - }; - } - - case "list_labels": { - const args = ListLabelsSchema.parse(request.params.arguments); - const labels = await listLabels(args.project_id, args); - return { - content: [{ type: "text", text: JSON.stringify(labels, null, 2) }], - }; - } - - case "get_label": { - const args = GetLabelSchema.parse(request.params.arguments); - const label = await getLabel(args.project_id, args.label_id, args.include_ancestor_groups); - return { - content: [{ type: "text", text: JSON.stringify(label, null, 2) }], - }; - } - - case "create_label": { - const args = CreateLabelSchema.parse(request.params.arguments); - const label = await createLabel(args.project_id, args); - return { - content: [{ type: "text", text: JSON.stringify(label, null, 2) }], - }; - } - - case "update_label": { - const args = UpdateLabelSchema.parse(request.params.arguments); - const { project_id, label_id, ...options } = args; - const label = await updateLabel(project_id, label_id, options); - return { - content: [{ type: "text", text: JSON.stringify(label, null, 2) }], - }; - } - - case "delete_label": { - const args = DeleteLabelSchema.parse(request.params.arguments); - await deleteLabel(args.project_id, args.label_id); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { status: "success", message: "Label deleted successfully" }, - null, - 2 - ), - }, - ], - }; - } - - case "list_group_projects": { - const args = ListGroupProjectsSchema.parse(request.params.arguments); - const projects = await listGroupProjects(args); - return { - content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], - }; - } - - case "list_wiki_pages": { - const { project_id, page, per_page, with_content } = ListWikiPagesSchema.parse( - request.params.arguments - ); - const wikiPages = await listWikiPages(project_id, { page, per_page, with_content }); - return { - content: [{ type: "text", text: JSON.stringify(wikiPages, null, 2) }], - }; - } - - case "get_wiki_page": { - const { project_id, slug } = GetWikiPageSchema.parse(request.params.arguments); - const wikiPage = await getWikiPage(project_id, slug); - return { - content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], - }; - } - - case "create_wiki_page": { - const { project_id, title, content, format } = CreateWikiPageSchema.parse( - request.params.arguments - ); - const wikiPage = await createWikiPage(project_id, title, content, format); - return { - content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], - }; - } - - case "update_wiki_page": { - const { project_id, slug, title, content, format } = UpdateWikiPageSchema.parse( - request.params.arguments - ); - const wikiPage = await updateWikiPage(project_id, slug, title, content, format); - return { - content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], - }; - } - - case "delete_wiki_page": { - const { project_id, slug } = DeleteWikiPageSchema.parse(request.params.arguments); - await deleteWikiPage(project_id, slug); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - status: "success", - message: "Wiki page deleted successfully", - }, - null, - 2 - ), - }, - ], - }; - } - - case "get_repository_tree": { - const args = GetRepositoryTreeSchema.parse(request.params.arguments); - const tree = await getRepositoryTree(args); - return { - content: [{ type: "text", text: JSON.stringify(tree, null, 2) }], - }; - } - - case "list_pipelines": { - const args = ListPipelinesSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const pipelines = await listPipelines(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(pipelines, null, 2) }], - }; - } - - case "get_pipeline": { - const { project_id, pipeline_id } = GetPipelineSchema.parse(request.params.arguments); - const pipeline = await getPipeline(project_id, pipeline_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(pipeline, null, 2), - }, - ], - }; - } - - case "list_pipeline_jobs": { - const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse( - request.params.arguments - ); - const jobs = await listPipelineJobs(project_id, pipeline_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(jobs, null, 2), - }, - ], - }; - } - - case "get_pipeline_job": { - const { project_id, job_id } = GetPipelineJobOutputSchema.parse(request.params.arguments); - const jobDetails = await getPipelineJob(project_id, job_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(jobDetails, null, 2), - }, - ], - }; - } - - case "get_pipeline_job_output": { - const { project_id, job_id, limit, offset } = GetPipelineJobOutputSchema.parse(request.params.arguments); - const jobOutput = await getPipelineJobOutput(project_id, job_id, limit, offset); - return { - content: [ - { - type: "text", - text: jobOutput, - }, - ], - }; - } - - case "create_pipeline": { - const { project_id, ref, variables } = CreatePipelineSchema.parse(request.params.arguments); - const pipeline = await createPipeline(project_id, ref, variables); - return { - content: [ - { - type: "text", - text: `Created pipeline #${pipeline.id} for ${ref}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, - }, - ], - }; - } - - case "retry_pipeline": { - const { project_id, pipeline_id } = RetryPipelineSchema.parse(request.params.arguments); - const pipeline = await retryPipeline(project_id, pipeline_id); - return { - content: [ - { - type: "text", - text: `Retried pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, - }, - ], - }; - } - - case "cancel_pipeline": { - const { project_id, pipeline_id } = CancelPipelineSchema.parse(request.params.arguments); - const pipeline = await cancelPipeline(project_id, pipeline_id); - return { - content: [ - { - type: "text", - text: `Canceled pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, - }, - ], - }; - } - - case "list_merge_requests": { - const args = ListMergeRequestsSchema.parse(request.params.arguments); - const mergeRequests = await listMergeRequests(args.project_id, args); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }], - }; - } - - case "list_milestones": { - const { project_id, ...options } = ListProjectMilestonesSchema.parse( - request.params.arguments - ); - const milestones = await listProjectMilestones(project_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestones, null, 2), - }, - ], - }; - } - - case "get_milestone": { - const { project_id, milestone_id } = GetProjectMilestoneSchema.parse( - request.params.arguments - ); - const milestone = await getProjectMilestone(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestone, null, 2), - }, - ], - }; - } - - case "create_milestone": { - const { project_id, ...options } = CreateProjectMilestoneSchema.parse( - request.params.arguments - ); - const milestone = await createProjectMilestone(project_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestone, null, 2), - }, - ], - }; - } - - case "edit_milestone": { - const { project_id, milestone_id, ...options } = EditProjectMilestoneSchema.parse( - request.params.arguments - ); - const milestone = await editProjectMilestone(project_id, milestone_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestone, null, 2), - }, - ], - }; - } - - case "delete_milestone": { - const { project_id, milestone_id } = DeleteProjectMilestoneSchema.parse( - request.params.arguments - ); - await deleteProjectMilestone(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - status: "success", - message: "Milestone deleted successfully", - }, - null, - 2 - ), - }, - ], - }; - } - - case "get_milestone_issue": { - const { project_id, milestone_id } = GetMilestoneIssuesSchema.parse( - request.params.arguments - ); - const issues = await getMilestoneIssues(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(issues, null, 2), - }, - ], - }; - } - - case "get_milestone_merge_requests": { - const { project_id, milestone_id } = GetMilestoneMergeRequestsSchema.parse( - request.params.arguments - ); - const mergeRequests = await getMilestoneMergeRequests(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(mergeRequests, null, 2), - }, - ], - }; - } - - case "promote_milestone": { - const { project_id, milestone_id } = PromoteProjectMilestoneSchema.parse( - request.params.arguments - ); - const milestone = await promoteProjectMilestone(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestone, null, 2), - }, - ], - }; - } - - case "get_milestone_burndown_events": { - const { project_id, milestone_id } = GetMilestoneBurndownEventsSchema.parse( - request.params.arguments - ); - const events = await getMilestoneBurndownEvents(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(events, null, 2), - }, - ], - }; - } - - case "list_commits": { - const args = ListCommitsSchema.parse(request.params.arguments); - const commits = await listCommits(args.project_id, args); - return { - content: [{ type: "text", text: JSON.stringify(commits, null, 2) }], - }; - } - - case "get_commit": { - const args = GetCommitSchema.parse(request.params.arguments); - const commit = await getCommit(args.project_id, args.sha, args.stats); - return { - content: [{ type: "text", text: JSON.stringify(commit, null, 2) }], - }; - } - - case "get_commit_diff": { - const args = GetCommitDiffSchema.parse(request.params.arguments); - const diff = await getCommitDiff(args.project_id, args.sha); - return { - content: [{ type: "text", text: JSON.stringify(diff, null, 2) }], - }; - } - - default: - throw new Error(`Unknown tool: ${request.params.name}`); - } - } catch (error) { - if (error instanceof z.ZodError) { - throw new Error( - `Invalid arguments: ${error.errors - .map(e => `${e.path.join(".")}: ${e.message}`) - .join(", ")}` - ); - } - throw error; - } -}); - /** * Initialize and run the server * 서버 초기화 및 실행 @@ -4326,7 +24,7 @@ async function runServer() { // Server startup banner removed - inappropriate use of console.error for logging if (!SSE) { const transport = new StdioServerTransport(); - await server.connect(transport); + await mcpserver.connect(transport); } else { const app = express(); const transports: { [sessionId: string]: SSEServerTransport } = {}; @@ -4336,7 +34,7 @@ async function runServer() { res.on("close", () => { delete transports[transport.sessionId]; }); - await server.connect(transport); + await mcpserver.connect(transport); }); app.post("/messages", async (req: Request, res: Response) => { diff --git a/mcpserver.ts b/mcpserver.ts new file mode 100644 index 0000000..7d6e862 --- /dev/null +++ b/mcpserver.ts @@ -0,0 +1,4141 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; +import fs from "fs"; +import path from "path"; +// Add type imports for proxy agents +import { URL } from "url"; + +import {config} from "./config.js" +import {DEFAULT_HEADERS, ensureSessionForRequest, fetch} from "./agent.js" + +import { + GitLabForkSchema, + GitLabReferenceSchema, + GitLabRepositorySchema, + GitLabIssueSchema, + GitLabMergeRequestSchema, + GitLabContentSchema, + GitLabCreateUpdateFileResponseSchema, + GitLabSearchResponseSchema, + GitLabTreeSchema, + GitLabCommitSchema, + GitLabNamespaceSchema, + GitLabNamespaceExistsResponseSchema, + GitLabProjectSchema, + GitLabLabelSchema, + GitLabUserSchema, + GitLabUsersResponseSchema, + GetUsersSchema, + CreateRepositoryOptionsSchema, + CreateIssueOptionsSchema, + CreateMergeRequestOptionsSchema, + CreateBranchOptionsSchema, + CreateOrUpdateFileSchema, + SearchRepositoriesSchema, + CreateRepositorySchema, + GetFileContentsSchema, + PushFilesSchema, + CreateIssueSchema, + CreateMergeRequestSchema, + ForkRepositorySchema, + CreateBranchSchema, + GitLabDiffSchema, + GetMergeRequestSchema, + GetMergeRequestDiffsSchema, + UpdateMergeRequestSchema, + ListIssuesSchema, + GetIssueSchema, + UpdateIssueSchema, + DeleteIssueSchema, + GitLabIssueLinkSchema, + GitLabIssueWithLinkDetailsSchema, + ListIssueLinksSchema, + ListIssueDiscussionsSchema, + GetIssueLinkSchema, + CreateIssueLinkSchema, + DeleteIssueLinkSchema, + ListNamespacesSchema, + GetNamespaceSchema, + VerifyNamespaceSchema, + GetProjectSchema, + ListProjectsSchema, + ListLabelsSchema, + GetLabelSchema, + CreateLabelSchema, + UpdateLabelSchema, + DeleteLabelSchema, + CreateNoteSchema, + CreateMergeRequestThreadSchema, + ListGroupProjectsSchema, + ListWikiPagesSchema, + GetWikiPageSchema, + CreateWikiPageSchema, + UpdateWikiPageSchema, + DeleteWikiPageSchema, + GitLabWikiPageSchema, + GetRepositoryTreeSchema, + GitLabTreeItemSchema, + GitLabPipelineSchema, + GetPipelineSchema, + ListPipelinesSchema, + ListPipelineJobsSchema, + CreatePipelineSchema, + RetryPipelineSchema, + CancelPipelineSchema, + // pipeline job schemas + GetPipelineJobOutputSchema, + GitLabPipelineJobSchema, + // Discussion Schemas + GitLabDiscussionNoteSchema, // Added + GitLabDiscussionSchema, + PaginatedDiscussionsResponseSchema, + UpdateMergeRequestNoteSchema, // Added + CreateMergeRequestNoteSchema, // Added + ListMergeRequestDiscussionsSchema, + type GitLabFork, + type GitLabReference, + type GitLabRepository, + type GitLabIssue, + type GitLabMergeRequest, + type GitLabContent, + type GitLabCreateUpdateFileResponse, + type GitLabSearchResponse, + type GitLabTree, + type GitLabCommit, + type FileOperation, + type GitLabMergeRequestDiff, + type GitLabIssueLink, + type GitLabIssueWithLinkDetails, + type GitLabNamespace, + type GitLabNamespaceExistsResponse, + type GitLabProject, + type GitLabLabel, + type GitLabUser, + type GitLabUsersResponse, + type GitLabPipeline, + type ListPipelinesOptions, + type GetPipelineOptions, + type ListPipelineJobsOptions, + type CreatePipelineOptions, + type RetryPipelineOptions, + type CancelPipelineOptions, + type GitLabPipelineJob, + type GitLabMilestones, + type ListProjectMilestonesOptions, + type GetProjectMilestoneOptions, + type CreateProjectMilestoneOptions, + type EditProjectMilestoneOptions, + type DeleteProjectMilestoneOptions, + type GetMilestoneIssuesOptions, + type GetMilestoneMergeRequestsOptions, + type PromoteProjectMilestoneOptions, + type GetMilestoneBurndownEventsOptions, + // Discussion Types + type GitLabDiscussionNote, + type GitLabDiscussion, + type PaginatedDiscussionsResponse, + type PaginationOptions, + type MergeRequestThreadPosition, + type GetWikiPageOptions, + type CreateWikiPageOptions, + type UpdateWikiPageOptions, + type DeleteWikiPageOptions, + type GitLabWikiPage, + type GitLabTreeItem, + type GetRepositoryTreeOptions, + UpdateIssueNoteSchema, + CreateIssueNoteSchema, + ListMergeRequestsSchema, + GitLabMilestonesSchema, + ListProjectMilestonesSchema, + GetProjectMilestoneSchema, + CreateProjectMilestoneSchema, + EditProjectMilestoneSchema, + DeleteProjectMilestoneSchema, + GetMilestoneIssuesSchema, + GetMilestoneMergeRequestsSchema, + PromoteProjectMilestoneSchema, + GetMilestoneBurndownEventsSchema, + GitLabCompareResult, + GitLabCompareResultSchema, + GetBranchDiffsSchema, + ListWikiPagesOptions, + ListCommitsSchema, + GetCommitSchema, + GetCommitDiffSchema, + type ListCommitsOptions, + type GetCommitOptions, + type GetCommitDiffOptions, + ListMergeRequestDiffsSchema, +} from "./schemas.js"; + +import { DEFAULT_FETCH_CONFIG } from "./agent.js"; +import { Response } from "node-fetch"; + +/** + * Read version from package.json + */ +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageJsonPath = path.resolve(__dirname, "../package.json"); +let SERVER_VERSION = "unknown"; +try { + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + SERVER_VERSION = packageJson.version || SERVER_VERSION; + } +} catch (error) { + // Warning: Could not read version from package.json - silently continue +} + +// create the underlying mcp server +const server = new Server( + { + name: "better-gitlab-mcp-server", + version: SERVER_VERSION, + }, + { + capabilities: { + tools: {}, + }, + } +); + + +// Define all available tools +const allTools = [ + { + name: "create_or_update_file", + description: "Create or update a single file in a GitLab project", + inputSchema: zodToJsonSchema(CreateOrUpdateFileSchema), + }, + { + name: "search_repositories", + description: "Search for GitLab projects", + inputSchema: zodToJsonSchema(SearchRepositoriesSchema), + }, + { + name: "create_repository", + description: "Create a new GitLab project", + inputSchema: zodToJsonSchema(CreateRepositorySchema), + }, + { + name: "get_file_contents", + description: "Get the contents of a file or directory from a GitLab project", + inputSchema: zodToJsonSchema(GetFileContentsSchema), + }, + { + name: "push_files", + description: "Push multiple files to a GitLab project in a single commit", + inputSchema: zodToJsonSchema(PushFilesSchema), + }, + { + name: "create_issue", + description: "Create a new issue in a GitLab project", + inputSchema: zodToJsonSchema(CreateIssueSchema), + }, + { + name: "create_merge_request", + description: "Create a new merge request in a GitLab project", + inputSchema: zodToJsonSchema(CreateMergeRequestSchema), + }, + { + name: "fork_repository", + description: "Fork a GitLab project to your account or specified namespace", + inputSchema: zodToJsonSchema(ForkRepositorySchema), + }, + { + name: "create_branch", + description: "Create a new branch in a GitLab project", + inputSchema: zodToJsonSchema(CreateBranchSchema), + }, + { + name: "get_merge_request", + description: + "Get details of a merge request (Either mergeRequestIid or branchName must be provided)", + inputSchema: zodToJsonSchema(GetMergeRequestSchema), + }, + { + name: "get_merge_request_diffs", + description: + "Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)", + inputSchema: zodToJsonSchema(GetMergeRequestDiffsSchema), + }, + { + name: "list_merge_request_diffs", + description: + "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)", + inputSchema: zodToJsonSchema(ListMergeRequestDiffsSchema), + }, + { + name: "get_branch_diffs", + description: "Get the changes/diffs between two branches or commits in a GitLab project", + inputSchema: zodToJsonSchema(GetBranchDiffsSchema), + }, + { + name: "update_merge_request", + description: "Update a merge request (Either mergeRequestIid or branchName must be provided)", + inputSchema: zodToJsonSchema(UpdateMergeRequestSchema), + }, + { + name: "create_note", + description: "Create a new note (comment) to an issue or merge request", + inputSchema: zodToJsonSchema(CreateNoteSchema), + }, + { + name: "create_merge_request_thread", + description: "Create a new thread on a merge request", + inputSchema: zodToJsonSchema(CreateMergeRequestThreadSchema), + }, + { + name: "mr_discussions", + description: "List discussion items for a merge request", + inputSchema: zodToJsonSchema(ListMergeRequestDiscussionsSchema), + }, + { + name: "update_merge_request_note", + description: "Modify an existing merge request thread note", + inputSchema: zodToJsonSchema(UpdateMergeRequestNoteSchema), + }, + { + name: "create_merge_request_note", + description: "Add a new note to an existing merge request thread", + inputSchema: zodToJsonSchema(CreateMergeRequestNoteSchema), + }, + { + name: "update_issue_note", + description: "Modify an existing issue thread note", + inputSchema: zodToJsonSchema(UpdateIssueNoteSchema), + }, + { + name: "create_issue_note", + description: "Add a new note to an existing issue thread", + inputSchema: zodToJsonSchema(CreateIssueNoteSchema), + }, + { + name: "list_issues", + description: "List issues in a GitLab project with filtering options", + inputSchema: zodToJsonSchema(ListIssuesSchema), + }, + { + name: "get_issue", + description: "Get details of a specific issue in a GitLab project", + inputSchema: zodToJsonSchema(GetIssueSchema), + }, + { + name: "update_issue", + description: "Update an issue in a GitLab project", + inputSchema: zodToJsonSchema(UpdateIssueSchema), + }, + { + name: "delete_issue", + description: "Delete an issue from a GitLab project", + inputSchema: zodToJsonSchema(DeleteIssueSchema), + }, + { + name: "list_issue_links", + description: "List all issue links for a specific issue", + inputSchema: zodToJsonSchema(ListIssueLinksSchema), + }, + { + name: "list_issue_discussions", + description: "List discussions for an issue in a GitLab project", + inputSchema: zodToJsonSchema(ListIssueDiscussionsSchema), + }, + { + name: "get_issue_link", + description: "Get a specific issue link", + inputSchema: zodToJsonSchema(GetIssueLinkSchema), + }, + { + name: "create_issue_link", + description: "Create an issue link between two issues", + inputSchema: zodToJsonSchema(CreateIssueLinkSchema), + }, + { + name: "delete_issue_link", + 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), + }, + { + name: "list_labels", + description: "List labels for a project", + inputSchema: zodToJsonSchema(ListLabelsSchema), + }, + { + name: "get_label", + description: "Get a single label from a project", + inputSchema: zodToJsonSchema(GetLabelSchema), + }, + { + name: "create_label", + description: "Create a new label in a project", + inputSchema: zodToJsonSchema(CreateLabelSchema), + }, + { + name: "update_label", + description: "Update an existing label in a project", + inputSchema: zodToJsonSchema(UpdateLabelSchema), + }, + { + name: "delete_label", + description: "Delete a label from a project", + inputSchema: zodToJsonSchema(DeleteLabelSchema), + }, + { + name: "list_group_projects", + description: "List projects in a GitLab group with filtering options", + inputSchema: zodToJsonSchema(ListGroupProjectsSchema), + }, + { + name: "list_wiki_pages", + description: "List wiki pages in a GitLab project", + inputSchema: zodToJsonSchema(ListWikiPagesSchema), + }, + { + name: "get_wiki_page", + description: "Get details of a specific wiki page", + inputSchema: zodToJsonSchema(GetWikiPageSchema), + }, + { + name: "create_wiki_page", + description: "Create a new wiki page in a GitLab project", + inputSchema: zodToJsonSchema(CreateWikiPageSchema), + }, + { + name: "update_wiki_page", + description: "Update an existing wiki page in a GitLab project", + inputSchema: zodToJsonSchema(UpdateWikiPageSchema), + }, + { + name: "delete_wiki_page", + description: "Delete a wiki page from a GitLab project", + inputSchema: zodToJsonSchema(DeleteWikiPageSchema), + }, + { + name: "get_repository_tree", + description: "Get the repository tree for a GitLab project (list files and directories)", + inputSchema: zodToJsonSchema(GetRepositoryTreeSchema), + }, + { + name: "list_pipelines", + description: "List pipelines in a GitLab project with filtering options", + inputSchema: zodToJsonSchema(ListPipelinesSchema), + }, + { + name: "get_pipeline", + description: "Get details of a specific pipeline in a GitLab project", + inputSchema: zodToJsonSchema(GetPipelineSchema), + }, + { + name: "list_pipeline_jobs", + description: "List all jobs in a specific pipeline", + inputSchema: zodToJsonSchema(ListPipelineJobsSchema), + }, + { + name: "get_pipeline_job", + description: "Get details of a GitLab pipeline job number", + inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), + }, + { + name: "get_pipeline_job_output", + description: "Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage", + inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), + }, + { + name: "create_pipeline", + description: "Create a new pipeline for a branch or tag", + inputSchema: zodToJsonSchema(CreatePipelineSchema), + }, + { + name: "retry_pipeline", + description: "Retry a failed or canceled pipeline", + inputSchema: zodToJsonSchema(RetryPipelineSchema), + }, + { + name: "cancel_pipeline", + description: "Cancel a running pipeline", + inputSchema: zodToJsonSchema(CancelPipelineSchema), + }, + { + name: "list_merge_requests", + description: "List merge requests in a GitLab project with filtering options", + inputSchema: zodToJsonSchema(ListMergeRequestsSchema), + }, + { + name: "list_milestones", + description: "List milestones in a GitLab project with filtering options", + inputSchema: zodToJsonSchema(ListProjectMilestonesSchema), + }, + { + name: "get_milestone", + description: "Get details of a specific milestone", + inputSchema: zodToJsonSchema(GetProjectMilestoneSchema), + }, + { + name: "create_milestone", + description: "Create a new milestone in a GitLab project", + inputSchema: zodToJsonSchema(CreateProjectMilestoneSchema), + }, + { + name: "edit_milestone", + description: "Edit an existing milestone in a GitLab project", + inputSchema: zodToJsonSchema(EditProjectMilestoneSchema), + }, + { + name: "delete_milestone", + description: "Delete a milestone from a GitLab project", + inputSchema: zodToJsonSchema(DeleteProjectMilestoneSchema), + }, + { + name: "get_milestone_issue", + description: "Get issues associated with a specific milestone", + inputSchema: zodToJsonSchema(GetMilestoneIssuesSchema), + }, + { + name: "get_milestone_merge_requests", + description: "Get merge requests associated with a specific milestone", + inputSchema: zodToJsonSchema(GetMilestoneMergeRequestsSchema), + }, + { + name: "promote_milestone", + description: "Promote a milestone to the next stage", + inputSchema: zodToJsonSchema(PromoteProjectMilestoneSchema), + }, + { + name: "get_milestone_burndown_events", + description: "Get burndown events for a specific milestone", + inputSchema: zodToJsonSchema(GetMilestoneBurndownEventsSchema), + }, + { + name: "get_users", + description: "Get GitLab user details by usernames", + inputSchema: zodToJsonSchema(GetUsersSchema), + }, + { + name: "list_commits", + description: "List repository commits with filtering options", + inputSchema: zodToJsonSchema(ListCommitsSchema), + }, + { + name: "get_commit", + description: "Get details of a specific commit", + inputSchema: zodToJsonSchema(GetCommitSchema), + }, + { + name: "get_commit_diff", + description: "Get changes/diffs of a specific commit", + inputSchema: zodToJsonSchema(GetCommitDiffSchema), + }, +]; + +// Define which tools are read-only +const readOnlyTools = [ + "search_repositories", + "get_file_contents", + "get_merge_request", + "get_merge_request_diffs", + "get_branch_diffs", + "mr_discussions", + "list_issues", + "list_merge_requests", + "get_issue", + "list_issue_links", + "list_issue_discussions", + "get_issue_link", + "list_namespaces", + "get_namespace", + "verify_namespace", + "get_project", + "get_pipeline", + "list_pipelines", + "list_pipeline_jobs", + "get_pipeline_job", + "get_pipeline_job_output", + "list_projects", + "list_labels", + "get_label", + "list_group_projects", + "get_repository_tree", + "list_milestones", + "get_milestone", + "get_milestone_issue", + "get_milestone_merge_requests", + "get_milestone_burndown_events", + "list_wiki_pages", + "get_wiki_page", + "get_users", + "list_commits", + "get_commit", + "get_commit_diff", +]; + +// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI +const wikiToolNames = [ + "list_wiki_pages", + "get_wiki_page", + "create_wiki_page", + "update_wiki_page", + "delete_wiki_page", + "upload_wiki_attachment", +]; + +// Define which tools are related to milestones and can be toggled by USE_MILESTONE +const milestoneToolNames = [ + "list_milestones", + "get_milestone", + "create_milestone", + "edit_milestone", + "delete_milestone", + "get_milestone_issue", + "get_milestone_merge_requests", + "promote_milestone", + "get_milestone_burndown_events", +]; + +// Define which tools are related to pipelines and can be toggled by USE_PIPELINE +const pipelineToolNames = [ + "list_pipelines", + "get_pipeline", + "list_pipeline_jobs", + "get_pipeline_job", + "get_pipeline_job_output", + "create_pipeline", + "retry_pipeline", + "cancel_pipeline", +]; + + + + +if (!config.GITLAB_PERSONAL_ACCESS_TOKEN) { + console.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set"); + process.exit(1); +} + +/** + * Utility function for handling GitLab API errors + * API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors) + * + * @param {import("node-fetch").Response} response - The response from GitLab API + * @throws {Error} Throws an error with response details if the request failed + */ +async function handleGitLabError(response: Response): Promise { + if (!response.ok) { + const errorBody = await response.text(); + // Check specifically for Rate Limit error + if (response.status === 403 && errorBody.includes("User API Key Rate limit exceeded")) { + console.error("GitLab API Rate Limit Exceeded:", errorBody); + console.log("User API Key Rate limit exceeded. Please try again later."); + throw new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`); + } else { + // Handle other API errors + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + } +} + +/** + * @param {string} projectId - The project ID parameter passed to the function + * @returns {string} The project ID to use for the API call + */ +function getEffectiveProjectId(projectId: string): string { + return config.GITLAB_PROJECT_ID || projectId; +} + +/** + * Create a fork of a GitLab project + * 프로젝트 포크 생성 (Create a project fork) + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {string} [namespace] - The namespace to fork the project to + * @returns {Promise} The created fork + */ +async function forkProject(projectId: string, namespace?: string): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const effectiveProjectId = getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/fork`); + + if (namespace) { + url.searchParams.append("namespace", namespace); + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + }); + + // 이미 존재하는 프로젝트인 경우 처리 + if (response.status === 409) { + throw new Error("Project already exists in the target namespace"); + } + + await handleGitLabError(response); + const data = await response.json(); + return GitLabForkSchema.parse(data); +} + +/** + * Create a new branch in a GitLab project + * 새로운 브랜치 생성 (Create a new branch) + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {z.infer} options - Branch creation options + * @returns {Promise} The created branch reference + */ +async function createBranch( + projectId: string, + options: z.infer +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const effectiveProjectId = getEffectiveProjectId(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/branches` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify({ + branch: options.name, + ref: options.ref, + }), + }); + + await handleGitLabError(response); + return GitLabReferenceSchema.parse(await response.json()); +} + +/** + * Get the default branch for a GitLab project + * 프로젝트의 기본 브랜치 조회 (Get the default branch of a project) + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @returns {Promise} The name of the default branch + */ +async function getDefaultBranchRef(projectId: string): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const effectiveProjectId = getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}`); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + const project = GitLabRepositorySchema.parse(await response.json()); + return project.default_branch ?? "main"; +} + +/** + * Get the contents of a file from a GitLab project + * 파일 내용 조회 (Get file contents) + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {string} filePath - The path of the file to get + * @param {string} [ref] - The name of the branch, tag or commit + * @returns {Promise} The file content + */ +async function getFileContents( + projectId: string, + filePath: string, + ref?: string +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const effectiveProjectId = getEffectiveProjectId(projectId); + const encodedPath = encodeURIComponent(filePath); + + // ref가 없는 경우 default branch를 가져옴 + if (!ref) { + ref = await getDefaultBranchRef(projectId); + } + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/files/${encodedPath}` + ); + + url.searchParams.append("ref", ref); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + // 파일을 찾을 수 없는 경우 처리 + if (response.status === 404) { + throw new Error(`File not found: ${filePath}`); + } + + await handleGitLabError(response); + const data = await response.json(); + const parsedData = GitLabContentSchema.parse(data); + + // Base64로 인코딩된 파일 내용을 UTF-8로 디코딩 + if (!Array.isArray(parsedData) && parsedData.content) { + parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8"); + parsedData.encoding = "utf8"; + } + + return parsedData; +} + +/** + * Create a new issue in a GitLab project + * 이슈 생성 (Create an issue) + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {z.infer} options - Issue creation options + * @returns {Promise} The created issue + */ +async function createIssue( + projectId: string, + options: z.infer +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const effectiveProjectId = getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify({ + title: options.title, + description: options.description, + assignee_ids: options.assignee_ids, + milestone_id: options.milestone_id, + labels: options.labels?.join(","), + }), + }); + + // 잘못된 요청 처리 + if (response.status === 400) { + const errorBody = await response.text(); + throw new Error(`Invalid request: ${errorBody}`); + } + + await handleGitLabError(response); + const data = await response.json(); + return GitLabIssueSchema.parse(data); +} + +/** + * List issues in a GitLab project + * 프로젝트의 이슈 목록 조회 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {Object} options - Options for listing issues + * @returns {Promise} List of issues + */ +async function listIssues( + projectId: string, + options: Omit, "project_id"> = {} +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const effectiveProjectId = getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`); + + // Add all query parameters + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + const keys = ["labels", "assignee_username"]; + if (keys.includes(key)) { + if (Array.isArray(value)) { + // Handle array of labels + value.forEach(label => { + url.searchParams.append(`${key}[]`, label.toString()); + }); + } else { + url.searchParams.append(`${key}[]`, value.toString()); + } + } else { + url.searchParams.append(key, value.toString()); + } + } + }); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabIssueSchema).parse(data); +} + +/** + * List merge requests in a GitLab project with optional filtering + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {Object} options - Optional filtering parameters + * @returns {Promise} List of merge requests + */ +async function listMergeRequests( + projectId: string, + options: Omit, "project_id"> = {} +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests`); + + // Add all query parameters + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (key === "labels" && Array.isArray(value)) { + // Handle array of labels + url.searchParams.append(key, value.join(",")); + } else { + url.searchParams.append(key, value.toString()); + } + } + }); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabMergeRequestSchema).parse(data); +} + +/** + * Get a single issue from a GitLab project + * 단일 이슈 조회 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} issueIid - The internal ID of the project issue + * @returns {Promise} The issue + */ +async function getIssue(projectId: string, issueIid: number): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabIssueSchema.parse(data); +} + +/** + * Update an issue in a GitLab project + * 이슈 업데이트 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} issueIid - The internal ID of the project issue + * @param {Object} options - Update options for the issue + * @returns {Promise} The updated issue + */ +async function updateIssue( + projectId: string, + issueIid: number, + options: Omit, "project_id" | "issue_iid"> +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}` + ); + + // Convert labels array to comma-separated string if present + const body: Record = { ...options }; + if (body.labels && Array.isArray(body.labels)) { + body.labels = body.labels.join(","); + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "PUT", + body: JSON.stringify(body), + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabIssueSchema.parse(data); +} + +/** + * Delete an issue from a GitLab project + * 이슈 삭제 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} issueIid - The internal ID of the project issue + * @returns {Promise} + */ +async function deleteIssue(projectId: string, issueIid: number): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "DELETE", + }); + + await handleGitLabError(response); +} + +/** + * List all issue links for a specific issue + * 이슈 관계 목록 조회 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} issueIid - The internal ID of the project issue + * @returns {Promise} List of issues with link details + */ +async function listIssueLinks( + projectId: string, + issueIid: number +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/links` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabIssueWithLinkDetailsSchema).parse(data); +} + +/** + * Get a specific issue link + * 특정 이슈 관계 조회 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} issueIid - The internal ID of the project issue + * @param {number} issueLinkId - The ID of the issue link + * @returns {Promise} The issue link + */ +async function getIssueLink( + projectId: string, + issueIid: number, + issueLinkId: number +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/issues/${issueIid}/links/${issueLinkId}` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabIssueLinkSchema.parse(data); +} + +/** + * Create an issue link between two issues + * 이슈 관계 생성 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} issueIid - The internal ID of the project issue + * @param {string} targetProjectId - The ID or URL-encoded path of the target project + * @param {number} targetIssueIid - The internal ID of the target project issue + * @param {string} linkType - The type of the relation (relates_to, blocks, is_blocked_by) + * @returns {Promise} The created issue link + */ +async function createIssueLink( + projectId: string, + issueIid: number, + targetProjectId: string, + targetIssueIid: number, + linkType: "relates_to" | "blocks" | "is_blocked_by" = "relates_to" +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + targetProjectId = decodeURIComponent(targetProjectId); // Decode target project ID as well + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/links` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify({ + target_project_id: targetProjectId, + target_issue_iid: targetIssueIid, + link_type: linkType, + }), + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabIssueLinkSchema.parse(data); +} + +/** + * Delete an issue link + * 이슈 관계 삭제 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} issueIid - The internal ID of the project issue + * @param {number} issueLinkId - The ID of the issue link + * @returns {Promise} + */ +async function deleteIssueLink( + projectId: string, + issueIid: number, + issueLinkId: number +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/issues/${issueIid}/links/${issueLinkId}` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "DELETE", + }); + + await handleGitLabError(response); +} + +/** + * Create a new merge request in a GitLab project + * 병합 요청 생성 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {z.infer} options - Merge request creation options + * @returns {Promise} The created merge request + */ +async function createMergeRequest( + projectId: string, + options: z.infer +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests`); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify({ + title: options.title, + description: options.description, + source_branch: options.source_branch, + target_branch: options.target_branch, + assignee_ids: options.assignee_ids, + reviewer_ids: options.reviewer_ids, + labels: options.labels?.join(","), + allow_collaboration: options.allow_collaboration, + draft: options.draft, + remove_source_branch: options.remove_source_branch, + squash: options.squash, + }), + }); + + if (response.status === 400) { + const errorBody = await response.text(); + throw new Error(`Invalid request: ${errorBody}`); + } + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabMergeRequestSchema.parse(data); +} + +/** + * Shared helper function for listing discussions + * 토론 목록 조회를 위한 공유 헬퍼 함수 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {"issues" | "merge_requests"} resourceType - The type of resource (issues or merge_requests) + * @param {number} resourceIid - The IID of the issue or merge request + * @param {PaginationOptions} options - Pagination and sorting options + * @returns {Promise} Paginated list of discussions + */ +async function listDiscussions( + projectId: string, + resourceType: "issues" | "merge_requests", + resourceIid: number, + options: PaginationOptions = {} +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/${resourceType}/${resourceIid}/discussions` + ); + + // Add query parameters for pagination and sorting + if (options.page) { + url.searchParams.append("page", options.page.toString()); + } + if (options.per_page) { + url.searchParams.append("per_page", options.per_page.toString()); + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + const discussions = await response.json(); + + // Extract pagination headers + const pagination = { + x_next_page: response.headers.get("x-next-page") + ? parseInt(response.headers.get("x-next-page")!) + : null, + x_page: response.headers.get("x-page") ? parseInt(response.headers.get("x-page")!) : undefined, + x_per_page: response.headers.get("x-per-page") + ? parseInt(response.headers.get("x-per-page")!) + : undefined, + x_prev_page: response.headers.get("x-prev-page") + ? parseInt(response.headers.get("x-prev-page")!) + : null, + x_total: response.headers.get("x-total") ? parseInt(response.headers.get("x-total")!) : null, + x_total_pages: response.headers.get("x-total-pages") + ? parseInt(response.headers.get("x-total-pages")!) + : null, + }; + + return PaginatedDiscussionsResponseSchema.parse({ + items: discussions, + pagination: pagination, + }); +} + +/** + * List merge request discussion items + * 병합 요청 토론 목록 조회 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} mergeRequestIid - The IID of a merge request + * @param {DiscussionPaginationOptions} options - Pagination and sorting options + * @returns {Promise} List of discussions + */ +async function listMergeRequestDiscussions( + projectId: string, + mergeRequestIid: number, + options: PaginationOptions = {} +): Promise { + return listDiscussions(projectId, "merge_requests", mergeRequestIid, options); +} + +/** + * List discussions for an issue + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} issueIid - The internal ID of the project issue + * @param {DiscussionPaginationOptions} options - Pagination and sorting options + * @returns {Promise} List of issue discussions + */ +async function listIssueDiscussions( + projectId: string, + issueIid: number, + options: PaginationOptions = {} +): Promise { + return listDiscussions(projectId, "issues", issueIid, options); +} + +/** + * Modify an existing merge request thread note + * 병합 요청 토론 노트 수정 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} mergeRequestIid - The IID of a merge request + * @param {string} discussionId - The ID of a thread + * @param {number} noteId - The ID of a thread note + * @param {string} body - The new content of the note + * @param {boolean} [resolved] - Resolve/unresolve state + * @returns {Promise} The updated note + */ +async function updateMergeRequestNote( + projectId: string, + mergeRequestIid: number, + discussionId: string, + noteId: number, + body?: string, + resolved?: boolean +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes/${noteId}` + ); + + // Only one of body or resolved can be sent according to GitLab API + const payload: { body?: string; resolved?: boolean } = {}; + if (body !== undefined) { + payload.body = body; + } else if (resolved !== undefined) { + payload.resolved = resolved; + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "PUT", + body: JSON.stringify(payload), + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionNoteSchema.parse(data); +} + +/** + * Update an issue discussion note + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} issueIid - The IID of an issue + * @param {string} discussionId - The ID of a thread + * @param {number} noteId - The ID of a thread note + * @param {string} body - The new content of the note + * @returns {Promise} The updated note + */ +async function updateIssueNote( + projectId: string, + issueIid: number, + discussionId: string, + noteId: number, + body: string +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/issues/${issueIid}/discussions/${discussionId}/notes/${noteId}` + ); + + const payload = { body }; + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "PUT", + body: JSON.stringify(payload), + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionNoteSchema.parse(data); +} + +/** + * Create a note in an issue discussion + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} issueIid - The IID of an issue + * @param {string} discussionId - The ID of a thread + * @param {string} body - The content of the new note + * @param {string} [createdAt] - The creation date of the note (ISO 8601 format) + * @returns {Promise} The created note + */ +async function createIssueNote( + projectId: string, + issueIid: number, + discussionId: string, + body: string, + createdAt?: string +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/issues/${issueIid}/discussions/${discussionId}/notes` + ); + + const payload: { body: string; created_at?: string } = { body }; + if (createdAt) { + payload.created_at = createdAt; + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify(payload), + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionNoteSchema.parse(data); +} + +/** + * Add a new note to an existing merge request thread + * 기존 병합 요청 스레드에 새 노트 추가 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} mergeRequestIid - The IID of a merge request + * @param {string} discussionId - The ID of a thread + * @param {string} body - The content of the new note + * @param {string} [createdAt] - The creation date of the note (ISO 8601 format) + * @returns {Promise} The created note + */ +async function createMergeRequestNote( + projectId: string, + mergeRequestIid: number, + discussionId: string, + body: string, + createdAt?: string +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes` + ); + + const payload: { body: string; created_at?: string } = { body }; + if (createdAt) { + payload.created_at = createdAt; + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify(payload), + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionNoteSchema.parse(data); +} + +/** + * Create or update a file in a GitLab project + * 파일 생성 또는 업데이트 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {string} filePath - The path of the file to create or update + * @param {string} content - The content of the file + * @param {string} commitMessage - The commit message + * @param {string} branch - The branch name + * @param {string} [previousPath] - The previous path of the file in case of rename + * @returns {Promise} The file update response + */ +async function createOrUpdateFile( + projectId: string, + filePath: string, + content: string, + commitMessage: string, + branch: string, + previousPath?: string, + last_commit_id?: string, + commit_id?: string +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const encodedPath = encodeURIComponent(filePath); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodedPath}` + ); + + const body: Record = { + branch, + content, + commit_message: commitMessage, + encoding: "text", + ...(previousPath ? { previous_path: previousPath } : {}), + }; + + // Check if file exists + let method = "POST"; + try { + // Get file contents to check existence and retrieve commit IDs + const fileData = await getFileContents(projectId, filePath, branch); + method = "PUT"; + + // If fileData is not an array, it's a file content object with commit IDs + if (!Array.isArray(fileData)) { + // Use commit IDs from the file data if not provided in parameters + if (!commit_id && fileData.commit_id) { + body.commit_id = fileData.commit_id; + } else if (commit_id) { + body.commit_id = commit_id; + } + + if (!last_commit_id && fileData.last_commit_id) { + body.last_commit_id = fileData.last_commit_id; + } else if (last_commit_id) { + body.last_commit_id = last_commit_id; + } + } + } catch (error) { + if (!(error instanceof Error && error.message.includes("File not found"))) { + throw error; + } + // File doesn't exist, use POST - no need for commit IDs for new files + // But still use any provided as parameters if they exist + if (commit_id) { + body.commit_id = commit_id; + } + if (last_commit_id) { + body.last_commit_id = last_commit_id; + } + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabCreateUpdateFileResponseSchema.parse(data); +} + +/** + * Create a tree structure in a GitLab project repository + * 저장소에 트리 구조 생성 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {FileOperation[]} files - Array of file operations + * @param {string} [ref] - The name of the branch, tag or commit + * @returns {Promise} The created tree + */ +async function createTree( + projectId: string, + files: FileOperation[], + ref?: string +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/tree` + ); + + if (ref) { + url.searchParams.append("ref", ref); + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify({ + files: files.map(file => ({ + file_path: file.path, + content: file.content, + encoding: "text", + })), + }), + }); + + if (response.status === 400) { + const errorBody = await response.text(); + throw new Error(`Invalid request: ${errorBody}`); + } + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabTreeSchema.parse(data); +} + +/** + * Create a commit in a GitLab project repository + * 저장소에 커밋 생성 + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {string} message - The commit message + * @param {string} branch - The branch name + * @param {FileOperation[]} actions - Array of file operations for the commit + * @returns {Promise} The created commit + */ +async function createCommit( + projectId: string, + message: string, + branch: string, + actions: FileOperation[] +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify({ + branch, + commit_message: message, + actions: actions.map(action => ({ + action: "create", + file_path: action.path, + content: action.content, + encoding: "text", + })), + }), + }); + + if (response.status === 400) { + const errorBody = await response.text(); + throw new Error(`Invalid request: ${errorBody}`); + } + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabCommitSchema.parse(data); +} + +/** + * Search for GitLab projects + * 프로젝트 검색 + * + * @param {string} query - The search query + * @param {number} [page=1] - The page number + * @param {number} [perPage=20] - Number of items per page + * @returns {Promise} The search results + */ +async function searchProjects( + query: string, + page: number = 1, + perPage: number = 20 +): Promise { + const url = new URL(`${config.GITLAB_API_URL}/projects`); + url.searchParams.append("search", query); + url.searchParams.append("page", page.toString()); + url.searchParams.append("per_page", perPage.toString()); + url.searchParams.append("order_by", "id"); + url.searchParams.append("sort", "desc"); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const projects = (await response.json()) as GitLabRepository[]; + const totalCount = response.headers.get("x-total"); + const totalPages = response.headers.get("x-total-pages"); + + // GitLab API doesn't return these headers for results > 10,000 + const count = totalCount ? parseInt(totalCount) : projects.length; + + return GitLabSearchResponseSchema.parse({ + count, + total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage), + current_page: page, + items: projects, + }); +} + +/** + * Create a new GitLab repository + * 새 저장소 생성 + * + * @param {z.infer} options - Repository creation options + * @returns {Promise} The created repository + */ +async function createRepository( + options: z.infer +): Promise { + const response = await fetch(`${config.GITLAB_API_URL}/projects`, { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify({ + name: options.name, + description: options.description, + visibility: options.visibility, + initialize_with_readme: options.initialize_with_readme, + default_branch: "main", + path: options.name.toLowerCase().replace(/\s+/g, "-"), + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabRepositorySchema.parse(data); +} + +/** + * Get merge request details + * MR 조회 함수 (Function to retrieve merge request) + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} mergeRequestIid - The internal ID of the merge request (Optional) + * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Optional) + * @returns {Promise} The merge request details + */ +async function getMergeRequest( + projectId: string, + mergeRequestIid?: number, + branchName?: string +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + let url: URL; + + if (mergeRequestIid) { + url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}` + ); + } else if (branchName) { + url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/merge_requests?source_branch=${encodeURIComponent(branchName)}` + ); + } else { + throw new Error("Either mergeRequestIid or branchName must be provided"); + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + + const data = await response.json(); + + // If response is an array (Comes from branchName search), return the first item if exist + if (Array.isArray(data) && data.length > 0) { + return GitLabMergeRequestSchema.parse(data[0]); + } + + return GitLabMergeRequestSchema.parse(data); +} + +/** + * Get merge request changes/diffs + * MR 변경사항 조회 함수 (Function to retrieve merge request changes) + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} mergeRequestIid - The internal ID of the merge request (Either mergeRequestIid or branchName must be provided) + * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Either mergeRequestIid or branchName must be provided) + * @param {string} [view] - The view type for the diff (inline or parallel) + * @returns {Promise} The merge request diffs + */ +async function getMergeRequestDiffs( + projectId: string, + mergeRequestIid?: number, + branchName?: string, + view?: "inline" | "parallel" +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + if (!mergeRequestIid && !branchName) { + throw new Error("Either mergeRequestIid or branchName must be provided"); + } + + if (branchName && !mergeRequestIid) { + const mergeRequest = await getMergeRequest(projectId, undefined, branchName); + mergeRequestIid = mergeRequest.iid; + } + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/changes` + ); + + if (view) { + url.searchParams.append("view", view); + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + const data = (await response.json()) as { changes: unknown }; + return z.array(GitLabDiffSchema).parse(data.changes); +} + +/** + * Get merge request changes with detailed information including commits, diff_refs, and more + * 마지막으로 추가된 상세한 MR 변경사항 조회 함수 (Detailed merge request changes retrieval function) + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} mergeRequestIid - The internal ID of the merge request (Either mergeRequestIid or branchName must be provided) + * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Either mergeRequestIid or branchName must be provided) + * @param {boolean} [unidiff] - Return diff in unidiff format + * @returns {Promise} The complete merge request changes response + */ +async function listMergeRequestDiffs( + projectId: string, + mergeRequestIid?: number, + branchName?: string, + page?: number, + perPage?: number, + unidiff?: boolean +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + if (!mergeRequestIid && !branchName) { + throw new Error("Either mergeRequestIid or branchName must be provided"); + } + + if (branchName && !mergeRequestIid) { + const mergeRequest = await getMergeRequest(projectId, undefined, branchName); + mergeRequestIid = mergeRequest.iid; + } + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/diffs` + ); + + if (page) { + url.searchParams.append("page", page.toString()); + } + + if (perPage) { + url.searchParams.append("per_page", perPage.toString()); + } + + if (unidiff) { + url.searchParams.append("unidiff", "true"); + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + return await response.json(); // Return full response including commits, diff_refs, changes, etc. +} + +/** + * Get branch comparison diffs + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {string} from - The branch name or commit SHA to compare from + * @param {string} to - The branch name or commit SHA to compare to + * @param {boolean} [straight] - Comparison method: false for '...' (default), true for '--' + * @returns {Promise} Branch comparison results + */ +async function getBranchDiffs( + projectId: string, + from: string, + to: string, + straight?: boolean +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/compare` + ); + + url.searchParams.append("from", from); + url.searchParams.append("to", to); + + if (straight !== undefined) { + url.searchParams.append("straight", straight.toString()); + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabCompareResultSchema.parse(data); +} + +/** + * Update a merge request + * MR 업데이트 함수 (Function to update merge request) + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} mergeRequestIid - The internal ID of the merge request (Optional) + * @param {string} branchName - The name of the branch to search for merge request by branch name (Optional) + * @param {Object} options - The update options + * @returns {Promise} The updated merge request + */ +async function updateMergeRequest( + projectId: string, + options: Omit< + z.infer, + "project_id" | "merge_request_iid" | "source_branch" + >, + mergeRequestIid?: number, + branchName?: string +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + if (!mergeRequestIid && !branchName) { + throw new Error("Either mergeRequestIid or branchName must be provided"); + } + + if (branchName && !mergeRequestIid) { + const mergeRequest = await getMergeRequest(projectId, undefined, branchName); + mergeRequestIid = mergeRequest.iid; + } + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "PUT", + body: JSON.stringify(options), + }); + + await handleGitLabError(response); + return GitLabMergeRequestSchema.parse(await response.json()); +} + +/** + * Create a new note (comment) on an issue or merge request + * 📦 새로운 함수: createNote - 이슈 또는 병합 요청에 노트(댓글)를 추가하는 함수 + * (New function: createNote - Function to add a note (comment) to an issue or merge request) + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {"issue" | "merge_request"} noteableType - The type of the item to add a note to (issue or merge_request) + * @param {number} noteableIid - The internal ID of the issue or merge request + * @param {string} body - The content of the note + * @returns {Promise} The created note + */ +async function createNote( + projectId: string, + noteableType: "issue" | "merge_request", // 'issue' 또는 'merge_request' 타입 명시 + noteableIid: number, + body: string +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + // ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능 + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify({ body }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); + } + + return await response.json(); +} + +/** + * Create a new thread on a merge request + * 📦 새로운 함수: createMergeRequestThread - 병합 요청에 새로운 스레드(토론)를 생성하는 함수 + * (New function: createMergeRequestThread - Function to create a new thread (discussion) on a merge request) + * + * This function provides more capabilities than createNote, including the ability to: + * - Create diff notes (comments on specific lines of code) + * - Specify exact positions for comments + * - Set creation timestamps + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} mergeRequestIid - The internal ID of the merge request + * @param {string} body - The content of the thread + * @param {MergeRequestThreadPosition} [position] - Position information for diff notes + * @param {string} [createdAt] - ISO 8601 formatted creation date + * @returns {Promise} The created discussion thread + */ +async function createMergeRequestThread( + projectId: string, + mergeRequestIid: number, + body: string, + position?: MergeRequestThreadPosition, + createdAt?: string +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/merge_requests/${mergeRequestIid}/discussions` + ); + + const payload: Record = { body }; + + // Add optional parameters if provided + if (position) { + payload.position = position; + } + + if (createdAt) { + payload.created_at = createdAt; + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify(payload), + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionSchema.parse(data); +} + +/** + * 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(`${config.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(), { + ...DEFAULT_FETCH_CONFIG, + }); + + 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(`${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(id)}`); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + 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(`${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(namespacePath)}/exists`); + + if (parentId) { + url.searchParams.append("parent_id", parentId.toString()); + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + 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 { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(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(), { + ...DEFAULT_FETCH_CONFIG, + }); + + 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 { + // Construct the query parameters + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(options)) { + if (value !== undefined && value !== null) { + if (typeof value === "boolean") { + params.append(key, value ? "true" : "false"); + } else { + params.append(key, String(value)); + } + } + } + + // Make the API request + const response = await fetch(`${config.GITLAB_API_URL}/projects?${params.toString()}`, { + ...DEFAULT_FETCH_CONFIG, + }); + + // Handle errors + await handleGitLabError(response); + + // Parse and return the data + const data = await response.json(); + return z.array(GitLabProjectSchema).parse(data); +} + +/** + * List labels for a project + * + * @param projectId The ID or URL-encoded path of the project + * @param options Optional parameters for listing labels + * @returns Array of GitLab labels + */ +async function listLabels( + projectId: string, + options: Omit, "project_id"> = {} +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + // Construct the URL with project path + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels`); + + // Add query parameters + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (typeof value === "boolean") { + url.searchParams.append(key, value ? "true" : "false"); + } else { + url.searchParams.append(key, String(value)); + } + } + }); + + // Make the API request + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + // Handle errors + await handleGitLabError(response); + + // Parse and return the data + const data = await response.json(); + return data as GitLabLabel[]; +} + +/** + * Get a single label from a project + * + * @param projectId The ID or URL-encoded path of the project + * @param labelId The ID or name of the label + * @param includeAncestorGroups Whether to include ancestor groups + * @returns GitLab label + */ +async function getLabel( + projectId: string, + labelId: number | string, + includeAncestorGroups?: boolean +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/labels/${encodeURIComponent(String(labelId))}` + ); + + // Add query parameters + if (includeAncestorGroups !== undefined) { + url.searchParams.append("include_ancestor_groups", includeAncestorGroups ? "true" : "false"); + } + + // Make the API request + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + // Handle errors + await handleGitLabError(response); + + // Parse and return the data + const data = await response.json(); + return data as GitLabLabel; +} + +/** + * Create a new label in a project + * + * @param projectId The ID or URL-encoded path of the project + * @param options Options for creating the label + * @returns Created GitLab label + */ +async function createLabel( + projectId: string, + options: Omit, "project_id"> +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + // Make the API request + const response = await fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels`, + { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify(options), + } + ); + + // Handle errors + await handleGitLabError(response); + + // Parse and return the data + const data = await response.json(); + return data as GitLabLabel; +} + +/** + * Update an existing label in a project + * + * @param projectId The ID or URL-encoded path of the project + * @param labelId The ID or name of the label to update + * @param options Options for updating the label + * @returns Updated GitLab label + */ +async function updateLabel( + projectId: string, + labelId: number | string, + options: Omit, "project_id" | "label_id"> +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + // Make the API request + const response = await fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/labels/${encodeURIComponent(String(labelId))}`, + { + ...DEFAULT_FETCH_CONFIG, + method: "PUT", + body: JSON.stringify(options), + } + ); + + // Handle errors + await handleGitLabError(response); + + // Parse and return the data + const data = await response.json(); + return data as GitLabLabel; +} + +/** + * Delete a label from a project + * + * @param projectId The ID or URL-encoded path of the project + * @param labelId The ID or name of the label to delete + */ +async function deleteLabel(projectId: string, labelId: number | string): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + // Make the API request + const response = await fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/labels/${encodeURIComponent(String(labelId))}`, + { + ...DEFAULT_FETCH_CONFIG, + method: "DELETE", + } + ); + + // Handle errors + await handleGitLabError(response); +} + +/** + * List all projects in a GitLab group + * + * @param {z.infer} options - Options for listing group projects + * @returns {Promise} Array of projects in the group + */ +async function listGroupProjects( + options: z.infer +): Promise { + const url = new URL(`${config.GITLAB_API_URL}/groups/${encodeURIComponent(options.group_id)}/projects`); + + // Add optional parameters to URL + if (options.include_subgroups) url.searchParams.append("include_subgroups", "true"); + if (options.search) url.searchParams.append("search", options.search); + if (options.order_by) url.searchParams.append("order_by", options.order_by); + if (options.sort) url.searchParams.append("sort", options.sort); + if (options.page) url.searchParams.append("page", options.page.toString()); + if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); + if (options.archived !== undefined) + url.searchParams.append("archived", options.archived.toString()); + if (options.visibility) url.searchParams.append("visibility", options.visibility); + if (options.with_issues_enabled !== undefined) + url.searchParams.append("with_issues_enabled", options.with_issues_enabled.toString()); + if (options.with_merge_requests_enabled !== undefined) + url.searchParams.append( + "with_merge_requests_enabled", + options.with_merge_requests_enabled.toString() + ); + if (options.min_access_level !== undefined) + url.searchParams.append("min_access_level", options.min_access_level.toString()); + if (options.with_programming_language) + url.searchParams.append("with_programming_language", options.with_programming_language); + if (options.starred !== undefined) url.searchParams.append("starred", options.starred.toString()); + if (options.statistics !== undefined) + url.searchParams.append("statistics", options.statistics.toString()); + if (options.with_custom_attributes !== undefined) + url.searchParams.append("with_custom_attributes", options.with_custom_attributes.toString()); + if (options.with_security_reports !== undefined) + url.searchParams.append("with_security_reports", options.with_security_reports.toString()); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + const projects = await response.json(); + return GitLabProjectSchema.array().parse(projects); +} + +// Wiki API helper functions +/** + * List wiki pages in a project + */ +async function listWikiPages( + projectId: string, + options: Omit = {} +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis`); + if (options.page) url.searchParams.append("page", options.page.toString()); + if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); + if (options.with_content) + url.searchParams.append("with_content", options.with_content.toString()); + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + await handleGitLabError(response); + const data = await response.json(); + return GitLabWikiPageSchema.array().parse(data); +} + +/** + * Get a specific wiki page + */ +async function getWikiPage(projectId: string, slug: string): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const response = await fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, + { ...DEFAULT_FETCH_CONFIG } + ); + await handleGitLabError(response); + const data = await response.json(); + return GitLabWikiPageSchema.parse(data); +} + +/** + * Create a new wiki page + */ +async function createWikiPage( + projectId: string, + title: string, + content: string, + format?: string +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const body: Record = { title, content }; + if (format) body.format = format; + const response = await fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis`, + { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify(body), + } + ); + await handleGitLabError(response); + const data = await response.json(); + return GitLabWikiPageSchema.parse(data); +} + +/** + * Update an existing wiki page + */ +async function updateWikiPage( + projectId: string, + slug: string, + title?: string, + content?: string, + format?: string +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const body: Record = {}; + if (title) body.title = title; + if (content) body.content = content; + if (format) body.format = format; + const response = await fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, + { + ...DEFAULT_FETCH_CONFIG, + method: "PUT", + body: JSON.stringify(body), + } + ); + await handleGitLabError(response); + const data = await response.json(); + return GitLabWikiPageSchema.parse(data); +} + +/** + * Delete a wiki page + */ +async function deleteWikiPage(projectId: string, slug: string): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const response = await fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, + { + ...DEFAULT_FETCH_CONFIG, + method: "DELETE", + } + ); + await handleGitLabError(response); +} + +/** + * List pipelines in a GitLab project + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {ListPipelinesOptions} options - Options for filtering pipelines + * @returns {Promise} List of pipelines + */ +async function listPipelines( + projectId: string, + options: Omit = {} +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines`); + + // Add all query parameters + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.append(key, value.toString()); + } + }); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabPipelineSchema).parse(data); +} + +/** + * Get details of a specific pipeline + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} pipelineId - The ID of the pipeline + * @returns {Promise} Pipeline details + */ +async function getPipeline(projectId: string, pipelineId: number): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + if (response.status === 404) { + throw new Error(`Pipeline not found`); + } + + await handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); +} + +/** + * List all jobs in a specific pipeline + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} pipelineId - The ID of the pipeline + * @param {Object} options - Options for filtering jobs + * @returns {Promise} List of pipeline jobs + */ +async function listPipelineJobs( + projectId: string, + pipelineId: number, + options: Omit = {} +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/jobs` + ); + + // Add all query parameters + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (typeof value === "boolean") { + url.searchParams.append(key, value ? "true" : "false"); + } else { + url.searchParams.append(key, value.toString()); + } + } + }); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + if (response.status === 404) { + throw new Error(`Pipeline not found`); + } + + await handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabPipelineJobSchema).parse(data); +} +async function getPipelineJob(projectId: string, jobId: number): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}`); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + if (response.status === 404) { + throw new Error(`Job not found`); + } + + await handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineJobSchema.parse(data); +} + +/** + * Get the output/trace of a pipeline job + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} jobId - The ID of the job + * @param {number} limit - Maximum number of lines to return from the end (default: 1000) + * @param {number} offset - Number of lines to skip from the end (default: 0) + * @returns {Promise} The job output/trace + */ +async function getPipelineJobOutput(projectId: string, jobId: number, limit?: number, offset?: number): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/trace` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + headers: { + ...DEFAULT_HEADERS, + Accept: "text/plain", // Override Accept header to get plain text + }, + }); + + if (response.status === 404) { + throw new Error(`Job trace not found or job is not finished yet`); + } + + await handleGitLabError(response); + const fullTrace = await response.text(); + + // Apply client-side pagination to limit context window usage + if (limit !== undefined || offset !== undefined) { + const lines = fullTrace.split('\n'); + const startOffset = offset || 0; + const maxLines = limit || 1000; + + // Return lines from the end, skipping offset lines and limiting to maxLines + const startIndex = Math.max(0, lines.length - startOffset - maxLines); + const endIndex = lines.length - startOffset; + + const selectedLines = lines.slice(startIndex, endIndex); + const result = selectedLines.join('\n'); + + // Add metadata about truncation + if (startIndex > 0 || endIndex < lines.length) { + const totalLines = lines.length; + const shownLines = selectedLines.length; + const skippedFromStart = startIndex; + const skippedFromEnd = startOffset; + + return `[Log truncated: showing ${shownLines} of ${totalLines} lines, skipped ${skippedFromStart} from start, ${skippedFromEnd} from end]\n\n${result}`; + } + + return result; + } + + return fullTrace; +} + +/** + * Create a new pipeline + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {string} ref - The branch or tag to run the pipeline on + * @param {Array} variables - Optional variables for the pipeline + * @returns {Promise} The created pipeline + */ +async function createPipeline( + projectId: string, + ref: string, + variables?: Array<{ key: string; value: string }> +): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipeline`); + + const body: any = { ref }; + if (variables && variables.length > 0) { + body.variables = variables.reduce( + (acc, { key, value }) => { + acc[key] = value; + return acc; + }, + {} as Record + ); + } + + const response = await fetch(url.toString(), { + method: "POST", + headers: DEFAULT_HEADERS, + body: JSON.stringify(body), + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); +} + +/** + * Retry a pipeline + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} pipelineId - The ID of the pipeline to retry + * @returns {Promise} The retried pipeline + */ +async function retryPipeline(projectId: string, pipelineId: number): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry` + ); + + const response = await fetch(url.toString(), { + method: "POST", + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); +} + +/** + * Cancel a pipeline + * + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} pipelineId - The ID of the pipeline to cancel + * @returns {Promise} The canceled pipeline + */ +async function cancelPipeline(projectId: string, pipelineId: number): Promise { + projectId = decodeURIComponent(projectId); // Decode project ID + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel` + ); + + const response = await fetch(url.toString(), { + method: "POST", + headers: DEFAULT_HEADERS, + }); + + await handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); +} + +/** + * Get the repository tree for a project + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {GetRepositoryTreeOptions} options - Options for the tree + * @returns {Promise} + */ +async function getRepositoryTree(options: GetRepositoryTreeOptions): Promise { + options.project_id = decodeURIComponent(options.project_id); // Decode project_id within options + const queryParams = new URLSearchParams(); + if (options.path) queryParams.append("path", options.path); + if (options.ref) queryParams.append("ref", options.ref); + if (options.recursive) queryParams.append("recursive", "true"); + if (options.per_page) queryParams.append("per_page", options.per_page.toString()); + if (options.page_token) queryParams.append("page_token", options.page_token); + if (options.pagination) queryParams.append("pagination", options.pagination); + + const headers: Record = { + "Content-Type": "application/json", + }; + if (config.IS_OLD) { + headers["Private-Token"] = `${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; + } else { + headers["Authorization"] = `Bearer ${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; + } + const response = await fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + options.project_id + )}/repository/tree?${queryParams.toString()}`, + { + headers, + } + ); + + if (response.status === 404) { + throw new Error("Repository or path not found"); + } + + if (!response.ok) { + throw new Error(`Failed to get repository tree: ${response.statusText}`); + } + + const data = await response.json(); + return z.array(GitLabTreeItemSchema).parse(data); +} + +/** + * List project milestones in a GitLab project + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {Object} options - Options for listing milestones + * @returns {Promise} List of milestones + */ +async function listProjectMilestones( + projectId: string, + options: Omit, "project_id"> +): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones`); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (key === "iids" && Array.isArray(value) && value.length > 0) { + value.forEach(iid => { + url.searchParams.append("iids[]", iid.toString()); + }); + } else if (value !== undefined) { + url.searchParams.append(key, value.toString()); + } + } + }); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + await handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabMilestonesSchema).parse(data); +} + +/** + * Get a single milestone in a GitLab project + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} milestoneId - The ID of the milestone + * @returns {Promise} Milestone details + */ +async function getProjectMilestone( + projectId: string, + milestoneId: number +): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + await handleGitLabError(response); + const data = await response.json(); + return GitLabMilestonesSchema.parse(data); +} + +/** + * Create a new milestone in a GitLab project + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {Object} options - Options for creating a milestone + * @returns {Promise} Created milestone + */ +async function createProjectMilestone( + projectId: string, + options: Omit, "project_id"> +): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones`); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + body: JSON.stringify(options), + }); + await handleGitLabError(response); + const data = await response.json(); + return GitLabMilestonesSchema.parse(data); +} + +/** + * Edit an existing milestone in a GitLab project + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} milestoneId - The ID of the milestone + * @param {Object} options - Options for editing a milestone + * @returns {Promise} Updated milestone + */ +async function editProjectMilestone( + projectId: string, + milestoneId: number, + options: Omit, "project_id" | "milestone_id"> +): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "PUT", + body: JSON.stringify(options), + }); + await handleGitLabError(response); + const data = await response.json(); + return GitLabMilestonesSchema.parse(data); +} + +/** + * Delete a milestone from a GitLab project + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} milestoneId - The ID of the milestone + * @returns {Promise} + */ +async function deleteProjectMilestone(projectId: string, milestoneId: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "DELETE", + }); + await handleGitLabError(response); +} + +/** + * Get all issues assigned to a single milestone + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} milestoneId - The ID of the milestone + * @returns {Promise} List of issues + */ +async function getMilestoneIssues(projectId: string, milestoneId: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/issues` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + await handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabIssueSchema).parse(data); +} + +/** + * Get all merge requests assigned to a single milestone + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} milestoneId - The ID of the milestone + * @returns {Promise} List of merge requests + */ +async function getMilestoneMergeRequests( + projectId: string, + milestoneId: number +): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/milestones/${milestoneId}/merge_requests` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + await handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabMergeRequestSchema).parse(data); +} + +/** + * Promote a project milestone to a group milestone + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} milestoneId - The ID of the milestone + * @returns {Promise} Promoted milestone + */ +async function promoteProjectMilestone( + projectId: string, + milestoneId: number +): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/promote` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + method: "POST", + }); + await handleGitLabError(response); + const data = await response.json(); + return GitLabMilestonesSchema.parse(data); +} + +/** + * Get all burndown chart events for a single milestone + * @param {string} projectId - The ID or URL-encoded path of the project + * @param {number} milestoneId - The ID of the milestone + * @returns {Promise} Burndown chart events + */ +async function getMilestoneBurndownEvents(projectId: string, milestoneId: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + getEffectiveProjectId(projectId) + )}/milestones/${milestoneId}/burndown_events` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + await handleGitLabError(response); + const data = await response.json(); + return data as any[]; +} + +/** + * Get a single user from GitLab + * + * @param {string} username - The username to look up + * @returns {Promise} The user data or null if not found + */ +async function getUser(username: string): Promise { + try { + const url = new URL(`${config.GITLAB_API_URL}/users`); + url.searchParams.append("username", username); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + + const users = await response.json(); + + // GitLab returns an array of users that match the username + if (Array.isArray(users) && users.length > 0) { + // Find exact match for username (case-sensitive) + const exactMatch = users.find(user => user.username === username); + if (exactMatch) { + return GitLabUserSchema.parse(exactMatch); + } + } + + // No matching user found + return null; + } catch (error) { + console.error(`Error fetching user by username '${username}':`, error); + return null; + } +} + +/** + * Get multiple users from GitLab + * + * @param {string[]} usernames - Array of usernames to look up + * @returns {Promise} Object with usernames as keys and user objects or null as values + */ +async function getUsers(usernames: string[]): Promise { + const users: Record = {}; + + // Process usernames sequentially to avoid rate limiting + for (const username of usernames) { + try { + const user = await getUser(username); + users[username] = user; + } catch (error) { + console.error(`Error processing username '${username}':`, error); + users[username] = null; + } + } + + return GitLabUsersResponseSchema.parse(users); +} + +/** + * List repository commits + * 저장소 커밋 목록 조회 + * + * @param {string} projectId - Project ID or URL-encoded path + * @param {ListCommitsOptions} options - List commits options + * @returns {Promise} List of commits + */ +async function listCommits( + projectId: string, + options: Omit = {} +): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits` + ); + + // Add query parameters + if (options.ref_name) url.searchParams.append("ref_name", options.ref_name); + if (options.since) url.searchParams.append("since", options.since); + if (options.until) url.searchParams.append("until", options.until); + if (options.path) url.searchParams.append("path", options.path); + if (options.author) url.searchParams.append("author", options.author); + if (options.all) url.searchParams.append("all", options.all.toString()); + if (options.with_stats) url.searchParams.append("with_stats", options.with_stats.toString()); + if (options.first_parent) url.searchParams.append("first_parent", options.first_parent.toString()); + if (options.order) url.searchParams.append("order", options.order); + if (options.trailers) url.searchParams.append("trailers", options.trailers.toString()); + if (options.page) url.searchParams.append("page", options.page.toString()); + if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + + const data = await response.json(); + return z.array(GitLabCommitSchema).parse(data); +} + +/** + * Get a single commit + * 단일 커밋 정보 조회 + * + * @param {string} projectId - Project ID or URL-encoded path + * @param {string} sha - The commit hash or name of a repository branch or tag + * @param {boolean} [stats] - Include commit stats + * @returns {Promise} The commit details + */ +async function getCommit( + projectId: string, + sha: string, + stats?: boolean +): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}` + ); + + if (stats) { + url.searchParams.append("stats", "true"); + } + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + + const data = await response.json(); + return GitLabCommitSchema.parse(data); +} + +/** + * Get commit diff + * 커밋 변경사항 조회 + * + * @param {string} projectId - Project ID or URL-encoded path + * @param {string} sha - The commit hash or name of a repository branch or tag + * @returns {Promise} The commit diffs + */ +async function getCommitDiff( + projectId: string, + sha: string +): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}/diff` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + await handleGitLabError(response); + + const data = await response.json(); + return z.array(GitLabDiffSchema).parse(data); +} + +server.setRequestHandler(ListToolsRequestSchema, async () => { + // Apply read-only filter first + const tools0 = config.GITLAB_READ_ONLY_MODE + ? allTools.filter(tool => readOnlyTools.includes(tool.name)) + : allTools; + // Toggle wiki tools by USE_GITLAB_WIKI flag + const tools1 = config.USE_GITLAB_WIKI + ? tools0 + : tools0.filter(tool => !wikiToolNames.includes(tool.name)); + // Toggle milestone tools by USE_MILESTONE flag + const tools2 = config.USE_MILESTONE + ? tools1 + : tools1.filter(tool => !milestoneToolNames.includes(tool.name)); + // Toggle pipeline tools by USE_PIPELINE flag + let tools = config.USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.includes(tool.name)); + + // <<< START: Gemini 호환성을 위해 $schema 제거 >>> + tools = tools.map(tool => { + // inputSchema가 존재하고 객체인지 확인 + if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) { + // $schema 키가 존재하면 삭제 + if ("$schema" in tool.inputSchema) { + // 불변성을 위해 새로운 객체 생성 (선택적이지만 권장) + const modifiedSchema = { ...tool.inputSchema }; + delete modifiedSchema.$schema; + return { ...tool, inputSchema: modifiedSchema }; + } + } + // 변경이 필요 없으면 그대로 반환 + return tool; + }); + // <<< END: Gemini 호환성을 위해 $schema 제거 >>> + + return { + tools, // $schema가 제거된 도구 목록 반환 + }; +}); + +server.setRequestHandler(CallToolRequestSchema, async request => { + try { + if (!request.params.arguments) { + throw new Error("Arguments are required"); + } + + // Ensure session is established for every request if cookie authentication is enabled + if (config.GITLAB_AUTH_COOKIE_PATH) { + await ensureSessionForRequest(); + } + + switch (request.params.name) { + case "fork_repository": { + if (config.GITLAB_PROJECT_ID) { + throw new Error("Direct project ID is set. So fork_repository is not allowed"); + } + const forkArgs = ForkRepositorySchema.parse(request.params.arguments); + try { + const forkedProject = await forkProject(forkArgs.project_id, forkArgs.namespace); + return { + content: [{ type: "text", text: JSON.stringify(forkedProject, null, 2) }], + }; + } catch (forkError) { + console.error("Error forking repository:", forkError); + let forkErrorMessage = "Failed to fork repository"; + if (forkError instanceof Error) { + forkErrorMessage = `${forkErrorMessage}: ${forkError.message}`; + } + return { + content: [ + { + type: "text", + text: JSON.stringify({ error: forkErrorMessage }, null, 2), + }, + ], + }; + } + } + + case "create_branch": { + const args = CreateBranchSchema.parse(request.params.arguments); + let ref = args.ref; + if (!ref) { + ref = await getDefaultBranchRef(args.project_id); + } + + const branch = await createBranch(args.project_id, { + name: args.branch, + ref, + }); + + return { + content: [{ type: "text", text: JSON.stringify(branch, null, 2) }], + }; + } + + case "get_branch_diffs": { + const args = GetBranchDiffsSchema.parse(request.params.arguments); + const diffResp = await getBranchDiffs(args.project_id, args.from, args.to, args.straight); + + if (args.excluded_file_patterns?.length) { + const regexPatterns = args.excluded_file_patterns.map(pattern => new RegExp(pattern)); + + // Helper function to check if a path matches any regex pattern + const matchesAnyPattern = (path: string): boolean => { + if (!path) return false; + return regexPatterns.some(regex => regex.test(path)); + }; + + // Filter out files that match any of the regex patterns on new files + diffResp.diffs = diffResp.diffs.filter(diff => !matchesAnyPattern(diff.new_path)); + } + return { + content: [{ type: "text", text: JSON.stringify(diffResp, null, 2) }], + }; + } + + case "search_repositories": { + const args = SearchRepositoriesSchema.parse(request.params.arguments); + const results = await searchProjects(args.search, args.page, args.per_page); + return { + content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + }; + } + + case "create_repository": { + if (config.GITLAB_PROJECT_ID) { + throw new Error("Direct project ID is set. So fork_repository is not allowed"); + } + const args = CreateRepositorySchema.parse(request.params.arguments); + const repository = await createRepository(args); + return { + content: [{ type: "text", text: JSON.stringify(repository, null, 2) }], + }; + } + + case "get_file_contents": { + const args = GetFileContentsSchema.parse(request.params.arguments); + const contents = await getFileContents(args.project_id, args.file_path, args.ref); + return { + content: [{ type: "text", text: JSON.stringify(contents, null, 2) }], + }; + } + + case "create_or_update_file": { + const args = CreateOrUpdateFileSchema.parse(request.params.arguments); + const result = await createOrUpdateFile( + args.project_id, + args.file_path, + args.content, + args.commit_message, + args.branch, + args.previous_path, + args.last_commit_id, + args.commit_id + ); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + + case "push_files": { + const args = PushFilesSchema.parse(request.params.arguments); + const result = await createCommit( + args.project_id, + args.commit_message, + args.branch, + args.files.map(f => ({ path: f.file_path, content: f.content })) + ); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + + case "create_issue": { + const args = CreateIssueSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const issue = await createIssue(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], + }; + } + + case "create_merge_request": { + const args = CreateMergeRequestSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const mergeRequest = await createMergeRequest(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], + }; + } + + case "update_merge_request_note": { + const args = UpdateMergeRequestNoteSchema.parse(request.params.arguments); + const note = await updateMergeRequestNote( + args.project_id, + args.merge_request_iid, + args.discussion_id, + args.note_id, + args.body, // Now optional + args.resolved // Now one of body or resolved must be provided, not both + ); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "create_merge_request_note": { + const args = CreateMergeRequestNoteSchema.parse(request.params.arguments); + const note = await createMergeRequestNote( + args.project_id, + args.merge_request_iid, + args.discussion_id, + args.body, + args.created_at + ); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "update_issue_note": { + const args = UpdateIssueNoteSchema.parse(request.params.arguments); + const note = await updateIssueNote( + args.project_id, + args.issue_iid, + args.discussion_id, + args.note_id, + args.body + ); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "create_issue_note": { + const args = CreateIssueNoteSchema.parse(request.params.arguments); + const note = await createIssueNote( + args.project_id, + args.issue_iid, + args.discussion_id, + args.body, + args.created_at + ); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "get_merge_request": { + const args = GetMergeRequestSchema.parse(request.params.arguments); + const mergeRequest = await getMergeRequest( + args.project_id, + args.merge_request_iid, + args.source_branch + ); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], + }; + } + + case "get_merge_request_diffs": { + const args = GetMergeRequestDiffsSchema.parse(request.params.arguments); + const diffs = await getMergeRequestDiffs( + args.project_id, + args.merge_request_iid, + args.source_branch, + args.view + ); + return { + content: [{ type: "text", text: JSON.stringify(diffs, null, 2) }], + }; + } + + case "list_merge_request_diffs": { + const args = ListMergeRequestDiffsSchema.parse(request.params.arguments); + const changes = await listMergeRequestDiffs( + args.project_id, + args.merge_request_iid, + args.source_branch, + args.page, + args.per_page, + args.unidiff + ); + return { + content: [{ type: "text", text: JSON.stringify(changes, null, 2) }], + }; + } + + case "update_merge_request": { + const args = UpdateMergeRequestSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, source_branch, ...options } = args; + const mergeRequest = await updateMergeRequest( + project_id, + options, + merge_request_iid, + source_branch + ); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], + }; + } + + case "mr_discussions": { + const args = ListMergeRequestDiscussionsSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, ...options } = args; + const discussions = await listMergeRequestDiscussions( + project_id, + merge_request_iid, + options + ); + return { + content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], + }; + } + + case "list_namespaces": { + const args = ListNamespacesSchema.parse(request.params.arguments); + const url = new URL(`${config.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(), { + ...DEFAULT_FETCH_CONFIG, + }); + + 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( + `${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(args.namespace_id)}` + ); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + 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(`${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(args.path)}/exists`); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + 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(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(args.project_id)}`); + + const response = await fetch(url.toString(), { + ...DEFAULT_FETCH_CONFIG, + }); + + 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 projects = await listProjects(args); + + return { + content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], + }; + } + + case "get_users": { + const args = GetUsersSchema.parse(request.params.arguments); + const usersMap = await getUsers(args.usernames); + + return { + content: [{ type: "text", text: JSON.stringify(usersMap, null, 2) }], + }; + } + + case "create_note": { + const args = CreateNoteSchema.parse(request.params.arguments); + const { project_id, noteable_type, noteable_iid, body } = args; + + const note = await createNote(project_id, noteable_type, noteable_iid, body); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "create_merge_request_thread": { + const args = CreateMergeRequestThreadSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, body, position, created_at } = args; + + const thread = await createMergeRequestThread( + project_id, + merge_request_iid, + body, + position, + created_at + ); + return { + content: [{ type: "text", text: JSON.stringify(thread, null, 2) }], + }; + } + + case "list_issues": { + const args = ListIssuesSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const issues = await listIssues(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(issues, null, 2) }], + }; + } + + case "get_issue": { + const args = GetIssueSchema.parse(request.params.arguments); + const issue = await getIssue(args.project_id, args.issue_iid); + return { + content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], + }; + } + + case "update_issue": { + const args = UpdateIssueSchema.parse(request.params.arguments); + const { project_id, issue_iid, ...options } = args; + const issue = await updateIssue(project_id, issue_iid, options); + return { + content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], + }; + } + + case "delete_issue": { + const args = DeleteIssueSchema.parse(request.params.arguments); + await deleteIssue(args.project_id, args.issue_iid); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { status: "success", message: "Issue deleted successfully" }, + null, + 2 + ), + }, + ], + }; + } + + case "list_issue_links": { + const args = ListIssueLinksSchema.parse(request.params.arguments); + const links = await listIssueLinks(args.project_id, args.issue_iid); + return { + content: [{ type: "text", text: JSON.stringify(links, null, 2) }], + }; + } + + case "list_issue_discussions": { + const args = ListIssueDiscussionsSchema.parse(request.params.arguments); + const { project_id, issue_iid, ...options } = args; + + const discussions = await listIssueDiscussions(project_id, issue_iid, options); + return { + content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], + }; + } + + case "get_issue_link": { + const args = GetIssueLinkSchema.parse(request.params.arguments); + const link = await getIssueLink(args.project_id, args.issue_iid, args.issue_link_id); + return { + content: [{ type: "text", text: JSON.stringify(link, null, 2) }], + }; + } + + case "create_issue_link": { + const args = CreateIssueLinkSchema.parse(request.params.arguments); + const link = await createIssueLink( + args.project_id, + args.issue_iid, + args.target_project_id, + args.target_issue_iid, + args.link_type + ); + return { + content: [{ type: "text", text: JSON.stringify(link, null, 2) }], + }; + } + + case "delete_issue_link": { + const args = DeleteIssueLinkSchema.parse(request.params.arguments); + await deleteIssueLink(args.project_id, args.issue_iid, args.issue_link_id); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + status: "success", + message: "Issue link deleted successfully", + }, + null, + 2 + ), + }, + ], + }; + } + + case "list_labels": { + const args = ListLabelsSchema.parse(request.params.arguments); + const labels = await listLabels(args.project_id, args); + return { + content: [{ type: "text", text: JSON.stringify(labels, null, 2) }], + }; + } + + case "get_label": { + const args = GetLabelSchema.parse(request.params.arguments); + const label = await getLabel(args.project_id, args.label_id, args.include_ancestor_groups); + return { + content: [{ type: "text", text: JSON.stringify(label, null, 2) }], + }; + } + + case "create_label": { + const args = CreateLabelSchema.parse(request.params.arguments); + const label = await createLabel(args.project_id, args); + return { + content: [{ type: "text", text: JSON.stringify(label, null, 2) }], + }; + } + + case "update_label": { + const args = UpdateLabelSchema.parse(request.params.arguments); + const { project_id, label_id, ...options } = args; + const label = await updateLabel(project_id, label_id, options); + return { + content: [{ type: "text", text: JSON.stringify(label, null, 2) }], + }; + } + + case "delete_label": { + const args = DeleteLabelSchema.parse(request.params.arguments); + await deleteLabel(args.project_id, args.label_id); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { status: "success", message: "Label deleted successfully" }, + null, + 2 + ), + }, + ], + }; + } + + case "list_group_projects": { + const args = ListGroupProjectsSchema.parse(request.params.arguments); + const projects = await listGroupProjects(args); + return { + content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], + }; + } + + case "list_wiki_pages": { + const { project_id, page, per_page, with_content } = ListWikiPagesSchema.parse( + request.params.arguments + ); + const wikiPages = await listWikiPages(project_id, { page, per_page, with_content }); + return { + content: [{ type: "text", text: JSON.stringify(wikiPages, null, 2) }], + }; + } + + case "get_wiki_page": { + const { project_id, slug } = GetWikiPageSchema.parse(request.params.arguments); + const wikiPage = await getWikiPage(project_id, slug); + return { + content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], + }; + } + + case "create_wiki_page": { + const { project_id, title, content, format } = CreateWikiPageSchema.parse( + request.params.arguments + ); + const wikiPage = await createWikiPage(project_id, title, content, format); + return { + content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], + }; + } + + case "update_wiki_page": { + const { project_id, slug, title, content, format } = UpdateWikiPageSchema.parse( + request.params.arguments + ); + const wikiPage = await updateWikiPage(project_id, slug, title, content, format); + return { + content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], + }; + } + + case "delete_wiki_page": { + const { project_id, slug } = DeleteWikiPageSchema.parse(request.params.arguments); + await deleteWikiPage(project_id, slug); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + status: "success", + message: "Wiki page deleted successfully", + }, + null, + 2 + ), + }, + ], + }; + } + + case "get_repository_tree": { + const args = GetRepositoryTreeSchema.parse(request.params.arguments); + const tree = await getRepositoryTree(args); + return { + content: [{ type: "text", text: JSON.stringify(tree, null, 2) }], + }; + } + + case "list_pipelines": { + const args = ListPipelinesSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const pipelines = await listPipelines(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(pipelines, null, 2) }], + }; + } + + case "get_pipeline": { + const { project_id, pipeline_id } = GetPipelineSchema.parse(request.params.arguments); + const pipeline = await getPipeline(project_id, pipeline_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(pipeline, null, 2), + }, + ], + }; + } + + case "list_pipeline_jobs": { + const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse( + request.params.arguments + ); + const jobs = await listPipelineJobs(project_id, pipeline_id, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(jobs, null, 2), + }, + ], + }; + } + + case "get_pipeline_job": { + const { project_id, job_id } = GetPipelineJobOutputSchema.parse(request.params.arguments); + const jobDetails = await getPipelineJob(project_id, job_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(jobDetails, null, 2), + }, + ], + }; + } + + case "get_pipeline_job_output": { + const { project_id, job_id, limit, offset } = GetPipelineJobOutputSchema.parse(request.params.arguments); + const jobOutput = await getPipelineJobOutput(project_id, job_id, limit, offset); + return { + content: [ + { + type: "text", + text: jobOutput, + }, + ], + }; + } + + case "create_pipeline": { + const { project_id, ref, variables } = CreatePipelineSchema.parse(request.params.arguments); + const pipeline = await createPipeline(project_id, ref, variables); + return { + content: [ + { + type: "text", + text: `Created pipeline #${pipeline.id} for ${ref}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, + }, + ], + }; + } + + case "retry_pipeline": { + const { project_id, pipeline_id } = RetryPipelineSchema.parse(request.params.arguments); + const pipeline = await retryPipeline(project_id, pipeline_id); + return { + content: [ + { + type: "text", + text: `Retried pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, + }, + ], + }; + } + + case "cancel_pipeline": { + const { project_id, pipeline_id } = CancelPipelineSchema.parse(request.params.arguments); + const pipeline = await cancelPipeline(project_id, pipeline_id); + return { + content: [ + { + type: "text", + text: `Canceled pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, + }, + ], + }; + } + + case "list_merge_requests": { + const args = ListMergeRequestsSchema.parse(request.params.arguments); + const mergeRequests = await listMergeRequests(args.project_id, args); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }], + }; + } + + case "list_milestones": { + const { project_id, ...options } = ListProjectMilestonesSchema.parse( + request.params.arguments + ); + const milestones = await listProjectMilestones(project_id, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestones, null, 2), + }, + ], + }; + } + + case "get_milestone": { + const { project_id, milestone_id } = GetProjectMilestoneSchema.parse( + request.params.arguments + ); + const milestone = await getProjectMilestone(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestone, null, 2), + }, + ], + }; + } + + case "create_milestone": { + const { project_id, ...options } = CreateProjectMilestoneSchema.parse( + request.params.arguments + ); + const milestone = await createProjectMilestone(project_id, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestone, null, 2), + }, + ], + }; + } + + case "edit_milestone": { + const { project_id, milestone_id, ...options } = EditProjectMilestoneSchema.parse( + request.params.arguments + ); + const milestone = await editProjectMilestone(project_id, milestone_id, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestone, null, 2), + }, + ], + }; + } + + case "delete_milestone": { + const { project_id, milestone_id } = DeleteProjectMilestoneSchema.parse( + request.params.arguments + ); + await deleteProjectMilestone(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + status: "success", + message: "Milestone deleted successfully", + }, + null, + 2 + ), + }, + ], + }; + } + + case "get_milestone_issue": { + const { project_id, milestone_id } = GetMilestoneIssuesSchema.parse( + request.params.arguments + ); + const issues = await getMilestoneIssues(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(issues, null, 2), + }, + ], + }; + } + + case "get_milestone_merge_requests": { + const { project_id, milestone_id } = GetMilestoneMergeRequestsSchema.parse( + request.params.arguments + ); + const mergeRequests = await getMilestoneMergeRequests(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(mergeRequests, null, 2), + }, + ], + }; + } + + case "promote_milestone": { + const { project_id, milestone_id } = PromoteProjectMilestoneSchema.parse( + request.params.arguments + ); + const milestone = await promoteProjectMilestone(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestone, null, 2), + }, + ], + }; + } + + case "get_milestone_burndown_events": { + const { project_id, milestone_id } = GetMilestoneBurndownEventsSchema.parse( + request.params.arguments + ); + const events = await getMilestoneBurndownEvents(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(events, null, 2), + }, + ], + }; + } + + case "list_commits": { + const args = ListCommitsSchema.parse(request.params.arguments); + const commits = await listCommits(args.project_id, args); + return { + content: [{ type: "text", text: JSON.stringify(commits, null, 2) }], + }; + } + + case "get_commit": { + const args = GetCommitSchema.parse(request.params.arguments); + const commit = await getCommit(args.project_id, args.sha, args.stats); + return { + content: [{ type: "text", text: JSON.stringify(commit, null, 2) }], + }; + } + + case "get_commit_diff": { + const args = GetCommitDiffSchema.parse(request.params.arguments); + const diff = await getCommitDiff(args.project_id, args.sha); + return { + content: [{ type: "text", text: JSON.stringify(diff, null, 2) }], + }; + } + + default: + throw new Error(`Unknown tool: ${request.params.name}`); + } + } catch (error) { + if (error instanceof z.ZodError) { + throw new Error( + `Invalid arguments: ${error.errors + .map(e => `${e.path.join(".")}: ${e.message}`) + .join(", ")}` + ); + } + throw error; + } +}); + +export const mcpserver = server From 4cec54b20de190d4fce392f15d9f5f208ceb0700 Mon Sep 17 00:00:00 2001 From: a Date: Tue, 1 Jul 2025 22:41:11 -0500 Subject: [PATCH 02/24] noot --- agent.ts | 146 -- index.ts | 7 +- mcpserver.ts | 4141 ---------------------------------- src/authhelpers.ts | 50 + config.ts => src/config.ts | 4 + src/gitlabhandler.ts | 2235 ++++++++++++++++++ src/gitlabsession.ts | 115 + src/mcpserver.ts | 1469 ++++++++++++ schemas.ts => src/schemas.ts | 0 9 files changed, 3874 insertions(+), 4293 deletions(-) delete mode 100644 agent.ts delete mode 100644 mcpserver.ts create mode 100644 src/authhelpers.ts rename config.ts => src/config.ts (91%) create mode 100644 src/gitlabhandler.ts create mode 100644 src/gitlabsession.ts create mode 100644 src/mcpserver.ts rename schemas.ts => src/schemas.ts (100%) diff --git a/agent.ts b/agent.ts deleted file mode 100644 index 8041097..0000000 --- a/agent.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { config } from "./config.js"; -import nodeFetch from "node-fetch"; -import fetchCookie from "fetch-cookie"; -import { CookieJar, parse as parseCookie } from "tough-cookie"; -import { SocksProxyAgent } from "socks-proxy-agent"; -import { HttpsProxyAgent } from "https-proxy-agent"; -import { HttpProxyAgent } from "http-proxy-agent"; -import { Agent } from "http"; -import { Agent as HttpsAgent } from "https"; -import fs from "fs"; -import path from "path"; - -let sslOptions = undefined; -if (config.NODE_TLS_REJECT_UNAUTHORIZED === "0") { - sslOptions = { rejectUnauthorized: false }; -} else if (config.GITLAB_CA_CERT_PATH) { - const ca = fs.readFileSync(config.GITLAB_CA_CERT_PATH); - sslOptions = { ca }; -} - -// Configure proxy agents if proxies are set -let httpAgent: Agent | undefined = undefined; -let httpsAgent: Agent | undefined = undefined; - -if (config.HTTP_PROXY) { - if (config.HTTP_PROXY.startsWith("socks")) { - httpAgent = new SocksProxyAgent(config.HTTP_PROXY); - } else { - httpAgent = new HttpProxyAgent(config.HTTP_PROXY); - } -} -if (config.HTTPS_PROXY) { - if (config.HTTPS_PROXY.startsWith("socks")) { - httpsAgent = new SocksProxyAgent(config.HTTPS_PROXY); - } else { - httpsAgent = new HttpsProxyAgent(config.HTTPS_PROXY, sslOptions); - } -} -httpsAgent = httpsAgent || new HttpsAgent(sslOptions); -httpAgent = httpAgent || new Agent(); - -// Create cookie jar with clean Netscape file parsing -const createCookieJar = (): CookieJar | null => { - if (!config.GITLAB_AUTH_COOKIE_PATH) return null; - - try { - const cookiePath = config.GITLAB_AUTH_COOKIE_PATH.startsWith("~/") - ? path.join(process.env.HOME || "", config.GITLAB_AUTH_COOKIE_PATH.slice(2)) - : config.GITLAB_AUTH_COOKIE_PATH; - - const jar = new CookieJar(); - const cookieContent = fs.readFileSync(cookiePath, "utf8"); - - cookieContent.split("\n").forEach(line => { - // Handle #HttpOnly_ prefix - if (line.startsWith("#HttpOnly_")) { - line = line.slice(10); - } - // Skip comments and empty lines - if (line.startsWith("#") || !line.trim()) { - return; - } - - // Parse Netscape format: domain, flag, path, secure, expires, name, value - const parts = line.split("\t"); - if (parts.length >= 7) { - const [domain, , path, secure, expires, name, value] = parts; - - // Build cookie string in standard format - const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secure === "TRUE" ? "; Secure" : ""}${expires !== "0" ? `; Expires=${new Date(parseInt(expires) * 1000).toUTCString()}` : ""}`; - - // Use tough-cookie's parse function for robust parsing - const cookie = parseCookie(cookieStr); - if (cookie) { - const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`; - jar.setCookieSync(cookie, url); - } - } - }); - - return jar; - } catch (error) { - console.error("Error loading cookie file:", error); - return null; - } -}; - -// Initialize cookie jar and fetch -const cookieJar = createCookieJar(); - -// Ensure session is established for the current request -export async function ensureSessionForRequest(): Promise { - if (!cookieJar || !config.GITLAB_AUTH_COOKIE_PATH) return; - - // Extract the base URL from GITLAB_API_URL - const apiUrl = new URL(config.GITLAB_API_URL); - const baseUrl = `${apiUrl.protocol}//${apiUrl.hostname}`; - - // Check if we already have GitLab session cookies - const gitlabCookies = cookieJar.getCookiesSync(baseUrl); - const hasSessionCookie = gitlabCookies.some(cookie => - cookie.key === '_gitlab_session' || cookie.key === 'remember_user_token' - ); - - if (!hasSessionCookie) { - try { - // Establish session with a lightweight request - await fetch(`${config.GITLAB_API_URL}/user`, { - ...DEFAULT_FETCH_CONFIG, - redirect: 'follow' - }).catch(() => { - // Ignore errors - the important thing is that cookies get set during redirects - }); - - // Small delay to ensure cookies are fully processed - await new Promise(resolve => setTimeout(resolve, 100)); - } catch (error) { - // Ignore session establishment errors - } - } -} - - -export const fetch = cookieJar ? fetchCookie(nodeFetch, cookieJar) : nodeFetch; -// Modify DEFAULT_HEADERS to include agent configuration -export const DEFAULT_HEADERS: Record = { - Accept: "application/json", - "Content-Type": "application/json", -}; - -if (config.IS_OLD) { - DEFAULT_HEADERS["Private-Token"] = `${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; -} else { - DEFAULT_HEADERS["Authorization"] = `Bearer ${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; -} - -export const DEFAULT_FETCH_CONFIG = { - headers: DEFAULT_HEADERS, - agent: (parsedUrl: URL) => { - if (parsedUrl.protocol === "https:") { - return httpsAgent; - } - return httpAgent; - }, -}; - diff --git a/index.ts b/index.ts index 834009a..b88cd6e 100644 --- a/index.ts +++ b/index.ts @@ -3,12 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express, { Request, Response } from "express"; -// Add type imports for proxy agents -import { Agent } from "http"; -import { Agent as HttpsAgent } from "https"; -import { URL } from "url"; - -import {mcpserver} from "./mcpserver.js"; +import {mcpserver} from "./src/mcpserver.js"; const SSE = process.env.SSE === "true"; diff --git a/mcpserver.ts b/mcpserver.ts deleted file mode 100644 index 7d6e862..0000000 --- a/mcpserver.ts +++ /dev/null @@ -1,4141 +0,0 @@ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; - -import { z } from "zod"; -import { zodToJsonSchema } from "zod-to-json-schema"; -import { fileURLToPath } from "url"; -import { dirname } from "path"; -import fs from "fs"; -import path from "path"; -// Add type imports for proxy agents -import { URL } from "url"; - -import {config} from "./config.js" -import {DEFAULT_HEADERS, ensureSessionForRequest, fetch} from "./agent.js" - -import { - GitLabForkSchema, - GitLabReferenceSchema, - GitLabRepositorySchema, - GitLabIssueSchema, - GitLabMergeRequestSchema, - GitLabContentSchema, - GitLabCreateUpdateFileResponseSchema, - GitLabSearchResponseSchema, - GitLabTreeSchema, - GitLabCommitSchema, - GitLabNamespaceSchema, - GitLabNamespaceExistsResponseSchema, - GitLabProjectSchema, - GitLabLabelSchema, - GitLabUserSchema, - GitLabUsersResponseSchema, - GetUsersSchema, - CreateRepositoryOptionsSchema, - CreateIssueOptionsSchema, - CreateMergeRequestOptionsSchema, - CreateBranchOptionsSchema, - CreateOrUpdateFileSchema, - SearchRepositoriesSchema, - CreateRepositorySchema, - GetFileContentsSchema, - PushFilesSchema, - CreateIssueSchema, - CreateMergeRequestSchema, - ForkRepositorySchema, - CreateBranchSchema, - GitLabDiffSchema, - GetMergeRequestSchema, - GetMergeRequestDiffsSchema, - UpdateMergeRequestSchema, - ListIssuesSchema, - GetIssueSchema, - UpdateIssueSchema, - DeleteIssueSchema, - GitLabIssueLinkSchema, - GitLabIssueWithLinkDetailsSchema, - ListIssueLinksSchema, - ListIssueDiscussionsSchema, - GetIssueLinkSchema, - CreateIssueLinkSchema, - DeleteIssueLinkSchema, - ListNamespacesSchema, - GetNamespaceSchema, - VerifyNamespaceSchema, - GetProjectSchema, - ListProjectsSchema, - ListLabelsSchema, - GetLabelSchema, - CreateLabelSchema, - UpdateLabelSchema, - DeleteLabelSchema, - CreateNoteSchema, - CreateMergeRequestThreadSchema, - ListGroupProjectsSchema, - ListWikiPagesSchema, - GetWikiPageSchema, - CreateWikiPageSchema, - UpdateWikiPageSchema, - DeleteWikiPageSchema, - GitLabWikiPageSchema, - GetRepositoryTreeSchema, - GitLabTreeItemSchema, - GitLabPipelineSchema, - GetPipelineSchema, - ListPipelinesSchema, - ListPipelineJobsSchema, - CreatePipelineSchema, - RetryPipelineSchema, - CancelPipelineSchema, - // pipeline job schemas - GetPipelineJobOutputSchema, - GitLabPipelineJobSchema, - // Discussion Schemas - GitLabDiscussionNoteSchema, // Added - GitLabDiscussionSchema, - PaginatedDiscussionsResponseSchema, - UpdateMergeRequestNoteSchema, // Added - CreateMergeRequestNoteSchema, // Added - ListMergeRequestDiscussionsSchema, - type GitLabFork, - type GitLabReference, - type GitLabRepository, - type GitLabIssue, - type GitLabMergeRequest, - type GitLabContent, - type GitLabCreateUpdateFileResponse, - type GitLabSearchResponse, - type GitLabTree, - type GitLabCommit, - type FileOperation, - type GitLabMergeRequestDiff, - type GitLabIssueLink, - type GitLabIssueWithLinkDetails, - type GitLabNamespace, - type GitLabNamespaceExistsResponse, - type GitLabProject, - type GitLabLabel, - type GitLabUser, - type GitLabUsersResponse, - type GitLabPipeline, - type ListPipelinesOptions, - type GetPipelineOptions, - type ListPipelineJobsOptions, - type CreatePipelineOptions, - type RetryPipelineOptions, - type CancelPipelineOptions, - type GitLabPipelineJob, - type GitLabMilestones, - type ListProjectMilestonesOptions, - type GetProjectMilestoneOptions, - type CreateProjectMilestoneOptions, - type EditProjectMilestoneOptions, - type DeleteProjectMilestoneOptions, - type GetMilestoneIssuesOptions, - type GetMilestoneMergeRequestsOptions, - type PromoteProjectMilestoneOptions, - type GetMilestoneBurndownEventsOptions, - // Discussion Types - type GitLabDiscussionNote, - type GitLabDiscussion, - type PaginatedDiscussionsResponse, - type PaginationOptions, - type MergeRequestThreadPosition, - type GetWikiPageOptions, - type CreateWikiPageOptions, - type UpdateWikiPageOptions, - type DeleteWikiPageOptions, - type GitLabWikiPage, - type GitLabTreeItem, - type GetRepositoryTreeOptions, - UpdateIssueNoteSchema, - CreateIssueNoteSchema, - ListMergeRequestsSchema, - GitLabMilestonesSchema, - ListProjectMilestonesSchema, - GetProjectMilestoneSchema, - CreateProjectMilestoneSchema, - EditProjectMilestoneSchema, - DeleteProjectMilestoneSchema, - GetMilestoneIssuesSchema, - GetMilestoneMergeRequestsSchema, - PromoteProjectMilestoneSchema, - GetMilestoneBurndownEventsSchema, - GitLabCompareResult, - GitLabCompareResultSchema, - GetBranchDiffsSchema, - ListWikiPagesOptions, - ListCommitsSchema, - GetCommitSchema, - GetCommitDiffSchema, - type ListCommitsOptions, - type GetCommitOptions, - type GetCommitDiffOptions, - ListMergeRequestDiffsSchema, -} from "./schemas.js"; - -import { DEFAULT_FETCH_CONFIG } from "./agent.js"; -import { Response } from "node-fetch"; - -/** - * Read version from package.json - */ -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const packageJsonPath = path.resolve(__dirname, "../package.json"); -let SERVER_VERSION = "unknown"; -try { - if (fs.existsSync(packageJsonPath)) { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); - SERVER_VERSION = packageJson.version || SERVER_VERSION; - } -} catch (error) { - // Warning: Could not read version from package.json - silently continue -} - -// create the underlying mcp server -const server = new Server( - { - name: "better-gitlab-mcp-server", - version: SERVER_VERSION, - }, - { - capabilities: { - tools: {}, - }, - } -); - - -// Define all available tools -const allTools = [ - { - name: "create_or_update_file", - description: "Create or update a single file in a GitLab project", - inputSchema: zodToJsonSchema(CreateOrUpdateFileSchema), - }, - { - name: "search_repositories", - description: "Search for GitLab projects", - inputSchema: zodToJsonSchema(SearchRepositoriesSchema), - }, - { - name: "create_repository", - description: "Create a new GitLab project", - inputSchema: zodToJsonSchema(CreateRepositorySchema), - }, - { - name: "get_file_contents", - description: "Get the contents of a file or directory from a GitLab project", - inputSchema: zodToJsonSchema(GetFileContentsSchema), - }, - { - name: "push_files", - description: "Push multiple files to a GitLab project in a single commit", - inputSchema: zodToJsonSchema(PushFilesSchema), - }, - { - name: "create_issue", - description: "Create a new issue in a GitLab project", - inputSchema: zodToJsonSchema(CreateIssueSchema), - }, - { - name: "create_merge_request", - description: "Create a new merge request in a GitLab project", - inputSchema: zodToJsonSchema(CreateMergeRequestSchema), - }, - { - name: "fork_repository", - description: "Fork a GitLab project to your account or specified namespace", - inputSchema: zodToJsonSchema(ForkRepositorySchema), - }, - { - name: "create_branch", - description: "Create a new branch in a GitLab project", - inputSchema: zodToJsonSchema(CreateBranchSchema), - }, - { - name: "get_merge_request", - description: - "Get details of a merge request (Either mergeRequestIid or branchName must be provided)", - inputSchema: zodToJsonSchema(GetMergeRequestSchema), - }, - { - name: "get_merge_request_diffs", - description: - "Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)", - inputSchema: zodToJsonSchema(GetMergeRequestDiffsSchema), - }, - { - name: "list_merge_request_diffs", - description: - "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)", - inputSchema: zodToJsonSchema(ListMergeRequestDiffsSchema), - }, - { - name: "get_branch_diffs", - description: "Get the changes/diffs between two branches or commits in a GitLab project", - inputSchema: zodToJsonSchema(GetBranchDiffsSchema), - }, - { - name: "update_merge_request", - description: "Update a merge request (Either mergeRequestIid or branchName must be provided)", - inputSchema: zodToJsonSchema(UpdateMergeRequestSchema), - }, - { - name: "create_note", - description: "Create a new note (comment) to an issue or merge request", - inputSchema: zodToJsonSchema(CreateNoteSchema), - }, - { - name: "create_merge_request_thread", - description: "Create a new thread on a merge request", - inputSchema: zodToJsonSchema(CreateMergeRequestThreadSchema), - }, - { - name: "mr_discussions", - description: "List discussion items for a merge request", - inputSchema: zodToJsonSchema(ListMergeRequestDiscussionsSchema), - }, - { - name: "update_merge_request_note", - description: "Modify an existing merge request thread note", - inputSchema: zodToJsonSchema(UpdateMergeRequestNoteSchema), - }, - { - name: "create_merge_request_note", - description: "Add a new note to an existing merge request thread", - inputSchema: zodToJsonSchema(CreateMergeRequestNoteSchema), - }, - { - name: "update_issue_note", - description: "Modify an existing issue thread note", - inputSchema: zodToJsonSchema(UpdateIssueNoteSchema), - }, - { - name: "create_issue_note", - description: "Add a new note to an existing issue thread", - inputSchema: zodToJsonSchema(CreateIssueNoteSchema), - }, - { - name: "list_issues", - description: "List issues in a GitLab project with filtering options", - inputSchema: zodToJsonSchema(ListIssuesSchema), - }, - { - name: "get_issue", - description: "Get details of a specific issue in a GitLab project", - inputSchema: zodToJsonSchema(GetIssueSchema), - }, - { - name: "update_issue", - description: "Update an issue in a GitLab project", - inputSchema: zodToJsonSchema(UpdateIssueSchema), - }, - { - name: "delete_issue", - description: "Delete an issue from a GitLab project", - inputSchema: zodToJsonSchema(DeleteIssueSchema), - }, - { - name: "list_issue_links", - description: "List all issue links for a specific issue", - inputSchema: zodToJsonSchema(ListIssueLinksSchema), - }, - { - name: "list_issue_discussions", - description: "List discussions for an issue in a GitLab project", - inputSchema: zodToJsonSchema(ListIssueDiscussionsSchema), - }, - { - name: "get_issue_link", - description: "Get a specific issue link", - inputSchema: zodToJsonSchema(GetIssueLinkSchema), - }, - { - name: "create_issue_link", - description: "Create an issue link between two issues", - inputSchema: zodToJsonSchema(CreateIssueLinkSchema), - }, - { - name: "delete_issue_link", - 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), - }, - { - name: "list_labels", - description: "List labels for a project", - inputSchema: zodToJsonSchema(ListLabelsSchema), - }, - { - name: "get_label", - description: "Get a single label from a project", - inputSchema: zodToJsonSchema(GetLabelSchema), - }, - { - name: "create_label", - description: "Create a new label in a project", - inputSchema: zodToJsonSchema(CreateLabelSchema), - }, - { - name: "update_label", - description: "Update an existing label in a project", - inputSchema: zodToJsonSchema(UpdateLabelSchema), - }, - { - name: "delete_label", - description: "Delete a label from a project", - inputSchema: zodToJsonSchema(DeleteLabelSchema), - }, - { - name: "list_group_projects", - description: "List projects in a GitLab group with filtering options", - inputSchema: zodToJsonSchema(ListGroupProjectsSchema), - }, - { - name: "list_wiki_pages", - description: "List wiki pages in a GitLab project", - inputSchema: zodToJsonSchema(ListWikiPagesSchema), - }, - { - name: "get_wiki_page", - description: "Get details of a specific wiki page", - inputSchema: zodToJsonSchema(GetWikiPageSchema), - }, - { - name: "create_wiki_page", - description: "Create a new wiki page in a GitLab project", - inputSchema: zodToJsonSchema(CreateWikiPageSchema), - }, - { - name: "update_wiki_page", - description: "Update an existing wiki page in a GitLab project", - inputSchema: zodToJsonSchema(UpdateWikiPageSchema), - }, - { - name: "delete_wiki_page", - description: "Delete a wiki page from a GitLab project", - inputSchema: zodToJsonSchema(DeleteWikiPageSchema), - }, - { - name: "get_repository_tree", - description: "Get the repository tree for a GitLab project (list files and directories)", - inputSchema: zodToJsonSchema(GetRepositoryTreeSchema), - }, - { - name: "list_pipelines", - description: "List pipelines in a GitLab project with filtering options", - inputSchema: zodToJsonSchema(ListPipelinesSchema), - }, - { - name: "get_pipeline", - description: "Get details of a specific pipeline in a GitLab project", - inputSchema: zodToJsonSchema(GetPipelineSchema), - }, - { - name: "list_pipeline_jobs", - description: "List all jobs in a specific pipeline", - inputSchema: zodToJsonSchema(ListPipelineJobsSchema), - }, - { - name: "get_pipeline_job", - description: "Get details of a GitLab pipeline job number", - inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), - }, - { - name: "get_pipeline_job_output", - description: "Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage", - inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), - }, - { - name: "create_pipeline", - description: "Create a new pipeline for a branch or tag", - inputSchema: zodToJsonSchema(CreatePipelineSchema), - }, - { - name: "retry_pipeline", - description: "Retry a failed or canceled pipeline", - inputSchema: zodToJsonSchema(RetryPipelineSchema), - }, - { - name: "cancel_pipeline", - description: "Cancel a running pipeline", - inputSchema: zodToJsonSchema(CancelPipelineSchema), - }, - { - name: "list_merge_requests", - description: "List merge requests in a GitLab project with filtering options", - inputSchema: zodToJsonSchema(ListMergeRequestsSchema), - }, - { - name: "list_milestones", - description: "List milestones in a GitLab project with filtering options", - inputSchema: zodToJsonSchema(ListProjectMilestonesSchema), - }, - { - name: "get_milestone", - description: "Get details of a specific milestone", - inputSchema: zodToJsonSchema(GetProjectMilestoneSchema), - }, - { - name: "create_milestone", - description: "Create a new milestone in a GitLab project", - inputSchema: zodToJsonSchema(CreateProjectMilestoneSchema), - }, - { - name: "edit_milestone", - description: "Edit an existing milestone in a GitLab project", - inputSchema: zodToJsonSchema(EditProjectMilestoneSchema), - }, - { - name: "delete_milestone", - description: "Delete a milestone from a GitLab project", - inputSchema: zodToJsonSchema(DeleteProjectMilestoneSchema), - }, - { - name: "get_milestone_issue", - description: "Get issues associated with a specific milestone", - inputSchema: zodToJsonSchema(GetMilestoneIssuesSchema), - }, - { - name: "get_milestone_merge_requests", - description: "Get merge requests associated with a specific milestone", - inputSchema: zodToJsonSchema(GetMilestoneMergeRequestsSchema), - }, - { - name: "promote_milestone", - description: "Promote a milestone to the next stage", - inputSchema: zodToJsonSchema(PromoteProjectMilestoneSchema), - }, - { - name: "get_milestone_burndown_events", - description: "Get burndown events for a specific milestone", - inputSchema: zodToJsonSchema(GetMilestoneBurndownEventsSchema), - }, - { - name: "get_users", - description: "Get GitLab user details by usernames", - inputSchema: zodToJsonSchema(GetUsersSchema), - }, - { - name: "list_commits", - description: "List repository commits with filtering options", - inputSchema: zodToJsonSchema(ListCommitsSchema), - }, - { - name: "get_commit", - description: "Get details of a specific commit", - inputSchema: zodToJsonSchema(GetCommitSchema), - }, - { - name: "get_commit_diff", - description: "Get changes/diffs of a specific commit", - inputSchema: zodToJsonSchema(GetCommitDiffSchema), - }, -]; - -// Define which tools are read-only -const readOnlyTools = [ - "search_repositories", - "get_file_contents", - "get_merge_request", - "get_merge_request_diffs", - "get_branch_diffs", - "mr_discussions", - "list_issues", - "list_merge_requests", - "get_issue", - "list_issue_links", - "list_issue_discussions", - "get_issue_link", - "list_namespaces", - "get_namespace", - "verify_namespace", - "get_project", - "get_pipeline", - "list_pipelines", - "list_pipeline_jobs", - "get_pipeline_job", - "get_pipeline_job_output", - "list_projects", - "list_labels", - "get_label", - "list_group_projects", - "get_repository_tree", - "list_milestones", - "get_milestone", - "get_milestone_issue", - "get_milestone_merge_requests", - "get_milestone_burndown_events", - "list_wiki_pages", - "get_wiki_page", - "get_users", - "list_commits", - "get_commit", - "get_commit_diff", -]; - -// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI -const wikiToolNames = [ - "list_wiki_pages", - "get_wiki_page", - "create_wiki_page", - "update_wiki_page", - "delete_wiki_page", - "upload_wiki_attachment", -]; - -// Define which tools are related to milestones and can be toggled by USE_MILESTONE -const milestoneToolNames = [ - "list_milestones", - "get_milestone", - "create_milestone", - "edit_milestone", - "delete_milestone", - "get_milestone_issue", - "get_milestone_merge_requests", - "promote_milestone", - "get_milestone_burndown_events", -]; - -// Define which tools are related to pipelines and can be toggled by USE_PIPELINE -const pipelineToolNames = [ - "list_pipelines", - "get_pipeline", - "list_pipeline_jobs", - "get_pipeline_job", - "get_pipeline_job_output", - "create_pipeline", - "retry_pipeline", - "cancel_pipeline", -]; - - - - -if (!config.GITLAB_PERSONAL_ACCESS_TOKEN) { - console.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set"); - process.exit(1); -} - -/** - * Utility function for handling GitLab API errors - * API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors) - * - * @param {import("node-fetch").Response} response - The response from GitLab API - * @throws {Error} Throws an error with response details if the request failed - */ -async function handleGitLabError(response: Response): Promise { - if (!response.ok) { - const errorBody = await response.text(); - // Check specifically for Rate Limit error - if (response.status === 403 && errorBody.includes("User API Key Rate limit exceeded")) { - console.error("GitLab API Rate Limit Exceeded:", errorBody); - console.log("User API Key Rate limit exceeded. Please try again later."); - throw new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`); - } else { - // Handle other API errors - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - } -} - -/** - * @param {string} projectId - The project ID parameter passed to the function - * @returns {string} The project ID to use for the API call - */ -function getEffectiveProjectId(projectId: string): string { - return config.GITLAB_PROJECT_ID || projectId; -} - -/** - * Create a fork of a GitLab project - * 프로젝트 포크 생성 (Create a project fork) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} [namespace] - The namespace to fork the project to - * @returns {Promise} The created fork - */ -async function forkProject(projectId: string, namespace?: string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/fork`); - - if (namespace) { - url.searchParams.append("namespace", namespace); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - }); - - // 이미 존재하는 프로젝트인 경우 처리 - if (response.status === 409) { - throw new Error("Project already exists in the target namespace"); - } - - await handleGitLabError(response); - const data = await response.json(); - return GitLabForkSchema.parse(data); -} - -/** - * Create a new branch in a GitLab project - * 새로운 브랜치 생성 (Create a new branch) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {z.infer} options - Branch creation options - * @returns {Promise} The created branch reference - */ -async function createBranch( - projectId: string, - options: z.infer -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/branches` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - branch: options.name, - ref: options.ref, - }), - }); - - await handleGitLabError(response); - return GitLabReferenceSchema.parse(await response.json()); -} - -/** - * Get the default branch for a GitLab project - * 프로젝트의 기본 브랜치 조회 (Get the default branch of a project) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @returns {Promise} The name of the default branch - */ -async function getDefaultBranchRef(projectId: string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const project = GitLabRepositorySchema.parse(await response.json()); - return project.default_branch ?? "main"; -} - -/** - * Get the contents of a file from a GitLab project - * 파일 내용 조회 (Get file contents) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} filePath - The path of the file to get - * @param {string} [ref] - The name of the branch, tag or commit - * @returns {Promise} The file content - */ -async function getFileContents( - projectId: string, - filePath: string, - ref?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const encodedPath = encodeURIComponent(filePath); - - // ref가 없는 경우 default branch를 가져옴 - if (!ref) { - ref = await getDefaultBranchRef(projectId); - } - - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/files/${encodedPath}` - ); - - url.searchParams.append("ref", ref); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - // 파일을 찾을 수 없는 경우 처리 - if (response.status === 404) { - throw new Error(`File not found: ${filePath}`); - } - - await handleGitLabError(response); - const data = await response.json(); - const parsedData = GitLabContentSchema.parse(data); - - // Base64로 인코딩된 파일 내용을 UTF-8로 디코딩 - if (!Array.isArray(parsedData) && parsedData.content) { - parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8"); - parsedData.encoding = "utf8"; - } - - return parsedData; -} - -/** - * Create a new issue in a GitLab project - * 이슈 생성 (Create an issue) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {z.infer} options - Issue creation options - * @returns {Promise} The created issue - */ -async function createIssue( - projectId: string, - options: z.infer -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - title: options.title, - description: options.description, - assignee_ids: options.assignee_ids, - milestone_id: options.milestone_id, - labels: options.labels?.join(","), - }), - }); - - // 잘못된 요청 처리 - if (response.status === 400) { - const errorBody = await response.text(); - throw new Error(`Invalid request: ${errorBody}`); - } - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueSchema.parse(data); -} - -/** - * List issues in a GitLab project - * 프로젝트의 이슈 목록 조회 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {Object} options - Options for listing issues - * @returns {Promise} List of issues - */ -async function listIssues( - projectId: string, - options: Omit, "project_id"> = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const effectiveProjectId = getEffectiveProjectId(projectId); - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`); - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - const keys = ["labels", "assignee_username"]; - if (keys.includes(key)) { - if (Array.isArray(value)) { - // Handle array of labels - value.forEach(label => { - url.searchParams.append(`${key}[]`, label.toString()); - }); - } else { - url.searchParams.append(`${key}[]`, value.toString()); - } - } else { - url.searchParams.append(key, value.toString()); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabIssueSchema).parse(data); -} - -/** - * List merge requests in a GitLab project with optional filtering - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {Object} options - Optional filtering parameters - * @returns {Promise} List of merge requests - */ -async function listMergeRequests( - projectId: string, - options: Omit, "project_id"> = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests`); - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (key === "labels" && Array.isArray(value)) { - // Handle array of labels - url.searchParams.append(key, value.join(",")); - } else { - url.searchParams.append(key, value.toString()); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabMergeRequestSchema).parse(data); -} - -/** - * Get a single issue from a GitLab project - * 단일 이슈 조회 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @returns {Promise} The issue - */ -async function getIssue(projectId: string, issueIid: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueSchema.parse(data); -} - -/** - * Update an issue in a GitLab project - * 이슈 업데이트 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {Object} options - Update options for the issue - * @returns {Promise} The updated issue - */ -async function updateIssue( - projectId: string, - issueIid: number, - options: Omit, "project_id" | "issue_iid"> -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}` - ); - - // Convert labels array to comma-separated string if present - const body: Record = { ...options }; - if (body.labels && Array.isArray(body.labels)) { - body.labels = body.labels.join(","); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(body), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueSchema.parse(data); -} - -/** - * Delete an issue from a GitLab project - * 이슈 삭제 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @returns {Promise} - */ -async function deleteIssue(projectId: string, issueIid: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - }); - - await handleGitLabError(response); -} - -/** - * List all issue links for a specific issue - * 이슈 관계 목록 조회 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @returns {Promise} List of issues with link details - */ -async function listIssueLinks( - projectId: string, - issueIid: number -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/links` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabIssueWithLinkDetailsSchema).parse(data); -} - -/** - * Get a specific issue link - * 특정 이슈 관계 조회 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {number} issueLinkId - The ID of the issue link - * @returns {Promise} The issue link - */ -async function getIssueLink( - projectId: string, - issueIid: number, - issueLinkId: number -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/issues/${issueIid}/links/${issueLinkId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueLinkSchema.parse(data); -} - -/** - * Create an issue link between two issues - * 이슈 관계 생성 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {string} targetProjectId - The ID or URL-encoded path of the target project - * @param {number} targetIssueIid - The internal ID of the target project issue - * @param {string} linkType - The type of the relation (relates_to, blocks, is_blocked_by) - * @returns {Promise} The created issue link - */ -async function createIssueLink( - projectId: string, - issueIid: number, - targetProjectId: string, - targetIssueIid: number, - linkType: "relates_to" | "blocks" | "is_blocked_by" = "relates_to" -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - targetProjectId = decodeURIComponent(targetProjectId); // Decode target project ID as well - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/issues/${issueIid}/links` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - target_project_id: targetProjectId, - target_issue_iid: targetIssueIid, - link_type: linkType, - }), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabIssueLinkSchema.parse(data); -} - -/** - * Delete an issue link - * 이슈 관계 삭제 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {number} issueLinkId - The ID of the issue link - * @returns {Promise} - */ -async function deleteIssueLink( - projectId: string, - issueIid: number, - issueLinkId: number -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/issues/${issueIid}/links/${issueLinkId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - }); - - await handleGitLabError(response); -} - -/** - * Create a new merge request in a GitLab project - * 병합 요청 생성 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {z.infer} options - Merge request creation options - * @returns {Promise} The created merge request - */ -async function createMergeRequest( - projectId: string, - options: z.infer -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - title: options.title, - description: options.description, - source_branch: options.source_branch, - target_branch: options.target_branch, - assignee_ids: options.assignee_ids, - reviewer_ids: options.reviewer_ids, - labels: options.labels?.join(","), - allow_collaboration: options.allow_collaboration, - draft: options.draft, - remove_source_branch: options.remove_source_branch, - squash: options.squash, - }), - }); - - if (response.status === 400) { - const errorBody = await response.text(); - throw new Error(`Invalid request: ${errorBody}`); - } - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabMergeRequestSchema.parse(data); -} - -/** - * Shared helper function for listing discussions - * 토론 목록 조회를 위한 공유 헬퍼 함수 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {"issues" | "merge_requests"} resourceType - The type of resource (issues or merge_requests) - * @param {number} resourceIid - The IID of the issue or merge request - * @param {PaginationOptions} options - Pagination and sorting options - * @returns {Promise} Paginated list of discussions - */ -async function listDiscussions( - projectId: string, - resourceType: "issues" | "merge_requests", - resourceIid: number, - options: PaginationOptions = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/${resourceType}/${resourceIid}/discussions` - ); - - // Add query parameters for pagination and sorting - if (options.page) { - url.searchParams.append("page", options.page.toString()); - } - if (options.per_page) { - url.searchParams.append("per_page", options.per_page.toString()); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const discussions = await response.json(); - - // Extract pagination headers - const pagination = { - x_next_page: response.headers.get("x-next-page") - ? parseInt(response.headers.get("x-next-page")!) - : null, - x_page: response.headers.get("x-page") ? parseInt(response.headers.get("x-page")!) : undefined, - x_per_page: response.headers.get("x-per-page") - ? parseInt(response.headers.get("x-per-page")!) - : undefined, - x_prev_page: response.headers.get("x-prev-page") - ? parseInt(response.headers.get("x-prev-page")!) - : null, - x_total: response.headers.get("x-total") ? parseInt(response.headers.get("x-total")!) : null, - x_total_pages: response.headers.get("x-total-pages") - ? parseInt(response.headers.get("x-total-pages")!) - : null, - }; - - return PaginatedDiscussionsResponseSchema.parse({ - items: discussions, - pagination: pagination, - }); -} - -/** - * List merge request discussion items - * 병합 요청 토론 목록 조회 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The IID of a merge request - * @param {DiscussionPaginationOptions} options - Pagination and sorting options - * @returns {Promise} List of discussions - */ -async function listMergeRequestDiscussions( - projectId: string, - mergeRequestIid: number, - options: PaginationOptions = {} -): Promise { - return listDiscussions(projectId, "merge_requests", mergeRequestIid, options); -} - -/** - * List discussions for an issue - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The internal ID of the project issue - * @param {DiscussionPaginationOptions} options - Pagination and sorting options - * @returns {Promise} List of issue discussions - */ -async function listIssueDiscussions( - projectId: string, - issueIid: number, - options: PaginationOptions = {} -): Promise { - return listDiscussions(projectId, "issues", issueIid, options); -} - -/** - * Modify an existing merge request thread note - * 병합 요청 토론 노트 수정 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The IID of a merge request - * @param {string} discussionId - The ID of a thread - * @param {number} noteId - The ID of a thread note - * @param {string} body - The new content of the note - * @param {boolean} [resolved] - Resolve/unresolve state - * @returns {Promise} The updated note - */ -async function updateMergeRequestNote( - projectId: string, - mergeRequestIid: number, - discussionId: string, - noteId: number, - body?: string, - resolved?: boolean -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes/${noteId}` - ); - - // Only one of body or resolved can be sent according to GitLab API - const payload: { body?: string; resolved?: boolean } = {}; - if (body !== undefined) { - payload.body = body; - } else if (resolved !== undefined) { - payload.resolved = resolved; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionNoteSchema.parse(data); -} - -/** - * Update an issue discussion note - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The IID of an issue - * @param {string} discussionId - The ID of a thread - * @param {number} noteId - The ID of a thread note - * @param {string} body - The new content of the note - * @returns {Promise} The updated note - */ -async function updateIssueNote( - projectId: string, - issueIid: number, - discussionId: string, - noteId: number, - body: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/issues/${issueIid}/discussions/${discussionId}/notes/${noteId}` - ); - - const payload = { body }; - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionNoteSchema.parse(data); -} - -/** - * Create a note in an issue discussion - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} issueIid - The IID of an issue - * @param {string} discussionId - The ID of a thread - * @param {string} body - The content of the new note - * @param {string} [createdAt] - The creation date of the note (ISO 8601 format) - * @returns {Promise} The created note - */ -async function createIssueNote( - projectId: string, - issueIid: number, - discussionId: string, - body: string, - createdAt?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/issues/${issueIid}/discussions/${discussionId}/notes` - ); - - const payload: { body: string; created_at?: string } = { body }; - if (createdAt) { - payload.created_at = createdAt; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionNoteSchema.parse(data); -} - -/** - * Add a new note to an existing merge request thread - * 기존 병합 요청 스레드에 새 노트 추가 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The IID of a merge request - * @param {string} discussionId - The ID of a thread - * @param {string} body - The content of the new note - * @param {string} [createdAt] - The creation date of the note (ISO 8601 format) - * @returns {Promise} The created note - */ -async function createMergeRequestNote( - projectId: string, - mergeRequestIid: number, - discussionId: string, - body: string, - createdAt?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes` - ); - - const payload: { body: string; created_at?: string } = { body }; - if (createdAt) { - payload.created_at = createdAt; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionNoteSchema.parse(data); -} - -/** - * Create or update a file in a GitLab project - * 파일 생성 또는 업데이트 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} filePath - The path of the file to create or update - * @param {string} content - The content of the file - * @param {string} commitMessage - The commit message - * @param {string} branch - The branch name - * @param {string} [previousPath] - The previous path of the file in case of rename - * @returns {Promise} The file update response - */ -async function createOrUpdateFile( - projectId: string, - filePath: string, - content: string, - commitMessage: string, - branch: string, - previousPath?: string, - last_commit_id?: string, - commit_id?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const encodedPath = encodeURIComponent(filePath); - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/files/${encodedPath}` - ); - - const body: Record = { - branch, - content, - commit_message: commitMessage, - encoding: "text", - ...(previousPath ? { previous_path: previousPath } : {}), - }; - - // Check if file exists - let method = "POST"; - try { - // Get file contents to check existence and retrieve commit IDs - const fileData = await getFileContents(projectId, filePath, branch); - method = "PUT"; - - // If fileData is not an array, it's a file content object with commit IDs - if (!Array.isArray(fileData)) { - // Use commit IDs from the file data if not provided in parameters - if (!commit_id && fileData.commit_id) { - body.commit_id = fileData.commit_id; - } else if (commit_id) { - body.commit_id = commit_id; - } - - if (!last_commit_id && fileData.last_commit_id) { - body.last_commit_id = fileData.last_commit_id; - } else if (last_commit_id) { - body.last_commit_id = last_commit_id; - } - } - } catch (error) { - if (!(error instanceof Error && error.message.includes("File not found"))) { - throw error; - } - // File doesn't exist, use POST - no need for commit IDs for new files - // But still use any provided as parameters if they exist - if (commit_id) { - body.commit_id = commit_id; - } - if (last_commit_id) { - body.last_commit_id = last_commit_id; - } - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabCreateUpdateFileResponseSchema.parse(data); -} - -/** - * Create a tree structure in a GitLab project repository - * 저장소에 트리 구조 생성 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {FileOperation[]} files - Array of file operations - * @param {string} [ref] - The name of the branch, tag or commit - * @returns {Promise} The created tree - */ -async function createTree( - projectId: string, - files: FileOperation[], - ref?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/tree` - ); - - if (ref) { - url.searchParams.append("ref", ref); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - files: files.map(file => ({ - file_path: file.path, - content: file.content, - encoding: "text", - })), - }), - }); - - if (response.status === 400) { - const errorBody = await response.text(); - throw new Error(`Invalid request: ${errorBody}`); - } - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabTreeSchema.parse(data); -} - -/** - * Create a commit in a GitLab project repository - * 저장소에 커밋 생성 - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} message - The commit message - * @param {string} branch - The branch name - * @param {FileOperation[]} actions - Array of file operations for the commit - * @returns {Promise} The created commit - */ -async function createCommit( - projectId: string, - message: string, - branch: string, - actions: FileOperation[] -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - branch, - commit_message: message, - actions: actions.map(action => ({ - action: "create", - file_path: action.path, - content: action.content, - encoding: "text", - })), - }), - }); - - if (response.status === 400) { - const errorBody = await response.text(); - throw new Error(`Invalid request: ${errorBody}`); - } - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabCommitSchema.parse(data); -} - -/** - * Search for GitLab projects - * 프로젝트 검색 - * - * @param {string} query - The search query - * @param {number} [page=1] - The page number - * @param {number} [perPage=20] - Number of items per page - * @returns {Promise} The search results - */ -async function searchProjects( - query: string, - page: number = 1, - perPage: number = 20 -): Promise { - const url = new URL(`${config.GITLAB_API_URL}/projects`); - url.searchParams.append("search", query); - url.searchParams.append("page", page.toString()); - url.searchParams.append("per_page", perPage.toString()); - url.searchParams.append("order_by", "id"); - url.searchParams.append("sort", "desc"); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const projects = (await response.json()) as GitLabRepository[]; - const totalCount = response.headers.get("x-total"); - const totalPages = response.headers.get("x-total-pages"); - - // GitLab API doesn't return these headers for results > 10,000 - const count = totalCount ? parseInt(totalCount) : projects.length; - - return GitLabSearchResponseSchema.parse({ - count, - total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage), - current_page: page, - items: projects, - }); -} - -/** - * Create a new GitLab repository - * 새 저장소 생성 - * - * @param {z.infer} options - Repository creation options - * @returns {Promise} The created repository - */ -async function createRepository( - options: z.infer -): Promise { - const response = await fetch(`${config.GITLAB_API_URL}/projects`, { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ - name: options.name, - description: options.description, - visibility: options.visibility, - initialize_with_readme: options.initialize_with_readme, - default_branch: "main", - path: options.name.toLowerCase().replace(/\s+/g, "-"), - }), - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabRepositorySchema.parse(data); -} - -/** - * Get merge request details - * MR 조회 함수 (Function to retrieve merge request) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request (Optional) - * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Optional) - * @returns {Promise} The merge request details - */ -async function getMergeRequest( - projectId: string, - mergeRequestIid?: number, - branchName?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - let url: URL; - - if (mergeRequestIid) { - url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}` - ); - } else if (branchName) { - url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests?source_branch=${encodeURIComponent(branchName)}` - ); - } else { - throw new Error("Either mergeRequestIid or branchName must be provided"); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const data = await response.json(); - - // If response is an array (Comes from branchName search), return the first item if exist - if (Array.isArray(data) && data.length > 0) { - return GitLabMergeRequestSchema.parse(data[0]); - } - - return GitLabMergeRequestSchema.parse(data); -} - -/** - * Get merge request changes/diffs - * MR 변경사항 조회 함수 (Function to retrieve merge request changes) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request (Either mergeRequestIid or branchName must be provided) - * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Either mergeRequestIid or branchName must be provided) - * @param {string} [view] - The view type for the diff (inline or parallel) - * @returns {Promise} The merge request diffs - */ -async function getMergeRequestDiffs( - projectId: string, - mergeRequestIid?: number, - branchName?: string, - view?: "inline" | "parallel" -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - if (!mergeRequestIid && !branchName) { - throw new Error("Either mergeRequestIid or branchName must be provided"); - } - - if (branchName && !mergeRequestIid) { - const mergeRequest = await getMergeRequest(projectId, undefined, branchName); - mergeRequestIid = mergeRequest.iid; - } - - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/changes` - ); - - if (view) { - url.searchParams.append("view", view); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = (await response.json()) as { changes: unknown }; - return z.array(GitLabDiffSchema).parse(data.changes); -} - -/** - * Get merge request changes with detailed information including commits, diff_refs, and more - * 마지막으로 추가된 상세한 MR 변경사항 조회 함수 (Detailed merge request changes retrieval function) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request (Either mergeRequestIid or branchName must be provided) - * @param {string} [branchName] - The name of the branch to search for merge request by branch name (Either mergeRequestIid or branchName must be provided) - * @param {boolean} [unidiff] - Return diff in unidiff format - * @returns {Promise} The complete merge request changes response - */ -async function listMergeRequestDiffs( - projectId: string, - mergeRequestIid?: number, - branchName?: string, - page?: number, - perPage?: number, - unidiff?: boolean -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - if (!mergeRequestIid && !branchName) { - throw new Error("Either mergeRequestIid or branchName must be provided"); - } - - if (branchName && !mergeRequestIid) { - const mergeRequest = await getMergeRequest(projectId, undefined, branchName); - mergeRequestIid = mergeRequest.iid; - } - - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/diffs` - ); - - if (page) { - url.searchParams.append("page", page.toString()); - } - - if (perPage) { - url.searchParams.append("per_page", perPage.toString()); - } - - if (unidiff) { - url.searchParams.append("unidiff", "true"); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - return await response.json(); // Return full response including commits, diff_refs, changes, etc. -} - -/** - * Get branch comparison diffs - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} from - The branch name or commit SHA to compare from - * @param {string} to - The branch name or commit SHA to compare to - * @param {boolean} [straight] - Comparison method: false for '...' (default), true for '--' - * @returns {Promise} Branch comparison results - */ -async function getBranchDiffs( - projectId: string, - from: string, - to: string, - straight?: boolean -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/compare` - ); - - url.searchParams.append("from", from); - url.searchParams.append("to", to); - - if (straight !== undefined) { - url.searchParams.append("straight", straight.toString()); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); - } - - const data = await response.json(); - return GitLabCompareResultSchema.parse(data); -} - -/** - * Update a merge request - * MR 업데이트 함수 (Function to update merge request) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request (Optional) - * @param {string} branchName - The name of the branch to search for merge request by branch name (Optional) - * @param {Object} options - The update options - * @returns {Promise} The updated merge request - */ -async function updateMergeRequest( - projectId: string, - options: Omit< - z.infer, - "project_id" | "merge_request_iid" | "source_branch" - >, - mergeRequestIid?: number, - branchName?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - if (!mergeRequestIid && !branchName) { - throw new Error("Either mergeRequestIid or branchName must be provided"); - } - - if (branchName && !mergeRequestIid) { - const mergeRequest = await getMergeRequest(projectId, undefined, branchName); - mergeRequestIid = mergeRequest.iid; - } - - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(options), - }); - - await handleGitLabError(response); - return GitLabMergeRequestSchema.parse(await response.json()); -} - -/** - * Create a new note (comment) on an issue or merge request - * 📦 새로운 함수: createNote - 이슈 또는 병합 요청에 노트(댓글)를 추가하는 함수 - * (New function: createNote - Function to add a note (comment) to an issue or merge request) - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {"issue" | "merge_request"} noteableType - The type of the item to add a note to (issue or merge_request) - * @param {number} noteableIid - The internal ID of the issue or merge request - * @param {string} body - The content of the note - * @returns {Promise} The created note - */ -async function createNote( - projectId: string, - noteableType: "issue" | "merge_request", // 'issue' 또는 'merge_request' 타입 명시 - noteableIid: number, - body: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능 - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify({ body }), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); - } - - return await response.json(); -} - -/** - * Create a new thread on a merge request - * 📦 새로운 함수: createMergeRequestThread - 병합 요청에 새로운 스레드(토론)를 생성하는 함수 - * (New function: createMergeRequestThread - Function to create a new thread (discussion) on a merge request) - * - * This function provides more capabilities than createNote, including the ability to: - * - Create diff notes (comments on specific lines of code) - * - Specify exact positions for comments - * - Set creation timestamps - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} mergeRequestIid - The internal ID of the merge request - * @param {string} body - The content of the thread - * @param {MergeRequestThreadPosition} [position] - Position information for diff notes - * @param {string} [createdAt] - ISO 8601 formatted creation date - * @returns {Promise} The created discussion thread - */ -async function createMergeRequestThread( - projectId: string, - mergeRequestIid: number, - body: string, - position?: MergeRequestThreadPosition, - createdAt?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/merge_requests/${mergeRequestIid}/discussions` - ); - - const payload: Record = { body }; - - // Add optional parameters if provided - if (position) { - payload.position = position; - } - - if (createdAt) { - payload.created_at = createdAt; - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(payload), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabDiscussionSchema.parse(data); -} - -/** - * 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(`${config.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(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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(`${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(id)}`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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(`${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(namespacePath)}/exists`); - - if (parentId) { - url.searchParams.append("parent_id", parentId.toString()); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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 { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(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(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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 { - // Construct the query parameters - const params = new URLSearchParams(); - for (const [key, value] of Object.entries(options)) { - if (value !== undefined && value !== null) { - if (typeof value === "boolean") { - params.append(key, value ? "true" : "false"); - } else { - params.append(key, String(value)); - } - } - } - - // Make the API request - const response = await fetch(`${config.GITLAB_API_URL}/projects?${params.toString()}`, { - ...DEFAULT_FETCH_CONFIG, - }); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return z.array(GitLabProjectSchema).parse(data); -} - -/** - * List labels for a project - * - * @param projectId The ID or URL-encoded path of the project - * @param options Optional parameters for listing labels - * @returns Array of GitLab labels - */ -async function listLabels( - projectId: string, - options: Omit, "project_id"> = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // Construct the URL with project path - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels`); - - // Add query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (typeof value === "boolean") { - url.searchParams.append(key, value ? "true" : "false"); - } else { - url.searchParams.append(key, String(value)); - } - } - }); - - // Make the API request - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return data as GitLabLabel[]; -} - -/** - * Get a single label from a project - * - * @param projectId The ID or URL-encoded path of the project - * @param labelId The ID or name of the label - * @param includeAncestorGroups Whether to include ancestor groups - * @returns GitLab label - */ -async function getLabel( - projectId: string, - labelId: number | string, - includeAncestorGroups?: boolean -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/labels/${encodeURIComponent(String(labelId))}` - ); - - // Add query parameters - if (includeAncestorGroups !== undefined) { - url.searchParams.append("include_ancestor_groups", includeAncestorGroups ? "true" : "false"); - } - - // Make the API request - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return data as GitLabLabel; -} - -/** - * Create a new label in a project - * - * @param projectId The ID or URL-encoded path of the project - * @param options Options for creating the label - * @returns Created GitLab label - */ -async function createLabel( - projectId: string, - options: Omit, "project_id"> -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // Make the API request - const response = await fetch( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/labels`, - { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(options), - } - ); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return data as GitLabLabel; -} - -/** - * Update an existing label in a project - * - * @param projectId The ID or URL-encoded path of the project - * @param labelId The ID or name of the label to update - * @param options Options for updating the label - * @returns Updated GitLab label - */ -async function updateLabel( - projectId: string, - labelId: number | string, - options: Omit, "project_id" | "label_id"> -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // Make the API request - const response = await fetch( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/labels/${encodeURIComponent(String(labelId))}`, - { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(options), - } - ); - - // Handle errors - await handleGitLabError(response); - - // Parse and return the data - const data = await response.json(); - return data as GitLabLabel; -} - -/** - * Delete a label from a project - * - * @param projectId The ID or URL-encoded path of the project - * @param labelId The ID or name of the label to delete - */ -async function deleteLabel(projectId: string, labelId: number | string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - // Make the API request - const response = await fetch( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/labels/${encodeURIComponent(String(labelId))}`, - { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - } - ); - - // Handle errors - await handleGitLabError(response); -} - -/** - * List all projects in a GitLab group - * - * @param {z.infer} options - Options for listing group projects - * @returns {Promise} Array of projects in the group - */ -async function listGroupProjects( - options: z.infer -): Promise { - const url = new URL(`${config.GITLAB_API_URL}/groups/${encodeURIComponent(options.group_id)}/projects`); - - // Add optional parameters to URL - if (options.include_subgroups) url.searchParams.append("include_subgroups", "true"); - if (options.search) url.searchParams.append("search", options.search); - if (options.order_by) url.searchParams.append("order_by", options.order_by); - if (options.sort) url.searchParams.append("sort", options.sort); - if (options.page) url.searchParams.append("page", options.page.toString()); - if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); - if (options.archived !== undefined) - url.searchParams.append("archived", options.archived.toString()); - if (options.visibility) url.searchParams.append("visibility", options.visibility); - if (options.with_issues_enabled !== undefined) - url.searchParams.append("with_issues_enabled", options.with_issues_enabled.toString()); - if (options.with_merge_requests_enabled !== undefined) - url.searchParams.append( - "with_merge_requests_enabled", - options.with_merge_requests_enabled.toString() - ); - if (options.min_access_level !== undefined) - url.searchParams.append("min_access_level", options.min_access_level.toString()); - if (options.with_programming_language) - url.searchParams.append("with_programming_language", options.with_programming_language); - if (options.starred !== undefined) url.searchParams.append("starred", options.starred.toString()); - if (options.statistics !== undefined) - url.searchParams.append("statistics", options.statistics.toString()); - if (options.with_custom_attributes !== undefined) - url.searchParams.append("with_custom_attributes", options.with_custom_attributes.toString()); - if (options.with_security_reports !== undefined) - url.searchParams.append("with_security_reports", options.with_security_reports.toString()); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const projects = await response.json(); - return GitLabProjectSchema.array().parse(projects); -} - -// Wiki API helper functions -/** - * List wiki pages in a project - */ -async function listWikiPages( - projectId: string, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis`); - if (options.page) url.searchParams.append("page", options.page.toString()); - if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); - if (options.with_content) - url.searchParams.append("with_content", options.with_content.toString()); - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabWikiPageSchema.array().parse(data); -} - -/** - * Get a specific wiki page - */ -async function getWikiPage(projectId: string, slug: string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const response = await fetch( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, - { ...DEFAULT_FETCH_CONFIG } - ); - await handleGitLabError(response); - const data = await response.json(); - return GitLabWikiPageSchema.parse(data); -} - -/** - * Create a new wiki page - */ -async function createWikiPage( - projectId: string, - title: string, - content: string, - format?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const body: Record = { title, content }; - if (format) body.format = format; - const response = await fetch( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis`, - { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(body), - } - ); - await handleGitLabError(response); - const data = await response.json(); - return GitLabWikiPageSchema.parse(data); -} - -/** - * Update an existing wiki page - */ -async function updateWikiPage( - projectId: string, - slug: string, - title?: string, - content?: string, - format?: string -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const body: Record = {}; - if (title) body.title = title; - if (content) body.content = content; - if (format) body.format = format; - const response = await fetch( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, - { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(body), - } - ); - await handleGitLabError(response); - const data = await response.json(); - return GitLabWikiPageSchema.parse(data); -} - -/** - * Delete a wiki page - */ -async function deleteWikiPage(projectId: string, slug: string): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const response = await fetch( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, - { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - } - ); - await handleGitLabError(response); -} - -/** - * List pipelines in a GitLab project - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {ListPipelinesOptions} options - Options for filtering pipelines - * @returns {Promise} List of pipelines - */ -async function listPipelines( - projectId: string, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines`); - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - url.searchParams.append(key, value.toString()); - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabPipelineSchema).parse(data); -} - -/** - * Get details of a specific pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline - * @returns {Promise} Pipeline details - */ -async function getPipeline(projectId: string, pipelineId: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (response.status === 404) { - throw new Error(`Pipeline not found`); - } - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineSchema.parse(data); -} - -/** - * List all jobs in a specific pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline - * @param {Object} options - Options for filtering jobs - * @returns {Promise} List of pipeline jobs - */ -async function listPipelineJobs( - projectId: string, - pipelineId: number, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/jobs` - ); - - // Add all query parameters - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (typeof value === "boolean") { - url.searchParams.append(key, value ? "true" : "false"); - } else { - url.searchParams.append(key, value.toString()); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (response.status === 404) { - throw new Error(`Pipeline not found`); - } - - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabPipelineJobSchema).parse(data); -} -async function getPipelineJob(projectId: string, jobId: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - if (response.status === 404) { - throw new Error(`Job not found`); - } - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineJobSchema.parse(data); -} - -/** - * Get the output/trace of a pipeline job - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} jobId - The ID of the job - * @param {number} limit - Maximum number of lines to return from the end (default: 1000) - * @param {number} offset - Number of lines to skip from the end (default: 0) - * @returns {Promise} The job output/trace - */ -async function getPipelineJobOutput(projectId: string, jobId: number, limit?: number, offset?: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/trace` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - headers: { - ...DEFAULT_HEADERS, - Accept: "text/plain", // Override Accept header to get plain text - }, - }); - - if (response.status === 404) { - throw new Error(`Job trace not found or job is not finished yet`); - } - - await handleGitLabError(response); - const fullTrace = await response.text(); - - // Apply client-side pagination to limit context window usage - if (limit !== undefined || offset !== undefined) { - const lines = fullTrace.split('\n'); - const startOffset = offset || 0; - const maxLines = limit || 1000; - - // Return lines from the end, skipping offset lines and limiting to maxLines - const startIndex = Math.max(0, lines.length - startOffset - maxLines); - const endIndex = lines.length - startOffset; - - const selectedLines = lines.slice(startIndex, endIndex); - const result = selectedLines.join('\n'); - - // Add metadata about truncation - if (startIndex > 0 || endIndex < lines.length) { - const totalLines = lines.length; - const shownLines = selectedLines.length; - const skippedFromStart = startIndex; - const skippedFromEnd = startOffset; - - return `[Log truncated: showing ${shownLines} of ${totalLines} lines, skipped ${skippedFromStart} from start, ${skippedFromEnd} from end]\n\n${result}`; - } - - return result; - } - - return fullTrace; -} - -/** - * Create a new pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {string} ref - The branch or tag to run the pipeline on - * @param {Array} variables - Optional variables for the pipeline - * @returns {Promise} The created pipeline - */ -async function createPipeline( - projectId: string, - ref: string, - variables?: Array<{ key: string; value: string }> -): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipeline`); - - const body: any = { ref }; - if (variables && variables.length > 0) { - body.variables = variables.reduce( - (acc, { key, value }) => { - acc[key] = value; - return acc; - }, - {} as Record - ); - } - - const response = await fetch(url.toString(), { - method: "POST", - headers: DEFAULT_HEADERS, - body: JSON.stringify(body), - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineSchema.parse(data); -} - -/** - * Retry a pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline to retry - * @returns {Promise} The retried pipeline - */ -async function retryPipeline(projectId: string, pipelineId: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry` - ); - - const response = await fetch(url.toString(), { - method: "POST", - headers: DEFAULT_HEADERS, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineSchema.parse(data); -} - -/** - * Cancel a pipeline - * - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} pipelineId - The ID of the pipeline to cancel - * @returns {Promise} The canceled pipeline - */ -async function cancelPipeline(projectId: string, pipelineId: number): Promise { - projectId = decodeURIComponent(projectId); // Decode project ID - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel` - ); - - const response = await fetch(url.toString(), { - method: "POST", - headers: DEFAULT_HEADERS, - }); - - await handleGitLabError(response); - const data = await response.json(); - return GitLabPipelineSchema.parse(data); -} - -/** - * Get the repository tree for a project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {GetRepositoryTreeOptions} options - Options for the tree - * @returns {Promise} - */ -async function getRepositoryTree(options: GetRepositoryTreeOptions): Promise { - options.project_id = decodeURIComponent(options.project_id); // Decode project_id within options - const queryParams = new URLSearchParams(); - if (options.path) queryParams.append("path", options.path); - if (options.ref) queryParams.append("ref", options.ref); - if (options.recursive) queryParams.append("recursive", "true"); - if (options.per_page) queryParams.append("per_page", options.per_page.toString()); - if (options.page_token) queryParams.append("page_token", options.page_token); - if (options.pagination) queryParams.append("pagination", options.pagination); - - const headers: Record = { - "Content-Type": "application/json", - }; - if (config.IS_OLD) { - headers["Private-Token"] = `${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; - } else { - headers["Authorization"] = `Bearer ${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; - } - const response = await fetch( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - options.project_id - )}/repository/tree?${queryParams.toString()}`, - { - headers, - } - ); - - if (response.status === 404) { - throw new Error("Repository or path not found"); - } - - if (!response.ok) { - throw new Error(`Failed to get repository tree: ${response.statusText}`); - } - - const data = await response.json(); - return z.array(GitLabTreeItemSchema).parse(data); -} - -/** - * List project milestones in a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {Object} options - Options for listing milestones - * @returns {Promise} List of milestones - */ -async function listProjectMilestones( - projectId: string, - options: Omit, "project_id"> -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones`); - - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - if (key === "iids" && Array.isArray(value) && value.length > 0) { - value.forEach(iid => { - url.searchParams.append("iids[]", iid.toString()); - }); - } else if (value !== undefined) { - url.searchParams.append(key, value.toString()); - } - } - }); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabMilestonesSchema).parse(data); -} - -/** - * Get a single milestone in a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} Milestone details - */ -async function getProjectMilestone( - projectId: string, - milestoneId: number -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabMilestonesSchema.parse(data); -} - -/** - * Create a new milestone in a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {Object} options - Options for creating a milestone - * @returns {Promise} Created milestone - */ -async function createProjectMilestone( - projectId: string, - options: Omit, "project_id"> -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - body: JSON.stringify(options), - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabMilestonesSchema.parse(data); -} - -/** - * Edit an existing milestone in a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @param {Object} options - Options for editing a milestone - * @returns {Promise} Updated milestone - */ -async function editProjectMilestone( - projectId: string, - milestoneId: number, - options: Omit, "project_id" | "milestone_id"> -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "PUT", - body: JSON.stringify(options), - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabMilestonesSchema.parse(data); -} - -/** - * Delete a milestone from a GitLab project - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} - */ -async function deleteProjectMilestone(projectId: string, milestoneId: number): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "DELETE", - }); - await handleGitLabError(response); -} - -/** - * Get all issues assigned to a single milestone - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} List of issues - */ -async function getMilestoneIssues(projectId: string, milestoneId: number): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/issues` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabIssueSchema).parse(data); -} - -/** - * Get all merge requests assigned to a single milestone - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} List of merge requests - */ -async function getMilestoneMergeRequests( - projectId: string, - milestoneId: number -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/milestones/${milestoneId}/merge_requests` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return z.array(GitLabMergeRequestSchema).parse(data); -} - -/** - * Promote a project milestone to a group milestone - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} Promoted milestone - */ -async function promoteProjectMilestone( - projectId: string, - milestoneId: number -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/milestones/${milestoneId}/promote` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - method: "POST", - }); - await handleGitLabError(response); - const data = await response.json(); - return GitLabMilestonesSchema.parse(data); -} - -/** - * Get all burndown chart events for a single milestone - * @param {string} projectId - The ID or URL-encoded path of the project - * @param {number} milestoneId - The ID of the milestone - * @returns {Promise} Burndown chart events - */ -async function getMilestoneBurndownEvents(projectId: string, milestoneId: number): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent( - getEffectiveProjectId(projectId) - )}/milestones/${milestoneId}/burndown_events` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - await handleGitLabError(response); - const data = await response.json(); - return data as any[]; -} - -/** - * Get a single user from GitLab - * - * @param {string} username - The username to look up - * @returns {Promise} The user data or null if not found - */ -async function getUser(username: string): Promise { - try { - const url = new URL(`${config.GITLAB_API_URL}/users`); - url.searchParams.append("username", username); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const users = await response.json(); - - // GitLab returns an array of users that match the username - if (Array.isArray(users) && users.length > 0) { - // Find exact match for username (case-sensitive) - const exactMatch = users.find(user => user.username === username); - if (exactMatch) { - return GitLabUserSchema.parse(exactMatch); - } - } - - // No matching user found - return null; - } catch (error) { - console.error(`Error fetching user by username '${username}':`, error); - return null; - } -} - -/** - * Get multiple users from GitLab - * - * @param {string[]} usernames - Array of usernames to look up - * @returns {Promise} Object with usernames as keys and user objects or null as values - */ -async function getUsers(usernames: string[]): Promise { - const users: Record = {}; - - // Process usernames sequentially to avoid rate limiting - for (const username of usernames) { - try { - const user = await getUser(username); - users[username] = user; - } catch (error) { - console.error(`Error processing username '${username}':`, error); - users[username] = null; - } - } - - return GitLabUsersResponseSchema.parse(users); -} - -/** - * List repository commits - * 저장소 커밋 목록 조회 - * - * @param {string} projectId - Project ID or URL-encoded path - * @param {ListCommitsOptions} options - List commits options - * @returns {Promise} List of commits - */ -async function listCommits( - projectId: string, - options: Omit = {} -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits` - ); - - // Add query parameters - if (options.ref_name) url.searchParams.append("ref_name", options.ref_name); - if (options.since) url.searchParams.append("since", options.since); - if (options.until) url.searchParams.append("until", options.until); - if (options.path) url.searchParams.append("path", options.path); - if (options.author) url.searchParams.append("author", options.author); - if (options.all) url.searchParams.append("all", options.all.toString()); - if (options.with_stats) url.searchParams.append("with_stats", options.with_stats.toString()); - if (options.first_parent) url.searchParams.append("first_parent", options.first_parent.toString()); - if (options.order) url.searchParams.append("order", options.order); - if (options.trailers) url.searchParams.append("trailers", options.trailers.toString()); - if (options.page) url.searchParams.append("page", options.page.toString()); - if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const data = await response.json(); - return z.array(GitLabCommitSchema).parse(data); -} - -/** - * Get a single commit - * 단일 커밋 정보 조회 - * - * @param {string} projectId - Project ID or URL-encoded path - * @param {string} sha - The commit hash or name of a repository branch or tag - * @param {boolean} [stats] - Include commit stats - * @returns {Promise} The commit details - */ -async function getCommit( - projectId: string, - sha: string, - stats?: boolean -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}` - ); - - if (stats) { - url.searchParams.append("stats", "true"); - } - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const data = await response.json(); - return GitLabCommitSchema.parse(data); -} - -/** - * Get commit diff - * 커밋 변경사항 조회 - * - * @param {string} projectId - Project ID or URL-encoded path - * @param {string} sha - The commit hash or name of a repository branch or tag - * @returns {Promise} The commit diffs - */ -async function getCommitDiff( - projectId: string, - sha: string -): Promise { - projectId = decodeURIComponent(projectId); - const url = new URL( - `${config.GITLAB_API_URL}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}/diff` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - await handleGitLabError(response); - - const data = await response.json(); - return z.array(GitLabDiffSchema).parse(data); -} - -server.setRequestHandler(ListToolsRequestSchema, async () => { - // Apply read-only filter first - const tools0 = config.GITLAB_READ_ONLY_MODE - ? allTools.filter(tool => readOnlyTools.includes(tool.name)) - : allTools; - // Toggle wiki tools by USE_GITLAB_WIKI flag - const tools1 = config.USE_GITLAB_WIKI - ? tools0 - : tools0.filter(tool => !wikiToolNames.includes(tool.name)); - // Toggle milestone tools by USE_MILESTONE flag - const tools2 = config.USE_MILESTONE - ? tools1 - : tools1.filter(tool => !milestoneToolNames.includes(tool.name)); - // Toggle pipeline tools by USE_PIPELINE flag - let tools = config.USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.includes(tool.name)); - - // <<< START: Gemini 호환성을 위해 $schema 제거 >>> - tools = tools.map(tool => { - // inputSchema가 존재하고 객체인지 확인 - if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) { - // $schema 키가 존재하면 삭제 - if ("$schema" in tool.inputSchema) { - // 불변성을 위해 새로운 객체 생성 (선택적이지만 권장) - const modifiedSchema = { ...tool.inputSchema }; - delete modifiedSchema.$schema; - return { ...tool, inputSchema: modifiedSchema }; - } - } - // 변경이 필요 없으면 그대로 반환 - return tool; - }); - // <<< END: Gemini 호환성을 위해 $schema 제거 >>> - - return { - tools, // $schema가 제거된 도구 목록 반환 - }; -}); - -server.setRequestHandler(CallToolRequestSchema, async request => { - try { - if (!request.params.arguments) { - throw new Error("Arguments are required"); - } - - // Ensure session is established for every request if cookie authentication is enabled - if (config.GITLAB_AUTH_COOKIE_PATH) { - await ensureSessionForRequest(); - } - - switch (request.params.name) { - case "fork_repository": { - if (config.GITLAB_PROJECT_ID) { - throw new Error("Direct project ID is set. So fork_repository is not allowed"); - } - const forkArgs = ForkRepositorySchema.parse(request.params.arguments); - try { - const forkedProject = await forkProject(forkArgs.project_id, forkArgs.namespace); - return { - content: [{ type: "text", text: JSON.stringify(forkedProject, null, 2) }], - }; - } catch (forkError) { - console.error("Error forking repository:", forkError); - let forkErrorMessage = "Failed to fork repository"; - if (forkError instanceof Error) { - forkErrorMessage = `${forkErrorMessage}: ${forkError.message}`; - } - return { - content: [ - { - type: "text", - text: JSON.stringify({ error: forkErrorMessage }, null, 2), - }, - ], - }; - } - } - - case "create_branch": { - const args = CreateBranchSchema.parse(request.params.arguments); - let ref = args.ref; - if (!ref) { - ref = await getDefaultBranchRef(args.project_id); - } - - const branch = await createBranch(args.project_id, { - name: args.branch, - ref, - }); - - return { - content: [{ type: "text", text: JSON.stringify(branch, null, 2) }], - }; - } - - case "get_branch_diffs": { - const args = GetBranchDiffsSchema.parse(request.params.arguments); - const diffResp = await getBranchDiffs(args.project_id, args.from, args.to, args.straight); - - if (args.excluded_file_patterns?.length) { - const regexPatterns = args.excluded_file_patterns.map(pattern => new RegExp(pattern)); - - // Helper function to check if a path matches any regex pattern - const matchesAnyPattern = (path: string): boolean => { - if (!path) return false; - return regexPatterns.some(regex => regex.test(path)); - }; - - // Filter out files that match any of the regex patterns on new files - diffResp.diffs = diffResp.diffs.filter(diff => !matchesAnyPattern(diff.new_path)); - } - return { - content: [{ type: "text", text: JSON.stringify(diffResp, null, 2) }], - }; - } - - case "search_repositories": { - const args = SearchRepositoriesSchema.parse(request.params.arguments); - const results = await searchProjects(args.search, args.page, args.per_page); - return { - content: [{ type: "text", text: JSON.stringify(results, null, 2) }], - }; - } - - case "create_repository": { - if (config.GITLAB_PROJECT_ID) { - throw new Error("Direct project ID is set. So fork_repository is not allowed"); - } - const args = CreateRepositorySchema.parse(request.params.arguments); - const repository = await createRepository(args); - return { - content: [{ type: "text", text: JSON.stringify(repository, null, 2) }], - }; - } - - case "get_file_contents": { - const args = GetFileContentsSchema.parse(request.params.arguments); - const contents = await getFileContents(args.project_id, args.file_path, args.ref); - return { - content: [{ type: "text", text: JSON.stringify(contents, null, 2) }], - }; - } - - case "create_or_update_file": { - const args = CreateOrUpdateFileSchema.parse(request.params.arguments); - const result = await createOrUpdateFile( - args.project_id, - args.file_path, - args.content, - args.commit_message, - args.branch, - args.previous_path, - args.last_commit_id, - args.commit_id - ); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "push_files": { - const args = PushFilesSchema.parse(request.params.arguments); - const result = await createCommit( - args.project_id, - args.commit_message, - args.branch, - args.files.map(f => ({ path: f.file_path, content: f.content })) - ); - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } - - case "create_issue": { - const args = CreateIssueSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const issue = await createIssue(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], - }; - } - - case "create_merge_request": { - const args = CreateMergeRequestSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const mergeRequest = await createMergeRequest(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], - }; - } - - case "update_merge_request_note": { - const args = UpdateMergeRequestNoteSchema.parse(request.params.arguments); - const note = await updateMergeRequestNote( - args.project_id, - args.merge_request_iid, - args.discussion_id, - args.note_id, - args.body, // Now optional - args.resolved // Now one of body or resolved must be provided, not both - ); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } - - case "create_merge_request_note": { - const args = CreateMergeRequestNoteSchema.parse(request.params.arguments); - const note = await createMergeRequestNote( - args.project_id, - args.merge_request_iid, - args.discussion_id, - args.body, - args.created_at - ); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } - - case "update_issue_note": { - const args = UpdateIssueNoteSchema.parse(request.params.arguments); - const note = await updateIssueNote( - args.project_id, - args.issue_iid, - args.discussion_id, - args.note_id, - args.body - ); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } - - case "create_issue_note": { - const args = CreateIssueNoteSchema.parse(request.params.arguments); - const note = await createIssueNote( - args.project_id, - args.issue_iid, - args.discussion_id, - args.body, - args.created_at - ); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } - - case "get_merge_request": { - const args = GetMergeRequestSchema.parse(request.params.arguments); - const mergeRequest = await getMergeRequest( - args.project_id, - args.merge_request_iid, - args.source_branch - ); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], - }; - } - - case "get_merge_request_diffs": { - const args = GetMergeRequestDiffsSchema.parse(request.params.arguments); - const diffs = await getMergeRequestDiffs( - args.project_id, - args.merge_request_iid, - args.source_branch, - args.view - ); - return { - content: [{ type: "text", text: JSON.stringify(diffs, null, 2) }], - }; - } - - case "list_merge_request_diffs": { - const args = ListMergeRequestDiffsSchema.parse(request.params.arguments); - const changes = await listMergeRequestDiffs( - args.project_id, - args.merge_request_iid, - args.source_branch, - args.page, - args.per_page, - args.unidiff - ); - return { - content: [{ type: "text", text: JSON.stringify(changes, null, 2) }], - }; - } - - case "update_merge_request": { - const args = UpdateMergeRequestSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, source_branch, ...options } = args; - const mergeRequest = await updateMergeRequest( - project_id, - options, - merge_request_iid, - source_branch - ); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], - }; - } - - case "mr_discussions": { - const args = ListMergeRequestDiscussionsSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, ...options } = args; - const discussions = await listMergeRequestDiscussions( - project_id, - merge_request_iid, - options - ); - return { - content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], - }; - } - - case "list_namespaces": { - const args = ListNamespacesSchema.parse(request.params.arguments); - const url = new URL(`${config.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(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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( - `${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(args.namespace_id)}` - ); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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(`${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(args.path)}/exists`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(args.project_id)}`); - - const response = await fetch(url.toString(), { - ...DEFAULT_FETCH_CONFIG, - }); - - 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 projects = await listProjects(args); - - return { - content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], - }; - } - - case "get_users": { - const args = GetUsersSchema.parse(request.params.arguments); - const usersMap = await getUsers(args.usernames); - - return { - content: [{ type: "text", text: JSON.stringify(usersMap, null, 2) }], - }; - } - - case "create_note": { - const args = CreateNoteSchema.parse(request.params.arguments); - const { project_id, noteable_type, noteable_iid, body } = args; - - const note = await createNote(project_id, noteable_type, noteable_iid, body); - return { - content: [{ type: "text", text: JSON.stringify(note, null, 2) }], - }; - } - - case "create_merge_request_thread": { - const args = CreateMergeRequestThreadSchema.parse(request.params.arguments); - const { project_id, merge_request_iid, body, position, created_at } = args; - - const thread = await createMergeRequestThread( - project_id, - merge_request_iid, - body, - position, - created_at - ); - return { - content: [{ type: "text", text: JSON.stringify(thread, null, 2) }], - }; - } - - case "list_issues": { - const args = ListIssuesSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const issues = await listIssues(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(issues, null, 2) }], - }; - } - - case "get_issue": { - const args = GetIssueSchema.parse(request.params.arguments); - const issue = await getIssue(args.project_id, args.issue_iid); - return { - content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], - }; - } - - case "update_issue": { - const args = UpdateIssueSchema.parse(request.params.arguments); - const { project_id, issue_iid, ...options } = args; - const issue = await updateIssue(project_id, issue_iid, options); - return { - content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], - }; - } - - case "delete_issue": { - const args = DeleteIssueSchema.parse(request.params.arguments); - await deleteIssue(args.project_id, args.issue_iid); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { status: "success", message: "Issue deleted successfully" }, - null, - 2 - ), - }, - ], - }; - } - - case "list_issue_links": { - const args = ListIssueLinksSchema.parse(request.params.arguments); - const links = await listIssueLinks(args.project_id, args.issue_iid); - return { - content: [{ type: "text", text: JSON.stringify(links, null, 2) }], - }; - } - - case "list_issue_discussions": { - const args = ListIssueDiscussionsSchema.parse(request.params.arguments); - const { project_id, issue_iid, ...options } = args; - - const discussions = await listIssueDiscussions(project_id, issue_iid, options); - return { - content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], - }; - } - - case "get_issue_link": { - const args = GetIssueLinkSchema.parse(request.params.arguments); - const link = await getIssueLink(args.project_id, args.issue_iid, args.issue_link_id); - return { - content: [{ type: "text", text: JSON.stringify(link, null, 2) }], - }; - } - - case "create_issue_link": { - const args = CreateIssueLinkSchema.parse(request.params.arguments); - const link = await createIssueLink( - args.project_id, - args.issue_iid, - args.target_project_id, - args.target_issue_iid, - args.link_type - ); - return { - content: [{ type: "text", text: JSON.stringify(link, null, 2) }], - }; - } - - case "delete_issue_link": { - const args = DeleteIssueLinkSchema.parse(request.params.arguments); - await deleteIssueLink(args.project_id, args.issue_iid, args.issue_link_id); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - status: "success", - message: "Issue link deleted successfully", - }, - null, - 2 - ), - }, - ], - }; - } - - case "list_labels": { - const args = ListLabelsSchema.parse(request.params.arguments); - const labels = await listLabels(args.project_id, args); - return { - content: [{ type: "text", text: JSON.stringify(labels, null, 2) }], - }; - } - - case "get_label": { - const args = GetLabelSchema.parse(request.params.arguments); - const label = await getLabel(args.project_id, args.label_id, args.include_ancestor_groups); - return { - content: [{ type: "text", text: JSON.stringify(label, null, 2) }], - }; - } - - case "create_label": { - const args = CreateLabelSchema.parse(request.params.arguments); - const label = await createLabel(args.project_id, args); - return { - content: [{ type: "text", text: JSON.stringify(label, null, 2) }], - }; - } - - case "update_label": { - const args = UpdateLabelSchema.parse(request.params.arguments); - const { project_id, label_id, ...options } = args; - const label = await updateLabel(project_id, label_id, options); - return { - content: [{ type: "text", text: JSON.stringify(label, null, 2) }], - }; - } - - case "delete_label": { - const args = DeleteLabelSchema.parse(request.params.arguments); - await deleteLabel(args.project_id, args.label_id); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { status: "success", message: "Label deleted successfully" }, - null, - 2 - ), - }, - ], - }; - } - - case "list_group_projects": { - const args = ListGroupProjectsSchema.parse(request.params.arguments); - const projects = await listGroupProjects(args); - return { - content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], - }; - } - - case "list_wiki_pages": { - const { project_id, page, per_page, with_content } = ListWikiPagesSchema.parse( - request.params.arguments - ); - const wikiPages = await listWikiPages(project_id, { page, per_page, with_content }); - return { - content: [{ type: "text", text: JSON.stringify(wikiPages, null, 2) }], - }; - } - - case "get_wiki_page": { - const { project_id, slug } = GetWikiPageSchema.parse(request.params.arguments); - const wikiPage = await getWikiPage(project_id, slug); - return { - content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], - }; - } - - case "create_wiki_page": { - const { project_id, title, content, format } = CreateWikiPageSchema.parse( - request.params.arguments - ); - const wikiPage = await createWikiPage(project_id, title, content, format); - return { - content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], - }; - } - - case "update_wiki_page": { - const { project_id, slug, title, content, format } = UpdateWikiPageSchema.parse( - request.params.arguments - ); - const wikiPage = await updateWikiPage(project_id, slug, title, content, format); - return { - content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], - }; - } - - case "delete_wiki_page": { - const { project_id, slug } = DeleteWikiPageSchema.parse(request.params.arguments); - await deleteWikiPage(project_id, slug); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - status: "success", - message: "Wiki page deleted successfully", - }, - null, - 2 - ), - }, - ], - }; - } - - case "get_repository_tree": { - const args = GetRepositoryTreeSchema.parse(request.params.arguments); - const tree = await getRepositoryTree(args); - return { - content: [{ type: "text", text: JSON.stringify(tree, null, 2) }], - }; - } - - case "list_pipelines": { - const args = ListPipelinesSchema.parse(request.params.arguments); - const { project_id, ...options } = args; - const pipelines = await listPipelines(project_id, options); - return { - content: [{ type: "text", text: JSON.stringify(pipelines, null, 2) }], - }; - } - - case "get_pipeline": { - const { project_id, pipeline_id } = GetPipelineSchema.parse(request.params.arguments); - const pipeline = await getPipeline(project_id, pipeline_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(pipeline, null, 2), - }, - ], - }; - } - - case "list_pipeline_jobs": { - const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse( - request.params.arguments - ); - const jobs = await listPipelineJobs(project_id, pipeline_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(jobs, null, 2), - }, - ], - }; - } - - case "get_pipeline_job": { - const { project_id, job_id } = GetPipelineJobOutputSchema.parse(request.params.arguments); - const jobDetails = await getPipelineJob(project_id, job_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(jobDetails, null, 2), - }, - ], - }; - } - - case "get_pipeline_job_output": { - const { project_id, job_id, limit, offset } = GetPipelineJobOutputSchema.parse(request.params.arguments); - const jobOutput = await getPipelineJobOutput(project_id, job_id, limit, offset); - return { - content: [ - { - type: "text", - text: jobOutput, - }, - ], - }; - } - - case "create_pipeline": { - const { project_id, ref, variables } = CreatePipelineSchema.parse(request.params.arguments); - const pipeline = await createPipeline(project_id, ref, variables); - return { - content: [ - { - type: "text", - text: `Created pipeline #${pipeline.id} for ${ref}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, - }, - ], - }; - } - - case "retry_pipeline": { - const { project_id, pipeline_id } = RetryPipelineSchema.parse(request.params.arguments); - const pipeline = await retryPipeline(project_id, pipeline_id); - return { - content: [ - { - type: "text", - text: `Retried pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, - }, - ], - }; - } - - case "cancel_pipeline": { - const { project_id, pipeline_id } = CancelPipelineSchema.parse(request.params.arguments); - const pipeline = await cancelPipeline(project_id, pipeline_id); - return { - content: [ - { - type: "text", - text: `Canceled pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, - }, - ], - }; - } - - case "list_merge_requests": { - const args = ListMergeRequestsSchema.parse(request.params.arguments); - const mergeRequests = await listMergeRequests(args.project_id, args); - return { - content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }], - }; - } - - case "list_milestones": { - const { project_id, ...options } = ListProjectMilestonesSchema.parse( - request.params.arguments - ); - const milestones = await listProjectMilestones(project_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestones, null, 2), - }, - ], - }; - } - - case "get_milestone": { - const { project_id, milestone_id } = GetProjectMilestoneSchema.parse( - request.params.arguments - ); - const milestone = await getProjectMilestone(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestone, null, 2), - }, - ], - }; - } - - case "create_milestone": { - const { project_id, ...options } = CreateProjectMilestoneSchema.parse( - request.params.arguments - ); - const milestone = await createProjectMilestone(project_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestone, null, 2), - }, - ], - }; - } - - case "edit_milestone": { - const { project_id, milestone_id, ...options } = EditProjectMilestoneSchema.parse( - request.params.arguments - ); - const milestone = await editProjectMilestone(project_id, milestone_id, options); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestone, null, 2), - }, - ], - }; - } - - case "delete_milestone": { - const { project_id, milestone_id } = DeleteProjectMilestoneSchema.parse( - request.params.arguments - ); - await deleteProjectMilestone(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - status: "success", - message: "Milestone deleted successfully", - }, - null, - 2 - ), - }, - ], - }; - } - - case "get_milestone_issue": { - const { project_id, milestone_id } = GetMilestoneIssuesSchema.parse( - request.params.arguments - ); - const issues = await getMilestoneIssues(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(issues, null, 2), - }, - ], - }; - } - - case "get_milestone_merge_requests": { - const { project_id, milestone_id } = GetMilestoneMergeRequestsSchema.parse( - request.params.arguments - ); - const mergeRequests = await getMilestoneMergeRequests(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(mergeRequests, null, 2), - }, - ], - }; - } - - case "promote_milestone": { - const { project_id, milestone_id } = PromoteProjectMilestoneSchema.parse( - request.params.arguments - ); - const milestone = await promoteProjectMilestone(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(milestone, null, 2), - }, - ], - }; - } - - case "get_milestone_burndown_events": { - const { project_id, milestone_id } = GetMilestoneBurndownEventsSchema.parse( - request.params.arguments - ); - const events = await getMilestoneBurndownEvents(project_id, milestone_id); - return { - content: [ - { - type: "text", - text: JSON.stringify(events, null, 2), - }, - ], - }; - } - - case "list_commits": { - const args = ListCommitsSchema.parse(request.params.arguments); - const commits = await listCommits(args.project_id, args); - return { - content: [{ type: "text", text: JSON.stringify(commits, null, 2) }], - }; - } - - case "get_commit": { - const args = GetCommitSchema.parse(request.params.arguments); - const commit = await getCommit(args.project_id, args.sha, args.stats); - return { - content: [{ type: "text", text: JSON.stringify(commit, null, 2) }], - }; - } - - case "get_commit_diff": { - const args = GetCommitDiffSchema.parse(request.params.arguments); - const diff = await getCommitDiff(args.project_id, args.sha); - return { - content: [{ type: "text", text: JSON.stringify(diff, null, 2) }], - }; - } - - default: - throw new Error(`Unknown tool: ${request.params.name}`); - } - } catch (error) { - if (error instanceof z.ZodError) { - throw new Error( - `Invalid arguments: ${error.errors - .map(e => `${e.path.join(".")}: ${e.message}`) - .join(", ")}` - ); - } - throw error; - } -}); - -export const mcpserver = server diff --git a/src/authhelpers.ts b/src/authhelpers.ts new file mode 100644 index 0000000..0089b68 --- /dev/null +++ b/src/authhelpers.ts @@ -0,0 +1,50 @@ +import { config } from "./config.js"; +import { CookieJar, parse as parseCookie } from "tough-cookie"; +import fs from "fs"; +import path from "path"; + +// Create cookie jar with clean Netscape file parsing +export const createCookieJar = (): CookieJar | undefined=> { + if (!config.GITLAB_AUTH_COOKIE_PATH) return undefined; + + try { + const cookiePath = config.GITLAB_AUTH_COOKIE_PATH.startsWith("~/") + ? path.join(process.env.HOME || "", config.GITLAB_AUTH_COOKIE_PATH.slice(2)) + : config.GITLAB_AUTH_COOKIE_PATH; + + const jar = new CookieJar(); + const cookieContent = fs.readFileSync(cookiePath, "utf8"); + + cookieContent.split("\n").forEach(line => { + // Handle #HttpOnly_ prefix + if (line.startsWith("#HttpOnly_")) { + line = line.slice(10); + } + // Skip comments and empty lines + if (line.startsWith("#") || !line.trim()) { + return; + } + + // Parse Netscape format: domain, flag, path, secure, expires, name, value + const parts = line.split("\t"); + if (parts.length >= 7) { + const [domain, , path, secure, expires, name, value] = parts; + + // Build cookie string in standard format + const cookieStr = `${name}=${value}; Domain=${domain}; Path=${path}${secure === "TRUE" ? "; Secure" : ""}${expires !== "0" ? `; Expires=${new Date(parseInt(expires) * 1000).toUTCString()}` : ""}`; + + // Use tough-cookie's parse function for robust parsing + const cookie = parseCookie(cookieStr); + if (cookie) { + const url = `${secure === "TRUE" ? "https" : "http"}://${domain.startsWith(".") ? domain.slice(1) : domain}`; + jar.setCookieSync(cookie, url); + } + } + }); + + return jar; + } catch (error) { + console.error("Error loading cookie file:", error); + return undefined; + } +}; diff --git a/config.ts b/src/config.ts similarity index 91% rename from config.ts rename to src/config.ts index 356a919..9bfd357 100644 --- a/config.ts +++ b/src/config.ts @@ -15,6 +15,10 @@ export const config = { GITLAB_API_URL: normalizeGitLabApiUrl(process.env.GITLAB_API_URL || ""), GITLAB_PROJECT_ID: process.env.GITLAB_PROJECT_ID, } +if (!config.GITLAB_PERSONAL_ACCESS_TOKEN) { + console.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set"); + process.exit(1); +} /** * Smart URL handling for GitLab API * diff --git a/src/gitlabhandler.ts b/src/gitlabhandler.ts new file mode 100644 index 0000000..f3e1d7a --- /dev/null +++ b/src/gitlabhandler.ts @@ -0,0 +1,2235 @@ +import { z } from "zod"; +import { URL } from "url"; +import { Response, RequestInit } from "node-fetch"; +import { config } from "./config.js"; +import { + GitLabForkSchema, + GitLabReferenceSchema, + GitLabRepositorySchema, + GitLabIssueSchema, + GitLabMergeRequestSchema, + GitLabContentSchema, + GitLabCreateUpdateFileResponseSchema, + GitLabSearchResponseSchema, + GitLabTreeSchema, + GitLabCommitSchema, + GitLabNamespaceSchema, + GitLabNamespaceExistsResponseSchema, + GitLabProjectSchema, + GitLabLabelSchema, + GitLabUserSchema, + GitLabUsersResponseSchema, + CreateRepositoryOptionsSchema, + CreateIssueOptionsSchema, + CreateMergeRequestOptionsSchema, + CreateBranchOptionsSchema, + GitLabDiffSchema, + GitLabIssueLinkSchema, + GitLabIssueWithLinkDetailsSchema, + GitLabDiscussionNoteSchema, + GitLabDiscussionSchema, + PaginatedDiscussionsResponseSchema, + GitLabWikiPageSchema, + GitLabTreeItemSchema, + GitLabPipelineSchema, + GitLabPipelineJobSchema, + GitLabMilestonesSchema, + GitLabCompareResultSchema, + type GitLabFork, + type GitLabReference, + type GitLabRepository, + type GitLabIssue, + type GitLabMergeRequest, + type GitLabContent, + type GitLabCreateUpdateFileResponse, + type GitLabSearchResponse, + type GitLabTree, + type GitLabCommit, + type FileOperation, + type GitLabMergeRequestDiff, + type GitLabIssueLink, + type GitLabIssueWithLinkDetails, + type GitLabNamespace, + type GitLabNamespaceExistsResponse, + type GitLabProject, + type GitLabLabel, + type GitLabUser, + type GitLabUsersResponse, + type GitLabPipeline, + type ListPipelinesOptions, + type GetPipelineOptions, + type ListPipelineJobsOptions, + type CreatePipelineOptions, + type RetryPipelineOptions, + type CancelPipelineOptions, + type GitLabPipelineJob, + type GitLabMilestones, + type ListProjectMilestonesOptions, + type GetProjectMilestoneOptions, + type CreateProjectMilestoneOptions, + type EditProjectMilestoneOptions, + type DeleteProjectMilestoneOptions, + type GetMilestoneIssuesOptions, + type GetMilestoneMergeRequestsOptions, + type PromoteProjectMilestoneOptions, + type GetMilestoneBurndownEventsOptions, + type GitLabDiscussionNote, + type GitLabDiscussion, + type PaginatedDiscussionsResponse, + type PaginationOptions, + type MergeRequestThreadPosition, + type GetWikiPageOptions, + type CreateWikiPageOptions, + type UpdateWikiPageOptions, + type DeleteWikiPageOptions, + type GitLabWikiPage, + type GitLabTreeItem, + type GetRepositoryTreeOptions, + type GitLabCompareResult, + type ListWikiPagesOptions, + type ListCommitsOptions, + type GetCommitOptions, + type GetCommitDiffOptions, + ListProjectMilestonesSchema, + UpdateMergeRequestSchema, + ListIssuesSchema, + UpdateIssueSchema, + ListMergeRequestsSchema, + ListLabelsSchema, + UpdateLabelSchema, + ListGroupProjectsSchema, + ListProjectsSchema, + CreateLabelSchema, + CreateProjectMilestoneSchema, + EditProjectMilestoneSchema, +} from "./schemas.js"; +import {GitlabSession} from "./gitlabsession.js"; + +export class GitlabHandler extends GitlabSession { + + /** + * Utility function for handling GitLab API errors + */ + async handleGitLabError(response: Response): Promise { + if (!response.ok) { + const errorBody = await response.text(); + // Check specifically for Rate Limit error + if (response.status === 403 && errorBody.includes("User API Key Rate limit exceeded")) { + console.error("GitLab API Rate Limit Exceeded:", errorBody); + console.log("User API Key Rate limit exceeded. Please try again later."); + throw new Error(`GitLab API Rate Limit Exceeded: ${errorBody}`); + } else { + // Handle other API errors + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + } + } + + /** + * Get effective project ID based on config or provided ID + */ + getEffectiveProjectId(projectId: string): string { + return config.GITLAB_PROJECT_ID || projectId; + } + + /** + * Create a fork of a GitLab project + */ + async forkProject(projectId: string, namespace?: string): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/fork`); + + if (namespace) { + url.searchParams.append("namespace", namespace); + } + + const response = await this.fetch(url.toString(), { + + method: "POST", + }); + + if (response.status === 409) { + throw new Error("Project already exists in the target namespace"); + } + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabForkSchema.parse(data); + } + + /** + * Create a new branch in a GitLab project + */ + async createBranch( + projectId: string, + options: z.infer + ): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/branches` + ); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ + branch: options.name, + ref: options.ref, + }), + }); + + await this.handleGitLabError(response); + return GitLabReferenceSchema.parse(await response.json()); + } + + /** + * Get the default branch for a GitLab project + */ + async getDefaultBranchRef(projectId: string): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}`); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const project = GitLabRepositorySchema.parse(await response.json()); + return project.default_branch ?? "main"; + } + + /** + * Get the contents of a file from a GitLab project + */ + async getFileContents( + projectId: string, + filePath: string, + ref?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const encodedPath = encodeURIComponent(filePath); + + if (!ref) { + ref = await this.getDefaultBranchRef(projectId); + } + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/repository/files/${encodedPath}` + ); + + url.searchParams.append("ref", ref); + + const response = await this.fetch(url.toString(), { + + }); + + if (response.status === 404) { + throw new Error(`File not found: ${filePath}`); + } + + await this.handleGitLabError(response); + const data = await response.json(); + const parsedData = GitLabContentSchema.parse(data); + + // Decode Base64 content to UTF-8 + if (!Array.isArray(parsedData) && parsedData.content) { + parsedData.content = Buffer.from(parsedData.content, "base64").toString("utf8"); + parsedData.encoding = "utf8"; + } + + return parsedData; + } + + /** + * Create a new issue in a GitLab project + */ + async createIssue( + projectId: string, + options: z.infer + ): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ + title: options.title, + description: options.description, + assignee_ids: options.assignee_ids, + milestone_id: options.milestone_id, + labels: options.labels?.join(","), + }), + }); + + if (response.status === 400) { + const errorBody = await response.text(); + throw new Error(`Invalid request: ${errorBody}`); + } + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabIssueSchema.parse(data); + } + + /** + * List issues in a GitLab project + */ + async listIssues( + projectId: string, + options: Omit, "project_id"> = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const effectiveProjectId = this.getEffectiveProjectId(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}/issues`); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + const keys = ["labels", "assignee_username"]; + if (keys.includes(key)) { + if (Array.isArray(value)) { + value.forEach(label => { + url.searchParams.append(`${key}[]`, label.toString()); + }); + } else { + url.searchParams.append(`${key}[]`, value.toString()); + } + } else { + url.searchParams.append(key, value.toString()); + } + } + }); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabIssueSchema).parse(data); + } + + /** + * List merge requests in a GitLab project with optional filtering + */ + async listMergeRequests( + projectId: string, + options: Omit, "project_id"> = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/merge_requests`); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (key === "labels" && Array.isArray(value)) { + url.searchParams.append(key, value.join(",")); + } else { + url.searchParams.append(key, value.toString()); + } + } + }); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabMergeRequestSchema).parse(data); + } + + /** + * Get a single issue from a GitLab project + */ + async getIssue(projectId: string, issueIid: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}` + ); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabIssueSchema.parse(data); + } + + /** + * Update an issue in a GitLab project + */ + async updateIssue( + projectId: string, + issueIid: number, + options: Omit, "project_id" | "issue_iid"> + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}` + ); + + const body: Record = { ...options }; + if (body.labels && Array.isArray(body.labels)) { + body.labels = body.labels.join(","); + } + + const response = await this.fetch(url.toString(), { + + method: "PUT", + body: JSON.stringify(body), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabIssueSchema.parse(data); + } + + /** + * Delete an issue from a GitLab project + */ + async deleteIssue(projectId: string, issueIid: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}` + ); + + const response = await this.fetch(url.toString(), { + + method: "DELETE", + }); + + await this.handleGitLabError(response); + } + + /** + * List all issue links for a specific issue + */ + async listIssueLinks( + projectId: string, + issueIid: number + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}/links` + ); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabIssueWithLinkDetailsSchema).parse(data); + } + + /** + * Get a specific issue link + */ + async getIssueLink( + projectId: string, + issueIid: number, + issueLinkId: number + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/issues/${issueIid}/links/${issueLinkId}` + ); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabIssueLinkSchema.parse(data); + } + + /** + * Create an issue link between two issues + */ + async createIssueLink( + projectId: string, + issueIid: number, + targetProjectId: string, + targetIssueIid: number, + linkType: "relates_to" | "blocks" | "is_blocked_by" = "relates_to" + ): Promise { + projectId = decodeURIComponent(projectId); + targetProjectId = decodeURIComponent(targetProjectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}/links` + ); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ + target_project_id: targetProjectId, + target_issue_iid: targetIssueIid, + link_type: linkType, + }), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabIssueLinkSchema.parse(data); + } + + /** + * Delete an issue link + */ + async deleteIssueLink( + projectId: string, + issueIid: number, + issueLinkId: number + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/issues/${issueIid}/links/${issueLinkId}` + ); + + const response = await this.fetch(url.toString(), { + + method: "DELETE", + }); + + await this.handleGitLabError(response); + } + + /** + * Create a new merge request in a GitLab project + */ + async createMergeRequest( + projectId: string, + options: z.infer + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/merge_requests`); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ + title: options.title, + description: options.description, + source_branch: options.source_branch, + target_branch: options.target_branch, + assignee_ids: options.assignee_ids, + reviewer_ids: options.reviewer_ids, + labels: options.labels?.join(","), + allow_collaboration: options.allow_collaboration, + draft: options.draft, + remove_source_branch: options.remove_source_branch, + squash: options.squash, + }), + }); + + if (response.status === 400) { + const errorBody = await response.text(); + throw new Error(`Invalid request: ${errorBody}`); + } + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabMergeRequestSchema.parse(data); + } + + /** + * Shared helper function for listing discussions + */ + async listDiscussions( + projectId: string, + resourceType: "issues" | "merge_requests", + resourceIid: number, + options: PaginationOptions = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/${resourceType}/${resourceIid}/discussions` + ); + + if (options.page) { + url.searchParams.append("page", options.page.toString()); + } + if (options.per_page) { + url.searchParams.append("per_page", options.per_page.toString()); + } + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const discussions = await response.json(); + + const pagination = { + x_next_page: response.headers.get("x-next-page") + ? parseInt(response.headers.get("x-next-page")!) + : null, + x_page: response.headers.get("x-page") ? parseInt(response.headers.get("x-page")!) : undefined, + x_per_page: response.headers.get("x-per-page") + ? parseInt(response.headers.get("x-per-page")!) + : undefined, + x_prev_page: response.headers.get("x-prev-page") + ? parseInt(response.headers.get("x-prev-page")!) + : null, + x_total: response.headers.get("x-total") ? parseInt(response.headers.get("x-total")!) : null, + x_total_pages: response.headers.get("x-total-pages") + ? parseInt(response.headers.get("x-total-pages")!) + : null, + }; + + return PaginatedDiscussionsResponseSchema.parse({ + items: discussions, + pagination: pagination, + }); + } + + /** + * List merge request discussion items + */ + async listMergeRequestDiscussions( + projectId: string, + mergeRequestIid: number, + options: PaginationOptions = {} + ): Promise { + return this.listDiscussions(projectId, "merge_requests", mergeRequestIid, options); + } + + /** + * List discussions for an issue + */ + async listIssueDiscussions( + projectId: string, + issueIid: number, + options: PaginationOptions = {} + ): Promise { + return this.listDiscussions(projectId, "issues", issueIid, options); + } + + /** + * Modify an existing merge request thread note + */ + async updateMergeRequestNote( + projectId: string, + mergeRequestIid: number, + discussionId: string, + noteId: number, + body?: string, + resolved?: boolean + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes/${noteId}` + ); + + const payload: { body?: string; resolved?: boolean } = {}; + if (body !== undefined) { + payload.body = body; + } else if (resolved !== undefined) { + payload.resolved = resolved; + } + + const response = await this.fetch(url.toString(), { + + method: "PUT", + body: JSON.stringify(payload), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionNoteSchema.parse(data); + } + + /** + * Update an issue discussion note + */ + async updateIssueNote( + projectId: string, + issueIid: number, + discussionId: string, + noteId: number, + body: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/issues/${issueIid}/discussions/${discussionId}/notes/${noteId}` + ); + + const payload = { body }; + + const response = await this.fetch(url.toString(), { + + method: "PUT", + body: JSON.stringify(payload), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionNoteSchema.parse(data); + } + + /** + * Create a note in an issue discussion + */ + async createIssueNote( + projectId: string, + issueIid: number, + discussionId: string, + body: string, + createdAt?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/issues/${issueIid}/discussions/${discussionId}/notes` + ); + + const payload: { body: string; created_at?: string } = { body }; + if (createdAt) { + payload.created_at = createdAt; + } + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify(payload), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionNoteSchema.parse(data); + } + + /** + * Add a new note to an existing merge request thread + */ + async createMergeRequestNote( + projectId: string, + mergeRequestIid: number, + discussionId: string, + body: string, + createdAt?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests/${mergeRequestIid}/discussions/${discussionId}/notes` + ); + + const payload: { body: string; created_at?: string } = { body }; + if (createdAt) { + payload.created_at = createdAt; + } + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify(payload), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionNoteSchema.parse(data); + } + + /** + * Create or update a file in a GitLab project + */ + async createOrUpdateFile( + projectId: string, + filePath: string, + content: string, + commitMessage: string, + branch: string, + previousPath?: string, + last_commit_id?: string, + commit_id?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const encodedPath = encodeURIComponent(filePath); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/files/${encodedPath}` + ); + + const body: Record = { + branch, + content, + commit_message: commitMessage, + encoding: "text", + ...(previousPath ? { previous_path: previousPath } : {}), + }; + + let method = "POST"; + try { + const fileData = await this.getFileContents(projectId, filePath, branch); + method = "PUT"; + + if (!Array.isArray(fileData)) { + if (!commit_id && fileData.commit_id) { + body.commit_id = fileData.commit_id; + } else if (commit_id) { + body.commit_id = commit_id; + } + + if (!last_commit_id && fileData.last_commit_id) { + body.last_commit_id = fileData.last_commit_id; + } else if (last_commit_id) { + body.last_commit_id = last_commit_id; + } + } + } catch (error) { + if (!(error instanceof Error && error.message.includes("File not found"))) { + throw error; + } + if (commit_id) { + body.commit_id = commit_id; + } + if (last_commit_id) { + body.last_commit_id = last_commit_id; + } + } + + const response = await this.fetch(url.toString(), { + + method, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabCreateUpdateFileResponseSchema.parse(data); + } + + /** + * Create a tree structure in a GitLab project repository + */ + async createTree( + projectId: string, + files: FileOperation[], + ref?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/tree` + ); + + if (ref) { + url.searchParams.append("ref", ref); + } + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ + files: files.map(file => ({ + file_path: file.path, + content: file.content, + encoding: "text", + })), + }), + }); + + if (response.status === 400) { + const errorBody = await response.text(); + throw new Error(`Invalid request: ${errorBody}`); + } + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabTreeSchema.parse(data); + } + + /** + * Create a commit in a GitLab project repository + */ + async createCommit( + projectId: string, + message: string, + branch: string, + actions: FileOperation[] + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/commits` + ); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ + branch, + commit_message: message, + actions: actions.map(action => ({ + action: "create", + file_path: action.path, + content: action.content, + encoding: "text", + })), + }), + }); + + if (response.status === 400) { + const errorBody = await response.text(); + throw new Error(`Invalid request: ${errorBody}`); + } + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabCommitSchema.parse(data); + } + + /** + * Search for GitLab projects + */ + async searchProjects( + query: string, + page: number = 1, + perPage: number = 20 + ): Promise { + const url = new URL(`${config.GITLAB_API_URL}/projects`); + url.searchParams.append("search", query); + url.searchParams.append("page", page.toString()); + url.searchParams.append("per_page", perPage.toString()); + url.searchParams.append("order_by", "id"); + url.searchParams.append("sort", "desc"); + + const response = await this.fetch(url.toString(), { + + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const projects = (await response.json()) as GitLabRepository[]; + const totalCount = response.headers.get("x-total"); + const totalPages = response.headers.get("x-total-pages"); + + const count = totalCount ? parseInt(totalCount) : projects.length; + + return GitLabSearchResponseSchema.parse({ + count, + total_pages: totalPages ? parseInt(totalPages) : Math.ceil(count / perPage), + current_page: page, + items: projects, + }); + } + + /** + * Create a new GitLab repository + */ + async createRepository( + options: z.infer + ): Promise { + const response = await this.fetch(`${config.GITLAB_API_URL}/projects`, { + + method: "POST", + body: JSON.stringify({ + name: options.name, + description: options.description, + visibility: options.visibility, + initialize_with_readme: options.initialize_with_readme, + default_branch: "main", + path: options.name.toLowerCase().replace(/\s+/g, "-"), + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabRepositorySchema.parse(data); + } + + /** + * Get merge request details + */ + async getMergeRequest( + projectId: string, + mergeRequestIid?: number, + branchName?: string + ): Promise { + projectId = decodeURIComponent(projectId); + let url: URL; + + if (mergeRequestIid) { + url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests/${mergeRequestIid}` + ); + } else if (branchName) { + url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests?source_branch=${encodeURIComponent(branchName)}` + ); + } else { + throw new Error("Either mergeRequestIid or branchName must be provided"); + } + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + + const data = await response.json(); + + if (Array.isArray(data) && data.length > 0) { + return GitLabMergeRequestSchema.parse(data[0]); + } + + return GitLabMergeRequestSchema.parse(data); + } + + /** + * Get merge request changes/diffs + */ + async getMergeRequestDiffs( + projectId: string, + mergeRequestIid?: number, + branchName?: string, + view?: "inline" | "parallel" + ): Promise { + projectId = decodeURIComponent(projectId); + if (!mergeRequestIid && !branchName) { + throw new Error("Either mergeRequestIid or branchName must be provided"); + } + + if (branchName && !mergeRequestIid) { + const mergeRequest = await this.getMergeRequest(projectId, undefined, branchName); + mergeRequestIid = mergeRequest.iid; + } + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests/${mergeRequestIid}/changes` + ); + + if (view) { + url.searchParams.append("view", view); + } + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const data = (await response.json()) as { changes: unknown }; + return z.array(GitLabDiffSchema).parse(data.changes); + } + + /** + * Get merge request changes with detailed information + */ + async listMergeRequestDiffs( + projectId: string, + mergeRequestIid?: number, + branchName?: string, + page?: number, + perPage?: number, + unidiff?: boolean + ): Promise { + projectId = decodeURIComponent(projectId); + if (!mergeRequestIid && !branchName) { + throw new Error("Either mergeRequestIid or branchName must be provided"); + } + + if (branchName && !mergeRequestIid) { + const mergeRequest = await this.getMergeRequest(projectId, undefined, branchName); + mergeRequestIid = mergeRequest.iid; + } + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests/${mergeRequestIid}/diffs` + ); + + if (page) { + url.searchParams.append("page", page.toString()); + } + + if (perPage) { + url.searchParams.append("per_page", perPage.toString()); + } + + if (unidiff) { + url.searchParams.append("unidiff", "true"); + } + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + return await response.json(); + } + + /** + * Get branch comparison diffs + */ + async getBranchDiffs( + projectId: string, + from: string, + to: string, + straight?: boolean + ): Promise { + projectId = decodeURIComponent(projectId); + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/compare` + ); + + url.searchParams.append("from", from); + url.searchParams.append("to", to); + + if (straight !== undefined) { + url.searchParams.append("straight", straight.toString()); + } + + const response = await this.fetch(url.toString(), { + + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`); + } + + const data = await response.json(); + return GitLabCompareResultSchema.parse(data); + } + + /** + * Update a merge request + */ + async updateMergeRequest( + projectId: string, + options: Omit< + z.infer, + "project_id" | "merge_request_iid" | "source_branch" + >, + mergeRequestIid?: number, + branchName?: string + ): Promise { + projectId = decodeURIComponent(projectId); + if (!mergeRequestIid && !branchName) { + throw new Error("Either mergeRequestIid or branchName must be provided"); + } + + if (branchName && !mergeRequestIid) { + const mergeRequest = await this.getMergeRequest(projectId, undefined, branchName); + mergeRequestIid = mergeRequest.iid; + } + + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}` + ); + + const response = await this.fetch(url.toString(), { + + method: "PUT", + body: JSON.stringify(options), + }); + + await this.handleGitLabError(response); + return GitLabMergeRequestSchema.parse(await response.json()); + } + + /** + * Create a new note (comment) on an issue or merge request + */ + async createNote( + projectId: string, + noteableType: "issue" | "merge_request", + noteableIid: number, + body: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/${noteableType}s/${noteableIid}/notes` + ); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify({ body }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`); + } + + return await response.json(); + } + + /** + * Create a new thread on a merge request + */ + async createMergeRequestThread( + projectId: string, + mergeRequestIid: number, + body: string, + position?: MergeRequestThreadPosition, + createdAt?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( +this.getEffectiveProjectId(projectId) +)}/merge_requests/${mergeRequestIid}/discussions` + ); + + const payload: Record = { body }; + + if (position) { + payload.position = position; + } + + if (createdAt) { + payload.created_at = createdAt; + } + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify(payload), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabDiscussionSchema.parse(data); + } + + /** + * List all namespaces + */ + async listNamespaces(options: { + search?: string; + owned_only?: boolean; + top_level_only?: boolean; + }): Promise { +const url = new URL(`${config.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 this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabNamespaceSchema).parse(data); + } + + /** + * Get details on a namespace + */ + async getNamespace(id: string): Promise { + const url = new URL(`${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(id)}`); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabNamespaceSchema.parse(data); + } + + /** + * Verify if a namespace exists + */ + async verifyNamespaceExistence( + namespacePath: string, + parentId?: number + ): Promise { + const url = new URL(`${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(namespacePath)}/exists`); + + if (parentId) { + url.searchParams.append("parent_id", parentId.toString()); + } + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabNamespaceExistsResponseSchema.parse(data); + } + + /** + * Get a single project + */ + async getProject( + projectId: string, + options: { + license?: boolean; + statistics?: boolean; + with_custom_attributes?: boolean; + } = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(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 this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabRepositorySchema.parse(data); + } + + /** + * List projects + */ + async listProjects( + options: z.infer = {} + ): Promise { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(options)) { + if (value !== undefined && value !== null) { + if (typeof value === "boolean") { + params.append(key, value ? "true" : "false"); + } else { + params.append(key, String(value)); + } + } + } + + const response = await this.fetch(`${config.GITLAB_API_URL}/projects?${params.toString()}`, { + + }); + + await this.handleGitLabError(response); + + const data = await response.json(); + return z.array(GitLabProjectSchema).parse(data); + } + + /** + * List labels for a project + */ + async listLabels( + projectId: string, + options: Omit, "project_id"> = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/labels`); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (typeof value === "boolean") { + url.searchParams.append(key, value ? "true" : "false"); + } else { + url.searchParams.append(key, String(value)); + } + } + }); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + + const data = await response.json(); + return data as GitLabLabel[]; + } + + /** + * Get a single label from a project + */ + async getLabel( + projectId: string, + labelId: number | string, + includeAncestorGroups?: boolean + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/labels/${encodeURIComponent(String(labelId))}` + ); + + if (includeAncestorGroups !== undefined) { + url.searchParams.append("include_ancestor_groups", includeAncestorGroups ? "true" : "false"); + } + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + + const data = await response.json(); + return data as GitLabLabel; + } + + /** + * Create a new label in a project + */ + async createLabel( + projectId: string, + options: Omit, "project_id"> + ): Promise { + projectId = decodeURIComponent(projectId); + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/labels`, + { + + method: "POST", + body: JSON.stringify(options), + } + ); + + await this.handleGitLabError(response); + + const data = await response.json(); + return data as GitLabLabel; + } + + /** + * Update an existing label in a project + */ + async updateLabel( + projectId: string, + labelId: number | string, + options: Omit, "project_id" | "label_id"> + ): Promise { + projectId = decodeURIComponent(projectId); + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/labels/${encodeURIComponent(String(labelId))}`, + { + + method: "PUT", + body: JSON.stringify(options), + } + ); + + await this.handleGitLabError(response); + + const data = await response.json(); + return data as GitLabLabel; + } + + /** + * Delete a label from a project + */ + async deleteLabel(projectId: string, labelId: number | string): Promise { + projectId = decodeURIComponent(projectId); + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/labels/${encodeURIComponent(String(labelId))}`, + { + + method: "DELETE", + } + ); + + await this.handleGitLabError(response); + } + + /** + * List all projects in a GitLab group + */ + async listGroupProjects( + options: z.infer + ): Promise { + const url = new URL(`${config.GITLAB_API_URL}/groups/${encodeURIComponent(options.group_id)}/projects`); + + if (options.include_subgroups) url.searchParams.append("include_subgroups", "true"); + if (options.search) url.searchParams.append("search", options.search); + if (options.order_by) url.searchParams.append("order_by", options.order_by); + if (options.sort) url.searchParams.append("sort", options.sort); + if (options.page) url.searchParams.append("page", options.page.toString()); + if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); + if (options.archived !== undefined) + url.searchParams.append("archived", options.archived.toString()); + if (options.visibility) url.searchParams.append("visibility", options.visibility); + if (options.with_issues_enabled !== undefined) + url.searchParams.append("with_issues_enabled", options.with_issues_enabled.toString()); + if (options.with_merge_requests_enabled !== undefined) + url.searchParams.append( + "with_merge_requests_enabled", + options.with_merge_requests_enabled.toString() + ); + if (options.min_access_level !== undefined) + url.searchParams.append("min_access_level", options.min_access_level.toString()); + if (options.with_programming_language) + url.searchParams.append("with_programming_language", options.with_programming_language); + if (options.starred !== undefined) url.searchParams.append("starred", options.starred.toString()); + if (options.statistics !== undefined) + url.searchParams.append("statistics", options.statistics.toString()); + if (options.with_custom_attributes !== undefined) + url.searchParams.append("with_custom_attributes", options.with_custom_attributes.toString()); + if (options.with_security_reports !== undefined) + url.searchParams.append("with_security_reports", options.with_security_reports.toString()); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const projects = await response.json(); + return GitLabProjectSchema.array().parse(projects); + } + + // Wiki API helper methods + /** + * List wiki pages in a project + */ + async listWikiPages( + projectId: string, + options: Omit = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/wikis`); + if (options.page) url.searchParams.append("page", options.page.toString()); + if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); + if (options.with_content) + url.searchParams.append("with_content", options.with_content.toString()); + const response = await this.fetch(url.toString(), { + + }); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabWikiPageSchema.array().parse(data); + } + + /** + * Get a specific wiki page + */ + async getWikiPage(projectId: string, slug: string): Promise { + projectId = decodeURIComponent(projectId); + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, + {}, + ); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabWikiPageSchema.parse(data); + } + + /** + * Create a new wiki page + */ + async createWikiPage( + projectId: string, + title: string, + content: string, + format?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const body: Record = { title, content }; + if (format) body.format = format; + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/wikis`, + { + + method: "POST", + body: JSON.stringify(body), + } + ); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabWikiPageSchema.parse(data); + } + + /** + * Update an existing wiki page + */ + async updateWikiPage( + projectId: string, + slug: string, + title?: string, + content?: string, + format?: string + ): Promise { + projectId = decodeURIComponent(projectId); + const body: Record = {}; + if (title) body.title = title; + if (content) body.content = content; + if (format) body.format = format; + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, + { + + method: "PUT", + body: JSON.stringify(body), + } + ); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabWikiPageSchema.parse(data); + } + + /** + * Delete a wiki page + */ + async deleteWikiPage(projectId: string, slug: string): Promise { + projectId = decodeURIComponent(projectId); + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/wikis/${encodeURIComponent(slug)}`, + { + + method: "DELETE", + } + ); + await this.handleGitLabError(response); + } + + /** + * List pipelines in a GitLab project + */ + async listPipelines( + projectId: string, + options: Omit = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines`); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.append(key, value.toString()); + } + }); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabPipelineSchema).parse(data); + } + + /** + * Get details of a specific pipeline + */ + async getPipeline(projectId: string, pipelineId: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines/${pipelineId}` + ); + + const response = await this.fetch(url.toString(), { + + }); + + if (response.status === 404) { + throw new Error(`Pipeline not found`); + } + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); + } + + /** + * List all jobs in a specific pipeline + */ + async listPipelineJobs( + projectId: string, + pipelineId: number, + options: Omit = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/jobs` + ); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (typeof value === "boolean") { + url.searchParams.append(key, value ? "true" : "false"); + } else { + url.searchParams.append(key, value.toString()); + } + } + }); + + const response = await this.fetch(url.toString(), { + + }); + + if (response.status === 404) { + throw new Error(`Pipeline not found`); + } + + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabPipelineJobSchema).parse(data); + } + + /** + * Get a specific pipeline job + */ + async getPipelineJob(projectId: string, jobId: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/jobs/${jobId}`); + + const response = await this.fetch(url.toString(), { + + }); + + if (response.status === 404) { + throw new Error(`Job not found`); + } + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineJobSchema.parse(data); + } + + /** + * Get the output/trace of a pipeline job + */ + async getPipelineJobOutput(projectId: string, jobId: number, limit?: number, offset?: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/jobs/${jobId}/trace` + ); + + const response = await this.fetch(url.toString(), { + headers: { + Accept: "text/plain", + }, + }); + + if (response.status === 404) { + throw new Error(`Job trace not found or job is not finished yet`); + } + + await this.handleGitLabError(response); + const fullTrace = await response.text(); + + // Apply client-side pagination + if (limit !== undefined || offset !== undefined) { + const lines = fullTrace.split('\n'); + const startOffset = offset || 0; + const maxLines = limit || 1000; + + const startIndex = Math.max(0, lines.length - startOffset - maxLines); + const endIndex = lines.length - startOffset; + + const selectedLines = lines.slice(startIndex, endIndex); + const result = selectedLines.join('\n'); + + if (startIndex > 0 || endIndex < lines.length) { + const totalLines = lines.length; + const shownLines = selectedLines.length; + const skippedFromStart = startIndex; + const skippedFromEnd = startOffset; + + return `[Log truncated: showing ${shownLines} of ${totalLines} lines, skipped ${skippedFromStart} from start, ${skippedFromEnd} from end]\n\n${result}`; + } + + return result; + } + + return fullTrace; + } + + /** + * Create a new pipeline + */ + async createPipeline( + projectId: string, + ref: string, + variables?: Array<{ key: string; value: string }> + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipeline`); + + const body: any = { ref }; + if (variables && variables.length > 0) { + body.variables = variables.reduce( + (acc, { key, value }) => { + acc[key] = value; + return acc; + }, + {} as Record + ); + } + + const response = await this.fetch(url.toString(), { + method: "POST", + body: JSON.stringify(body), + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); + } + + /** + * Retry a pipeline + */ + async retryPipeline(projectId: string, pipelineId: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry` + ); + + const response = await this.fetch(url.toString(), { + method: "POST", + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); + } + + /** + * Cancel a pipeline + */ + async cancelPipeline(projectId: string, pipelineId: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel` + ); + + const response = await this.fetch(url.toString(), { + method: "POST", + }); + + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabPipelineSchema.parse(data); + } + + /** + * Get the repository tree for a project + */ + async getRepositoryTree(options: GetRepositoryTreeOptions): Promise { + options.project_id = decodeURIComponent(options.project_id); + const queryParams = new URLSearchParams(); + if (options.path) queryParams.append("path", options.path); + if (options.ref) queryParams.append("ref", options.ref); + if (options.recursive) queryParams.append("recursive", "true"); + if (options.per_page) queryParams.append("per_page", options.per_page.toString()); + if (options.page_token) queryParams.append("page_token", options.page_token); + if (options.pagination) queryParams.append("pagination", options.pagination); + + const headers: Record = { + "Content-Type": "application/json", + }; + if (config.IS_OLD) { + headers["Private-Token"] = `${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; + } else { + headers["Authorization"] = `Bearer ${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; + } + const response = await this.fetch( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + options.project_id + )}/repository/tree?${queryParams.toString()}`, + { + headers, + } + ); + + if (response.status === 404) { + throw new Error("Repository or path not found"); + } + + if (!response.ok) { + throw new Error(`Failed to get repository tree: ${response.statusText}`); + } + + const data = await response.json(); + return z.array(GitLabTreeItemSchema).parse(data); + } + + /** + * List project milestones in a GitLab project + */ + async listProjectMilestones( + projectId: string, + options: Omit, "project_id"> + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones`); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + if (key === "iids" && Array.isArray(value) && value.length > 0) { + value.forEach(iid => { + url.searchParams.append("iids[]", iid.toString()); + }); + } else if (value !== undefined) { + url.searchParams.append(key, value.toString()); + } + } + }); + + const response = await this.fetch(url.toString(), { + + }); + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabMilestonesSchema).parse(data); + } + + /** + * Get a single milestone in a GitLab project + */ + async getProjectMilestone( + projectId: string, + milestoneId: number + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}` + ); + + const response = await this.fetch(url.toString(), { + + }); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabMilestonesSchema.parse(data); + } + + /** + * Create a new milestone in a GitLab project + */ + async createProjectMilestone( + projectId: string, + options: Omit, "project_id"> + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones`); + + const response = await this.fetch(url.toString(), { + + method: "POST", + body: JSON.stringify(options), + }); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabMilestonesSchema.parse(data); + } + + /** + * Edit an existing milestone in a GitLab project + */ + async editProjectMilestone( + projectId: string, + milestoneId: number, + options: Omit, "project_id" | "milestone_id"> + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}` + ); + + const response = await this.fetch(url.toString(), { + + method: "PUT", + body: JSON.stringify(options), + }); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabMilestonesSchema.parse(data); + } + + /** + * Delete a milestone from a GitLab project + */ + async deleteProjectMilestone(projectId: string, milestoneId: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}` + ); + + const response = await this.fetch(url.toString(), { + + method: "DELETE", + }); + await this.handleGitLabError(response); + } + + /** + * Get all issues assigned to a single milestone + */ + async getMilestoneIssues(projectId: string, milestoneId: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}/issues` + ); + + const response = await this.fetch(url.toString(), { + + }); + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabIssueSchema).parse(data); + } + + /** + * Get all merge requests assigned to a single milestone + */ + async getMilestoneMergeRequests( + projectId: string, + milestoneId: number + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/milestones/${milestoneId}/merge_requests` + ); + + const response = await this.fetch(url.toString(), { + + }); + await this.handleGitLabError(response); + const data = await response.json(); + return z.array(GitLabMergeRequestSchema).parse(data); + } + + /** + * Promote a project milestone to a group milestone + */ + async promoteProjectMilestone( + projectId: string, + milestoneId: number + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}/promote` + ); + + const response = await this.fetch(url.toString(), { + + method: "POST", + }); + await this.handleGitLabError(response); + const data = await response.json(); + return GitLabMilestonesSchema.parse(data); + } + + /** + * Get all burndown chart events for a single milestone + */ + async getMilestoneBurndownEvents(projectId: string, milestoneId: number): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent( + this.getEffectiveProjectId(projectId) + )}/milestones/${milestoneId}/burndown_events` + ); + + const response = await this.fetch(url.toString(), { + + }); + await this.handleGitLabError(response); + const data = await response.json(); + return data as any[]; + } + + /** + * Get a single user from GitLab + */ + async getUser(username: string): Promise { + try { + const url = new URL(`${config.GITLAB_API_URL}/users`); + url.searchParams.append("username", username); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + + const users = await response.json(); + + if (Array.isArray(users) && users.length > 0) { + const exactMatch = users.find(user => user.username === username); + if (exactMatch) { + return GitLabUserSchema.parse(exactMatch); + } + } + + return null; + } catch (error) { + console.error(`Error fetching user by username '${username}':`, error); + return null; + } + } + + /** + * Get multiple users from GitLab + */ + async getUsers(usernames: string[]): Promise { + const users: Record = {}; + + for (const username of usernames) { + try { + const user = await this.getUser(username); + users[username] = user; + } catch (error) { + console.error(`Error processing username '${username}':`, error); + users[username] = null; + } + } + + return GitLabUsersResponseSchema.parse(users); + } + + /** + * List repository commits + */ + async listCommits( + projectId: string, + options: Omit = {} + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/commits` + ); + + if (options.ref_name) url.searchParams.append("ref_name", options.ref_name); + if (options.since) url.searchParams.append("since", options.since); + if (options.until) url.searchParams.append("until", options.until); + if (options.path) url.searchParams.append("path", options.path); + if (options.author) url.searchParams.append("author", options.author); + if (options.all) url.searchParams.append("all", options.all.toString()); + if (options.with_stats) url.searchParams.append("with_stats", options.with_stats.toString()); + if (options.first_parent) url.searchParams.append("first_parent", options.first_parent.toString()); + if (options.order) url.searchParams.append("order", options.order); + if (options.trailers) url.searchParams.append("trailers", options.trailers.toString()); + if (options.page) url.searchParams.append("page", options.page.toString()); + if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + + const data = await response.json(); + return z.array(GitLabCommitSchema).parse(data); + } + + /** + * Get a single commit + */ + async getCommit( + projectId: string, + sha: string, + stats?: boolean + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}` + ); + + if (stats) { + url.searchParams.append("stats", "true"); + } + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + + const data = await response.json(); + return GitLabCommitSchema.parse(data); + } + + /** + * Get commit diff + */ + async getCommitDiff( + projectId: string, + sha: string + ): Promise { + projectId = decodeURIComponent(projectId); + const url = new URL( + `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}/diff` + ); + + const response = await this.fetch(url.toString(), { + + }); + + await this.handleGitLabError(response); + + const data = await response.json(); + return z.array(GitLabDiffSchema).parse(data); + } +} diff --git a/src/gitlabsession.ts b/src/gitlabsession.ts new file mode 100644 index 0000000..f94fcce --- /dev/null +++ b/src/gitlabsession.ts @@ -0,0 +1,115 @@ +import { Request, Response, RequestInit } from "node-fetch"; +import { config } from "./config.js"; + +import nodeFetch from "node-fetch"; + +import { SocksProxyAgent } from "socks-proxy-agent"; +import { HttpsProxyAgent } from "https-proxy-agent"; +import { HttpProxyAgent } from "http-proxy-agent"; +import { Agent } from "http"; +import { Agent as HttpsAgent } from "https"; +import { readFileSync } from "fs"; +import { CookieJar } from "tough-cookie"; +import fetchCookie from "fetch-cookie"; + +export class GitlabSession { + + private defaultConfig: RequestInit + private defaultHeaders: Record + + constructor(private readonly cookieJar?: CookieJar) { + // Configure proxy agents if proxies are set + let httpAgent: Agent | undefined = undefined; + let httpsAgent: Agent | undefined = undefined; + + let sslOptions = undefined; + if (config.NODE_TLS_REJECT_UNAUTHORIZED === "0") { + sslOptions = { rejectUnauthorized: false }; + } else if (config.GITLAB_CA_CERT_PATH) { + const ca = readFileSync(config.GITLAB_CA_CERT_PATH); + sslOptions = { ca }; + } + + if (config.HTTP_PROXY) { + if (config.HTTP_PROXY.startsWith("socks")) { + httpAgent = new SocksProxyAgent(config.HTTP_PROXY); + } else { + httpAgent = new HttpProxyAgent(config.HTTP_PROXY); + } + } + if (config.HTTPS_PROXY) { + if (config.HTTPS_PROXY.startsWith("socks")) { + httpsAgent = new SocksProxyAgent(config.HTTPS_PROXY); + } else { + httpsAgent = new HttpsProxyAgent(config.HTTPS_PROXY, sslOptions); + } + } + httpsAgent = httpsAgent || new HttpsAgent(sslOptions); + httpAgent = httpAgent || new Agent(); + this.defaultHeaders = { + Accept: "application/json", + "Content-Type": "application/json", + }; + + if (config.IS_OLD) { + this.defaultHeaders["Private-Token"] = `${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; + } else { + this.defaultHeaders["Authorization"] = `Bearer ${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; + } + + this.defaultConfig = { + headers: this.defaultHeaders, + agent: (parsedUrl: URL) => { + if (parsedUrl.protocol === "https:") { + return httpsAgent; + } + return httpAgent; + }, + } + + } + + async ensureSessionForCookieJar(): Promise { + if (!this.cookieJar || !config.GITLAB_AUTH_COOKIE_PATH) return; + + // Extract the base URL from GITLAB_API_URL + const apiUrl = new URL(config.GITLAB_API_URL); + const baseUrl = `${apiUrl.protocol}//${apiUrl.hostname}`; + + // Check if we already have GitLab session cookies + const gitlabCookies = this.cookieJar.getCookiesSync(baseUrl); + const hasSessionCookie = gitlabCookies.some(cookie => + cookie.key === '_gitlab_session' || cookie.key === 'remember_user_token' + ); + + if (!hasSessionCookie) { + try { + // Establish session with a lightweight request + await fetch(`${config.GITLAB_API_URL}/user`, { + redirect: 'follow' + }).catch(() => { + // Ignore errors - the important thing is that cookies get set during redirects + }); + + // Small delay to ensure cookies are fully processed + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (error) { + // Ignore session establishment errors + } + } + } + + + async fetch(url: URL | string | Request, init?: RequestInit): Promise { + const fullInit = {...this.defaultConfig, ...init, headers: { + ...this.defaultHeaders, + ...init?.headers + }} + let fetcher = nodeFetch; + if(this.cookieJar) { + fetcher = fetchCookie(fetcher, this.cookieJar); + } + return fetcher(url, fullInit); + } + +} diff --git a/src/mcpserver.ts b/src/mcpserver.ts new file mode 100644 index 0000000..9ef2e9a --- /dev/null +++ b/src/mcpserver.ts @@ -0,0 +1,1469 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { fileURLToPath } from "url"; +import { dirname } from "path"; +import fs from "fs"; +import path from "path"; + +import {config} from "./config.js" +import { GitlabHandler } from "./gitlabhandler.js" + +import { + ForkRepositorySchema, + CreateBranchSchema, + CreateOrUpdateFileSchema, + SearchRepositoriesSchema, + CreateRepositorySchema, + GetFileContentsSchema, + PushFilesSchema, + CreateIssueSchema, + CreateMergeRequestSchema, + GetMergeRequestSchema, + GetMergeRequestDiffsSchema, + UpdateMergeRequestSchema, + ListIssuesSchema, + GetIssueSchema, + UpdateIssueSchema, + DeleteIssueSchema, + ListIssueLinksSchema, + ListIssueDiscussionsSchema, + GetIssueLinkSchema, + CreateIssueLinkSchema, + DeleteIssueLinkSchema, + ListNamespacesSchema, + GetNamespaceSchema, + VerifyNamespaceSchema, + GetProjectSchema, + ListProjectsSchema, + ListLabelsSchema, + GetLabelSchema, + CreateLabelSchema, + UpdateLabelSchema, + DeleteLabelSchema, + CreateNoteSchema, + CreateMergeRequestThreadSchema, + ListGroupProjectsSchema, + ListWikiPagesSchema, + GetWikiPageSchema, + CreateWikiPageSchema, + UpdateWikiPageSchema, + DeleteWikiPageSchema, + GetRepositoryTreeSchema, + GetPipelineSchema, + ListPipelinesSchema, + ListPipelineJobsSchema, + CreatePipelineSchema, + RetryPipelineSchema, + CancelPipelineSchema, + GetPipelineJobOutputSchema, + UpdateMergeRequestNoteSchema, + CreateMergeRequestNoteSchema, + ListMergeRequestDiscussionsSchema, + UpdateIssueNoteSchema, + CreateIssueNoteSchema, + ListMergeRequestsSchema, + ListProjectMilestonesSchema, + GetProjectMilestoneSchema, + CreateProjectMilestoneSchema, + EditProjectMilestoneSchema, + DeleteProjectMilestoneSchema, + GetMilestoneIssuesSchema, + GetMilestoneMergeRequestsSchema, + PromoteProjectMilestoneSchema, + GetMilestoneBurndownEventsSchema, + GetBranchDiffsSchema, + GetUsersSchema, + ListCommitsSchema, + GetCommitSchema, + GetCommitDiffSchema, + ListMergeRequestDiffsSchema, +} from "./schemas.js"; +import { createCookieJar } from "./authhelpers.js"; +import { CookieJar } from "tough-cookie"; + +/** + * Read version from package.json + */ +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageJsonPath = path.resolve(__dirname, "../package.json"); +let SERVER_VERSION = "unknown"; +try { + if (fs.existsSync(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + SERVER_VERSION = packageJson.version || SERVER_VERSION; + } +} catch (error) { + // Warning: Could not read version from package.json - silently continue +} + +// create the underlying mcp server +const server = new Server( + { + name: "better-gitlab-mcp-server", + version: SERVER_VERSION, + }, + { + capabilities: { + tools: {}, + }, + } +); + + + +// Define all available tools +const allTools = [ + { + name: "create_or_update_file", + description: "Create or update a single file in a GitLab project", + inputSchema: zodToJsonSchema(CreateOrUpdateFileSchema), + }, + { + name: "search_repositories", + description: "Search for GitLab projects", + inputSchema: zodToJsonSchema(SearchRepositoriesSchema), + }, + { + name: "create_repository", + description: "Create a new GitLab project", + inputSchema: zodToJsonSchema(CreateRepositorySchema), + }, + { + name: "get_file_contents", + description: "Get the contents of a file or directory from a GitLab project", + inputSchema: zodToJsonSchema(GetFileContentsSchema), + }, + { + name: "push_files", + description: "Push multiple files to a GitLab project in a single commit", + inputSchema: zodToJsonSchema(PushFilesSchema), + }, + { + name: "create_issue", + description: "Create a new issue in a GitLab project", + inputSchema: zodToJsonSchema(CreateIssueSchema), + }, + { + name: "create_merge_request", + description: "Create a new merge request in a GitLab project", + inputSchema: zodToJsonSchema(CreateMergeRequestSchema), + }, + { + name: "fork_repository", + description: "Fork a GitLab project to your account or specified namespace", + inputSchema: zodToJsonSchema(ForkRepositorySchema), + }, + { + name: "create_branch", + description: "Create a new branch in a GitLab project", + inputSchema: zodToJsonSchema(CreateBranchSchema), + }, + { + name: "get_merge_request", + description: + "Get details of a merge request (Either mergeRequestIid or branchName must be provided)", + inputSchema: zodToJsonSchema(GetMergeRequestSchema), + }, + { + name: "get_merge_request_diffs", + description: + "Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided)", + inputSchema: zodToJsonSchema(GetMergeRequestDiffsSchema), + }, + { + name: "list_merge_request_diffs", + description: + "List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided)", + inputSchema: zodToJsonSchema(ListMergeRequestDiffsSchema), + }, + { + name: "get_branch_diffs", + description: "Get the changes/diffs between two branches or commits in a GitLab project", + inputSchema: zodToJsonSchema(GetBranchDiffsSchema), + }, + { + name: "update_merge_request", + description: "Update a merge request (Either mergeRequestIid or branchName must be provided)", + inputSchema: zodToJsonSchema(UpdateMergeRequestSchema), + }, + { + name: "create_note", + description: "Create a new note (comment) to an issue or merge request", + inputSchema: zodToJsonSchema(CreateNoteSchema), + }, + { + name: "create_merge_request_thread", + description: "Create a new thread on a merge request", + inputSchema: zodToJsonSchema(CreateMergeRequestThreadSchema), + }, + { + name: "mr_discussions", + description: "List discussion items for a merge request", + inputSchema: zodToJsonSchema(ListMergeRequestDiscussionsSchema), + }, + { + name: "update_merge_request_note", + description: "Modify an existing merge request thread note", + inputSchema: zodToJsonSchema(UpdateMergeRequestNoteSchema), + }, + { + name: "create_merge_request_note", + description: "Add a new note to an existing merge request thread", + inputSchema: zodToJsonSchema(CreateMergeRequestNoteSchema), + }, + { + name: "update_issue_note", + description: "Modify an existing issue thread note", + inputSchema: zodToJsonSchema(UpdateIssueNoteSchema), + }, + { + name: "create_issue_note", + description: "Add a new note to an existing issue thread", + inputSchema: zodToJsonSchema(CreateIssueNoteSchema), + }, + { + name: "list_issues", + description: "List issues in a GitLab project with filtering options", + inputSchema: zodToJsonSchema(ListIssuesSchema), + }, + { + name: "get_issue", + description: "Get details of a specific issue in a GitLab project", + inputSchema: zodToJsonSchema(GetIssueSchema), + }, + { + name: "update_issue", + description: "Update an issue in a GitLab project", + inputSchema: zodToJsonSchema(UpdateIssueSchema), + }, + { + name: "delete_issue", + description: "Delete an issue from a GitLab project", + inputSchema: zodToJsonSchema(DeleteIssueSchema), + }, + { + name: "list_issue_links", + description: "List all issue links for a specific issue", + inputSchema: zodToJsonSchema(ListIssueLinksSchema), + }, + { + name: "list_issue_discussions", + description: "List discussions for an issue in a GitLab project", + inputSchema: zodToJsonSchema(ListIssueDiscussionsSchema), + }, + { + name: "get_issue_link", + description: "Get a specific issue link", + inputSchema: zodToJsonSchema(GetIssueLinkSchema), + }, + { + name: "create_issue_link", + description: "Create an issue link between two issues", + inputSchema: zodToJsonSchema(CreateIssueLinkSchema), + }, + { + name: "delete_issue_link", + 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), + }, + { + name: "list_labels", + description: "List labels for a project", + inputSchema: zodToJsonSchema(ListLabelsSchema), + }, + { + name: "get_label", + description: "Get a single label from a project", + inputSchema: zodToJsonSchema(GetLabelSchema), + }, + { + name: "create_label", + description: "Create a new label in a project", + inputSchema: zodToJsonSchema(CreateLabelSchema), + }, + { + name: "update_label", + description: "Update an existing label in a project", + inputSchema: zodToJsonSchema(UpdateLabelSchema), + }, + { + name: "delete_label", + description: "Delete a label from a project", + inputSchema: zodToJsonSchema(DeleteLabelSchema), + }, + { + name: "list_group_projects", + description: "List projects in a GitLab group with filtering options", + inputSchema: zodToJsonSchema(ListGroupProjectsSchema), + }, + { + name: "list_wiki_pages", + description: "List wiki pages in a GitLab project", + inputSchema: zodToJsonSchema(ListWikiPagesSchema), + }, + { + name: "get_wiki_page", + description: "Get details of a specific wiki page", + inputSchema: zodToJsonSchema(GetWikiPageSchema), + }, + { + name: "create_wiki_page", + description: "Create a new wiki page in a GitLab project", + inputSchema: zodToJsonSchema(CreateWikiPageSchema), + }, + { + name: "update_wiki_page", + description: "Update an existing wiki page in a GitLab project", + inputSchema: zodToJsonSchema(UpdateWikiPageSchema), + }, + { + name: "delete_wiki_page", + description: "Delete a wiki page from a GitLab project", + inputSchema: zodToJsonSchema(DeleteWikiPageSchema), + }, + { + name: "get_repository_tree", + description: "Get the repository tree for a GitLab project (list files and directories)", + inputSchema: zodToJsonSchema(GetRepositoryTreeSchema), + }, + { + name: "list_pipelines", + description: "List pipelines in a GitLab project with filtering options", + inputSchema: zodToJsonSchema(ListPipelinesSchema), + }, + { + name: "get_pipeline", + description: "Get details of a specific pipeline in a GitLab project", + inputSchema: zodToJsonSchema(GetPipelineSchema), + }, + { + name: "list_pipeline_jobs", + description: "List all jobs in a specific pipeline", + inputSchema: zodToJsonSchema(ListPipelineJobsSchema), + }, + { + name: "get_pipeline_job", + description: "Get details of a GitLab pipeline job number", + inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), + }, + { + name: "get_pipeline_job_output", + description: "Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage", + inputSchema: zodToJsonSchema(GetPipelineJobOutputSchema), + }, + { + name: "create_pipeline", + description: "Create a new pipeline for a branch or tag", + inputSchema: zodToJsonSchema(CreatePipelineSchema), + }, + { + name: "retry_pipeline", + description: "Retry a failed or canceled pipeline", + inputSchema: zodToJsonSchema(RetryPipelineSchema), + }, + { + name: "cancel_pipeline", + description: "Cancel a running pipeline", + inputSchema: zodToJsonSchema(CancelPipelineSchema), + }, + { + name: "list_merge_requests", + description: "List merge requests in a GitLab project with filtering options", + inputSchema: zodToJsonSchema(ListMergeRequestsSchema), + }, + { + name: "list_milestones", + description: "List milestones in a GitLab project with filtering options", + inputSchema: zodToJsonSchema(ListProjectMilestonesSchema), + }, + { + name: "get_milestone", + description: "Get details of a specific milestone", + inputSchema: zodToJsonSchema(GetProjectMilestoneSchema), + }, + { + name: "create_milestone", + description: "Create a new milestone in a GitLab project", + inputSchema: zodToJsonSchema(CreateProjectMilestoneSchema), + }, + { + name: "edit_milestone", + description: "Edit an existing milestone in a GitLab project", + inputSchema: zodToJsonSchema(EditProjectMilestoneSchema), + }, + { + name: "delete_milestone", + description: "Delete a milestone from a GitLab project", + inputSchema: zodToJsonSchema(DeleteProjectMilestoneSchema), + }, + { + name: "get_milestone_issue", + description: "Get issues associated with a specific milestone", + inputSchema: zodToJsonSchema(GetMilestoneIssuesSchema), + }, + { + name: "get_milestone_merge_requests", + description: "Get merge requests associated with a specific milestone", + inputSchema: zodToJsonSchema(GetMilestoneMergeRequestsSchema), + }, + { + name: "promote_milestone", + description: "Promote a milestone to the next stage", + inputSchema: zodToJsonSchema(PromoteProjectMilestoneSchema), + }, + { + name: "get_milestone_burndown_events", + description: "Get burndown events for a specific milestone", + inputSchema: zodToJsonSchema(GetMilestoneBurndownEventsSchema), + }, + { + name: "get_users", + description: "Get GitLab user details by usernames", + inputSchema: zodToJsonSchema(GetUsersSchema), + }, + { + name: "list_commits", + description: "List repository commits with filtering options", + inputSchema: zodToJsonSchema(ListCommitsSchema), + }, + { + name: "get_commit", + description: "Get details of a specific commit", + inputSchema: zodToJsonSchema(GetCommitSchema), + }, + { + name: "get_commit_diff", + description: "Get changes/diffs of a specific commit", + inputSchema: zodToJsonSchema(GetCommitDiffSchema), + }, +]; + +// Define which tools are read-only +const readOnlyTools = [ + "search_repositories", + "get_file_contents", + "get_merge_request", + "get_merge_request_diffs", + "get_branch_diffs", + "mr_discussions", + "list_issues", + "list_merge_requests", + "get_issue", + "list_issue_links", + "list_issue_discussions", + "get_issue_link", + "list_namespaces", + "get_namespace", + "verify_namespace", + "get_project", + "get_pipeline", + "list_pipelines", + "list_pipeline_jobs", + "get_pipeline_job", + "get_pipeline_job_output", + "list_projects", + "list_labels", + "get_label", + "list_group_projects", + "get_repository_tree", + "list_milestones", + "get_milestone", + "get_milestone_issue", + "get_milestone_merge_requests", + "get_milestone_burndown_events", + "list_wiki_pages", + "get_wiki_page", + "get_users", + "list_commits", + "get_commit", + "get_commit_diff", +]; + +// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI +const wikiToolNames = [ + "list_wiki_pages", + "get_wiki_page", + "create_wiki_page", + "update_wiki_page", + "delete_wiki_page", + "upload_wiki_attachment", +]; + +// Define which tools are related to milestones and can be toggled by USE_MILESTONE +const milestoneToolNames = [ + "list_milestones", + "get_milestone", + "create_milestone", + "edit_milestone", + "delete_milestone", + "get_milestone_issue", + "get_milestone_merge_requests", + "promote_milestone", + "get_milestone_burndown_events", +]; + +// Define which tools are related to pipelines and can be toggled by USE_PIPELINE +const pipelineToolNames = [ + "list_pipelines", + "get_pipeline", + "list_pipeline_jobs", + "get_pipeline_job", + "get_pipeline_job_output", + "create_pipeline", + "retry_pipeline", + "cancel_pipeline", +]; + + +server.setRequestHandler(ListToolsRequestSchema, async () => { + // Apply read-only filter first + const tools0 = config.GITLAB_READ_ONLY_MODE + ? allTools.filter(tool => readOnlyTools.includes(tool.name)) + : allTools; + // Toggle wiki tools by USE_GITLAB_WIKI flag + const tools1 = config.USE_GITLAB_WIKI + ? tools0 + : tools0.filter(tool => !wikiToolNames.includes(tool.name)); + // Toggle milestone tools by USE_MILESTONE flag + const tools2 = config.USE_MILESTONE + ? tools1 + : tools1.filter(tool => !milestoneToolNames.includes(tool.name)); + // Toggle pipeline tools by USE_PIPELINE flag + let tools = config.USE_PIPELINE ? tools2 : tools2.filter(tool => !pipelineToolNames.includes(tool.name)); + + // <<< START: Gemini 호환성을 위해 $schema 제거 >>> + tools = tools.map(tool => { + // inputSchema가 존재하고 객체인지 확인 + if (tool.inputSchema && typeof tool.inputSchema === "object" && tool.inputSchema !== null) { + // $schema 키가 존재하면 삭제 + if ("$schema" in tool.inputSchema) { + // 불변성을 위해 새로운 객체 생성 (선택적이지만 권장) + const modifiedSchema = { ...tool.inputSchema }; + delete modifiedSchema.$schema; + return { ...tool, inputSchema: modifiedSchema }; + } + } + // 변경이 필요 없으면 그대로 반환 + return tool; + }); + // <<< END: Gemini 호환성을 위해 $schema 제거 >>> + + return { + tools, // $schema가 제거된 도구 목록 반환 + }; +}); + +const globalCookieJar = createCookieJar(); + +server.setRequestHandler(CallToolRequestSchema, async request => { + try { + if (!request.params.arguments) { + throw new Error("Arguments are required"); + } + + let cookieJar: CookieJar | undefined = undefined; + if(config.GITLAB_AUTH_COOKIE_PATH) { + cookieJar = globalCookieJar; + } + + // Create GitlabSession instance + const gitlabSession = new GitlabHandler(cookieJar); + await gitlabSession.ensureSessionForCookieJar(); + + switch (request.params.name) { + case "fork_repository": { + if (config.GITLAB_PROJECT_ID) { + throw new Error("Direct project ID is set. So fork_repository is not allowed"); + } + const forkArgs = ForkRepositorySchema.parse(request.params.arguments); + try { + const forkedProject = await gitlabSession.forkProject(forkArgs.project_id, forkArgs.namespace); + return { + content: [{ type: "text", text: JSON.stringify(forkedProject, null, 2) }], + }; + } catch (forkError) { + console.error("Error forking repository:", forkError); + let forkErrorMessage = "Failed to fork repository"; + if (forkError instanceof Error) { + forkErrorMessage = `${forkErrorMessage}: ${forkError.message}`; + } + return { + content: [ + { + type: "text", + text: JSON.stringify({ error: forkErrorMessage }, null, 2), + }, + ], + }; + } + } + + case "create_branch": { + const args = CreateBranchSchema.parse(request.params.arguments); + let ref = args.ref; + if (!ref) { + ref = await gitlabSession.getDefaultBranchRef(args.project_id); + } + + const branch = await gitlabSession.createBranch(args.project_id, { + name: args.branch, + ref, + }); + + return { + content: [{ type: "text", text: JSON.stringify(branch, null, 2) }], + }; + } + + case "get_branch_diffs": { + const args = GetBranchDiffsSchema.parse(request.params.arguments); + const diffResp = await gitlabSession.getBranchDiffs(args.project_id, args.from, args.to, args.straight); + + if (args.excluded_file_patterns?.length) { + const regexPatterns = args.excluded_file_patterns.map(pattern => new RegExp(pattern)); + + // Helper function to check if a path matches any regex pattern + const matchesAnyPattern = (path: string): boolean => { + if (!path) return false; + return regexPatterns.some(regex => regex.test(path)); + }; + + // Filter out files that match any of the regex patterns on new files + diffResp.diffs = diffResp.diffs.filter(diff => !matchesAnyPattern(diff.new_path)); + } + return { + content: [{ type: "text", text: JSON.stringify(diffResp, null, 2) }], + }; + } + + case "search_repositories": { + const args = SearchRepositoriesSchema.parse(request.params.arguments); + const results = await gitlabSession.searchProjects(args.search, args.page, args.per_page); + return { + content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + }; + } + + case "create_repository": { + if (config.GITLAB_PROJECT_ID) { + throw new Error("Direct project ID is set. So fork_repository is not allowed"); + } + const args = CreateRepositorySchema.parse(request.params.arguments); + const repository = await gitlabSession.createRepository(args); + return { + content: [{ type: "text", text: JSON.stringify(repository, null, 2) }], + }; + } + + case "get_file_contents": { + const args = GetFileContentsSchema.parse(request.params.arguments); + const contents = await gitlabSession.getFileContents(args.project_id, args.file_path, args.ref); + return { + content: [{ type: "text", text: JSON.stringify(contents, null, 2) }], + }; + } + + case "create_or_update_file": { + const args = CreateOrUpdateFileSchema.parse(request.params.arguments); + const result = await gitlabSession.createOrUpdateFile( + args.project_id, + args.file_path, + args.content, + args.commit_message, + args.branch, + args.previous_path, + args.last_commit_id, + args.commit_id + ); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + + case "push_files": { + const args = PushFilesSchema.parse(request.params.arguments); + const result = await gitlabSession.createCommit( + args.project_id, + args.commit_message, + args.branch, + args.files.map(f => ({ path: f.file_path, content: f.content })) + ); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + + case "create_issue": { + const args = CreateIssueSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const issue = await gitlabSession.createIssue(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], + }; + } + + case "create_merge_request": { + const args = CreateMergeRequestSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const mergeRequest = await gitlabSession.createMergeRequest(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], + }; + } + + case "update_merge_request_note": { + const args = UpdateMergeRequestNoteSchema.parse(request.params.arguments); + const note = await gitlabSession.updateMergeRequestNote( + args.project_id, + args.merge_request_iid, + args.discussion_id, + args.note_id, + args.body, // Now optional + args.resolved // Now one of body or resolved must be provided, not both + ); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "create_merge_request_note": { + const args = CreateMergeRequestNoteSchema.parse(request.params.arguments); + const note = await gitlabSession.createMergeRequestNote( + args.project_id, + args.merge_request_iid, + args.discussion_id, + args.body, + args.created_at + ); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "update_issue_note": { + const args = UpdateIssueNoteSchema.parse(request.params.arguments); + const note = await gitlabSession.updateIssueNote( + args.project_id, + args.issue_iid, + args.discussion_id, + args.note_id, + args.body + ); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "create_issue_note": { + const args = CreateIssueNoteSchema.parse(request.params.arguments); + const note = await gitlabSession.createIssueNote( + args.project_id, + args.issue_iid, + args.discussion_id, + args.body, + args.created_at + ); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "get_merge_request": { + const args = GetMergeRequestSchema.parse(request.params.arguments); + const mergeRequest = await gitlabSession.getMergeRequest( + args.project_id, + args.merge_request_iid, + args.source_branch + ); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], + }; + } + + case "get_merge_request_diffs": { + const args = GetMergeRequestDiffsSchema.parse(request.params.arguments); + const diffs = await gitlabSession.getMergeRequestDiffs( + args.project_id, + args.merge_request_iid, + args.source_branch, + args.view + ); + return { + content: [{ type: "text", text: JSON.stringify(diffs, null, 2) }], + }; + } + + case "list_merge_request_diffs": { + const args = ListMergeRequestDiffsSchema.parse(request.params.arguments); + const changes = await gitlabSession.listMergeRequestDiffs( + args.project_id, + args.merge_request_iid, + args.source_branch, + args.page, + args.per_page, + args.unidiff + ); + return { + content: [{ type: "text", text: JSON.stringify(changes, null, 2) }], + }; + } + + case "update_merge_request": { + const args = UpdateMergeRequestSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, source_branch, ...options } = args; + const mergeRequest = await gitlabSession.updateMergeRequest( + project_id, + options, + merge_request_iid, + source_branch + ); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequest, null, 2) }], + }; + } + + case "mr_discussions": { + const args = ListMergeRequestDiscussionsSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, ...options } = args; + const discussions = await gitlabSession.listMergeRequestDiscussions( + project_id, + merge_request_iid, + options + ); + return { + content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], + }; + } + + case "list_namespaces": { + const args = ListNamespacesSchema.parse(request.params.arguments); + const namespaces = await gitlabSession.listNamespaces({ + search: args.search, + owned_only: args.owned, + top_level_only: undefined + }); + + return { + content: [{ type: "text", text: JSON.stringify(namespaces, null, 2) }], + }; + } + + case "get_namespace": { + const args = GetNamespaceSchema.parse(request.params.arguments); + const namespace = await gitlabSession.getNamespace(args.namespace_id); + + return { + content: [{ type: "text", text: JSON.stringify(namespace, null, 2) }], + }; + } + + case "verify_namespace": { + const args = VerifyNamespaceSchema.parse(request.params.arguments); + const namespaceExists = await gitlabSession.verifyNamespaceExistence(args.path); + + return { + content: [{ type: "text", text: JSON.stringify(namespaceExists, null, 2) }], + }; + } + + case "get_project": { + const args = GetProjectSchema.parse(request.params.arguments); + const project = await gitlabSession.getProject(args.project_id); + + return { + content: [{ type: "text", text: JSON.stringify(project, null, 2) }], + }; + } + + case "list_projects": { + const args = ListProjectsSchema.parse(request.params.arguments); + const projects = await gitlabSession.listProjects(args); + + return { + content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], + }; + } + + case "get_users": { + const args = GetUsersSchema.parse(request.params.arguments); + const usersMap = await gitlabSession.getUsers(args.usernames); + + return { + content: [{ type: "text", text: JSON.stringify(usersMap, null, 2) }], + }; + } + + case "create_note": { + const args = CreateNoteSchema.parse(request.params.arguments); + const { project_id, noteable_type, noteable_iid, body } = args; + + const note = await gitlabSession.createNote(project_id, noteable_type, noteable_iid, body); + return { + content: [{ type: "text", text: JSON.stringify(note, null, 2) }], + }; + } + + case "create_merge_request_thread": { + const args = CreateMergeRequestThreadSchema.parse(request.params.arguments); + const { project_id, merge_request_iid, body, position, created_at } = args; + + const thread = await gitlabSession.createMergeRequestThread( + project_id, + merge_request_iid, + body, + position, + created_at + ); + return { + content: [{ type: "text", text: JSON.stringify(thread, null, 2) }], + }; + } + + case "list_issues": { + const args = ListIssuesSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const issues = await gitlabSession.listIssues(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(issues, null, 2) }], + }; + } + + case "get_issue": { + const args = GetIssueSchema.parse(request.params.arguments); + const issue = await gitlabSession.getIssue(args.project_id, args.issue_iid); + return { + content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], + }; + } + + case "update_issue": { + const args = UpdateIssueSchema.parse(request.params.arguments); + const { project_id, issue_iid, ...options } = args; + const issue = await gitlabSession.updateIssue(project_id, issue_iid, options); + return { + content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], + }; + } + + case "delete_issue": { + const args = DeleteIssueSchema.parse(request.params.arguments); + await gitlabSession.deleteIssue(args.project_id, args.issue_iid); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { status: "success", message: "Issue deleted successfully" }, + null, + 2 + ), + }, + ], + }; + } + + case "list_issue_links": { + const args = ListIssueLinksSchema.parse(request.params.arguments); + const links = await gitlabSession.listIssueLinks(args.project_id, args.issue_iid); + return { + content: [{ type: "text", text: JSON.stringify(links, null, 2) }], + }; + } + + case "list_issue_discussions": { + const args = ListIssueDiscussionsSchema.parse(request.params.arguments); + const { project_id, issue_iid, ...options } = args; + + const discussions = await gitlabSession.listIssueDiscussions(project_id, issue_iid, options); + return { + content: [{ type: "text", text: JSON.stringify(discussions, null, 2) }], + }; + } + + case "get_issue_link": { + const args = GetIssueLinkSchema.parse(request.params.arguments); + const link = await gitlabSession.getIssueLink(args.project_id, args.issue_iid, args.issue_link_id); + return { + content: [{ type: "text", text: JSON.stringify(link, null, 2) }], + }; + } + + case "create_issue_link": { + const args = CreateIssueLinkSchema.parse(request.params.arguments); + const link = await gitlabSession.createIssueLink( + args.project_id, + args.issue_iid, + args.target_project_id, + args.target_issue_iid, + args.link_type + ); + return { + content: [{ type: "text", text: JSON.stringify(link, null, 2) }], + }; + } + + case "delete_issue_link": { + const args = DeleteIssueLinkSchema.parse(request.params.arguments); + await gitlabSession.deleteIssueLink(args.project_id, args.issue_iid, args.issue_link_id); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + status: "success", + message: "Issue link deleted successfully", + }, + null, + 2 + ), + }, + ], + }; + } + + case "list_labels": { + const args = ListLabelsSchema.parse(request.params.arguments); + const labels = await gitlabSession.listLabels(args.project_id, args); + return { + content: [{ type: "text", text: JSON.stringify(labels, null, 2) }], + }; + } + + case "get_label": { + const args = GetLabelSchema.parse(request.params.arguments); + const label = await gitlabSession.getLabel(args.project_id, args.label_id, args.include_ancestor_groups); + return { + content: [{ type: "text", text: JSON.stringify(label, null, 2) }], + }; + } + + case "create_label": { + const args = CreateLabelSchema.parse(request.params.arguments); + const label = await gitlabSession.createLabel(args.project_id, args); + return { + content: [{ type: "text", text: JSON.stringify(label, null, 2) }], + }; + } + + case "update_label": { + const args = UpdateLabelSchema.parse(request.params.arguments); + const { project_id, label_id, ...options } = args; + const label = await gitlabSession.updateLabel(project_id, label_id, options); + return { + content: [{ type: "text", text: JSON.stringify(label, null, 2) }], + }; + } + + case "delete_label": { + const args = DeleteLabelSchema.parse(request.params.arguments); + await gitlabSession.deleteLabel(args.project_id, args.label_id); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { status: "success", message: "Label deleted successfully" }, + null, + 2 + ), + }, + ], + }; + } + + case "list_group_projects": { + const args = ListGroupProjectsSchema.parse(request.params.arguments); + const projects = await gitlabSession.listGroupProjects(args); + return { + content: [{ type: "text", text: JSON.stringify(projects, null, 2) }], + }; + } + + case "list_wiki_pages": { + const { project_id, page, per_page, with_content } = ListWikiPagesSchema.parse( + request.params.arguments + ); + const wikiPages = await gitlabSession.listWikiPages(project_id, { page, per_page, with_content }); + return { + content: [{ type: "text", text: JSON.stringify(wikiPages, null, 2) }], + }; + } + + case "get_wiki_page": { + const { project_id, slug } = GetWikiPageSchema.parse(request.params.arguments); + const wikiPage = await gitlabSession.getWikiPage(project_id, slug); + return { + content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], + }; + } + + case "create_wiki_page": { + const { project_id, title, content, format } = CreateWikiPageSchema.parse( + request.params.arguments + ); + const wikiPage = await gitlabSession.createWikiPage(project_id, title, content, format); + return { + content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], + }; + } + + case "update_wiki_page": { + const { project_id, slug, title, content, format } = UpdateWikiPageSchema.parse( + request.params.arguments + ); + const wikiPage = await gitlabSession.updateWikiPage(project_id, slug, title, content, format); + return { + content: [{ type: "text", text: JSON.stringify(wikiPage, null, 2) }], + }; + } + + case "delete_wiki_page": { + const { project_id, slug } = DeleteWikiPageSchema.parse(request.params.arguments); + await gitlabSession.deleteWikiPage(project_id, slug); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + status: "success", + message: "Wiki page deleted successfully", + }, + null, + 2 + ), + }, + ], + }; + } + + case "get_repository_tree": { + const args = GetRepositoryTreeSchema.parse(request.params.arguments); + const tree = await gitlabSession.getRepositoryTree(args); + return { + content: [{ type: "text", text: JSON.stringify(tree, null, 2) }], + }; + } + + case "list_pipelines": { + const args = ListPipelinesSchema.parse(request.params.arguments); + const { project_id, ...options } = args; + const pipelines = await gitlabSession.listPipelines(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(pipelines, null, 2) }], + }; + } + + case "get_pipeline": { + const { project_id, pipeline_id } = GetPipelineSchema.parse(request.params.arguments); + const pipeline = await gitlabSession.getPipeline(project_id, pipeline_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(pipeline, null, 2), + }, + ], + }; + } + + case "list_pipeline_jobs": { + const { project_id, pipeline_id, ...options } = ListPipelineJobsSchema.parse( + request.params.arguments + ); + const jobs = await gitlabSession.listPipelineJobs(project_id, pipeline_id, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(jobs, null, 2), + }, + ], + }; + } + + case "get_pipeline_job": { + const { project_id, job_id } = GetPipelineJobOutputSchema.parse(request.params.arguments); + const jobDetails = await gitlabSession.getPipelineJob(project_id, job_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(jobDetails, null, 2), + }, + ], + }; + } + + case "get_pipeline_job_output": { + const { project_id, job_id, limit, offset } = GetPipelineJobOutputSchema.parse(request.params.arguments); + const jobOutput = await gitlabSession.getPipelineJobOutput(project_id, job_id, limit, offset); + return { + content: [ + { + type: "text", + text: jobOutput, + }, + ], + }; + } + + case "create_pipeline": { + const { project_id, ref, variables } = CreatePipelineSchema.parse(request.params.arguments); + const pipeline = await gitlabSession.createPipeline(project_id, ref, variables); + return { + content: [ + { + type: "text", + text: `Created pipeline #${pipeline.id} for ${ref}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, + }, + ], + }; + } + + case "retry_pipeline": { + const { project_id, pipeline_id } = RetryPipelineSchema.parse(request.params.arguments); + const pipeline = await gitlabSession.retryPipeline(project_id, pipeline_id); + return { + content: [ + { + type: "text", + text: `Retried pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, + }, + ], + }; + } + + case "cancel_pipeline": { + const { project_id, pipeline_id } = CancelPipelineSchema.parse(request.params.arguments); + const pipeline = await gitlabSession.cancelPipeline(project_id, pipeline_id); + return { + content: [ + { + type: "text", + text: `Canceled pipeline #${pipeline.id}. Status: ${pipeline.status}\nWeb URL: ${pipeline.web_url}`, + }, + ], + }; + } + + case "list_merge_requests": { + const args = ListMergeRequestsSchema.parse(request.params.arguments); + const mergeRequests = await gitlabSession.listMergeRequests(args.project_id, args); + return { + content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }], + }; + } + + case "list_milestones": { + const { project_id, ...options } = ListProjectMilestonesSchema.parse( + request.params.arguments + ); + const milestones = await gitlabSession.listProjectMilestones(project_id, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestones, null, 2), + }, + ], + }; + } + + case "get_milestone": { + const { project_id, milestone_id } = GetProjectMilestoneSchema.parse( + request.params.arguments + ); + const milestone = await gitlabSession.getProjectMilestone(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestone, null, 2), + }, + ], + }; + } + + case "create_milestone": { + const { project_id, ...options } = CreateProjectMilestoneSchema.parse( + request.params.arguments + ); + const milestone = await gitlabSession.createProjectMilestone(project_id, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestone, null, 2), + }, + ], + }; + } + + case "edit_milestone": { + const { project_id, milestone_id, ...options } = EditProjectMilestoneSchema.parse( + request.params.arguments + ); + const milestone = await gitlabSession.editProjectMilestone(project_id, milestone_id, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestone, null, 2), + }, + ], + }; + } + + case "delete_milestone": { + const { project_id, milestone_id } = DeleteProjectMilestoneSchema.parse( + request.params.arguments + ); + await gitlabSession.deleteProjectMilestone(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + status: "success", + message: "Milestone deleted successfully", + }, + null, + 2 + ), + }, + ], + }; + } + + case "get_milestone_issue": { + const { project_id, milestone_id } = GetMilestoneIssuesSchema.parse( + request.params.arguments + ); + const issues = await gitlabSession.getMilestoneIssues(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(issues, null, 2), + }, + ], + }; + } + + case "get_milestone_merge_requests": { + const { project_id, milestone_id } = GetMilestoneMergeRequestsSchema.parse( + request.params.arguments + ); + const mergeRequests = await gitlabSession.getMilestoneMergeRequests(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(mergeRequests, null, 2), + }, + ], + }; + } + + case "promote_milestone": { + const { project_id, milestone_id } = PromoteProjectMilestoneSchema.parse( + request.params.arguments + ); + const milestone = await gitlabSession.promoteProjectMilestone(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(milestone, null, 2), + }, + ], + }; + } + + case "get_milestone_burndown_events": { + const { project_id, milestone_id } = GetMilestoneBurndownEventsSchema.parse( + request.params.arguments + ); + const events = await gitlabSession.getMilestoneBurndownEvents(project_id, milestone_id); + return { + content: [ + { + type: "text", + text: JSON.stringify(events, null, 2), + }, + ], + }; + } + + case "list_commits": { + const args = ListCommitsSchema.parse(request.params.arguments); + const commits = await gitlabSession.listCommits(args.project_id, args); + return { + content: [{ type: "text", text: JSON.stringify(commits, null, 2) }], + }; + } + + case "get_commit": { + const args = GetCommitSchema.parse(request.params.arguments); + const commit = await gitlabSession.getCommit(args.project_id, args.sha, args.stats); + return { + content: [{ type: "text", text: JSON.stringify(commit, null, 2) }], + }; + } + + case "get_commit_diff": { + const args = GetCommitDiffSchema.parse(request.params.arguments); + const diff = await gitlabSession.getCommitDiff(args.project_id, args.sha); + return { + content: [{ type: "text", text: JSON.stringify(diff, null, 2) }], + }; + } + + default: + throw new Error(`Unknown tool: ${request.params.name}`); + } + } catch (error) { + if (error instanceof z.ZodError) { + throw new Error( + `Invalid arguments: ${error.errors + .map(e => `${e.path.join(".")}: ${e.message}`) + .join(", ")}` + ); + } + throw error; + } +}); + +export const mcpserver = server diff --git a/schemas.ts b/src/schemas.ts similarity index 100% rename from schemas.ts rename to src/schemas.ts From 2513168d858ef3dc3f13898e9a8deb7ac699bedc Mon Sep 17 00:00:00 2001 From: a Date: Wed, 2 Jul 2025 00:06:47 -0500 Subject: [PATCH 03/24] wip --- index.ts | 36 ++++++++++++++++- package-lock.json | 30 ++++++-------- package.json | 2 +- src/config.ts | 22 ++++++++-- src/mcpserver.ts | 5 ++- src/oauth.ts | 100 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 26 deletions(-) create mode 100644 src/oauth.ts diff --git a/index.ts b/index.ts index b88cd6e..13e9f08 100644 --- a/index.ts +++ b/index.ts @@ -4,9 +4,25 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express, { Request, Response } from "express"; import {mcpserver} from "./src/mcpserver.js"; +import { config } from "./src/config.js"; +import { createOAuth2Router, createTokenVerifier } from "./src/oauth.js"; +import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; const SSE = process.env.SSE === "true"; +// ensure there is a valid configuration +if (config.GITLAB_PERSONAL_ACCESS_TOKEN) { + if (config.GITLAB_OAUTH2_CLIENT_ID) { + console.error("only one of GITLAB_OAUTH2_CLIENT_ID or GITLAB_PERSONAL_ACCESS_TOKEN must be set"); + process.exit(1); + } +}else { + if(!config.GITLAB_OAUTH2_CLIENT_ID) { + console.error("one of GITLAB_OAUTH2_CLIENT_ID or GITLAB_PERSONAL_ACCESS_TOKEN must be set"); + process.exit(1); + } +} + /** * Initialize and run the server * 서버 초기화 및 실행 @@ -23,7 +39,23 @@ async function runServer() { } else { const app = express(); const transports: { [sessionId: string]: SSEServerTransport } = {}; - app.get("/sse", async (_: Request, res: Response) => { + + let authMiddleware: express.RequestHandler = (req: Request, res: Response, next: express.NextFunction) => { + next(); + } + // if gitlab oauth client id is set, then we attempt to enable the oauth proxy + if (config.GITLAB_OAUTH2_CLIENT_ID) { + const oauth2Proxy = createOAuth2Router() + console.log("Gitlab OAuth2 proxy enabled"); + app.use(oauth2Proxy); + + const tokenVerifier = createTokenVerifier() + authMiddleware = requireBearerAuth({ + verifier:tokenVerifier, + }) + } + + app.get("/sse", authMiddleware, async (req: Request, res: Response) => { const transport = new SSEServerTransport("/messages", res); transports[transport.sessionId] = transport; res.on("close", () => { @@ -32,7 +64,7 @@ async function runServer() { await mcpserver.connect(transport); }); - app.post("/messages", async (req: Request, res: Response) => { + app.post("/messages",authMiddleware, async (req: Request, res: Response) => { const sessionId = req.query.sessionId as string; const transport = transports[sessionId]; if (transport) { diff --git a/package-lock.json b/package-lock.json index aa67436..7c80820 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@zereight/mcp-gitlab", - "version": "1.0.64", + "version": "1.0.68", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@zereight/mcp-gitlab", - "version": "1.0.64", + "version": "1.0.68", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "1.8.0", + "@modelcontextprotocol/sdk": "1.13.3", "@types/node-fetch": "^2.6.12", "express": "^5.1.0", "fetch-cookie": "^3.1.0", @@ -334,18 +334,20 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.8.0.tgz", - "integrity": "sha512-e06W7SwrontJDHwCawNO5SGxG+nU9AAx+jpHHZqGl/WrDBdWOpvirC+s58VpJTB5QemI4jTRcjWT4Pt3Q1NPQQ==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.13.3.tgz", + "integrity": "sha512-bGwA78F/U5G2jrnsdRkPY3IwIwZeWUEfb5o764b79lb0rJmMT76TLwKhdNZOWakOQtedYefwIR4emisEMvInKA==", "license": "MIT", "dependencies": { + "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", - "pkce-challenge": "^4.1.0", + "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" @@ -843,7 +845,6 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -1631,7 +1632,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -1668,7 +1668,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -2260,7 +2259,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -2660,9 +2658,9 @@ } }, "node_modules/pkce-challenge": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", - "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", "license": "MIT", "engines": { "node": ">=16.20.0" @@ -2711,7 +2709,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3298,7 +3295,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" diff --git a/package.json b/package.json index 078f684..28af23c 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "format:check": "prettier --check \"**/*.{js,ts,json,md}\"" }, "dependencies": { - "@modelcontextprotocol/sdk": "1.8.0", + "@modelcontextprotocol/sdk": "1.13.3", "@types/node-fetch": "^2.6.12", "express": "^5.1.0", "fetch-cookie": "^3.1.0", diff --git a/src/config.ts b/src/config.ts index 9bfd357..383f488 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,11 +14,25 @@ export const config = { // Use the normalizeGitLabApiUrl function to handle various URL formats GITLAB_API_URL: normalizeGitLabApiUrl(process.env.GITLAB_API_URL || ""), GITLAB_PROJECT_ID: process.env.GITLAB_PROJECT_ID, + + // GitLab OAuth2 configuration + GITLAB_OAUTH2_CLIENT_ID: process.env.GITLAB_OAUTH2_CLIENT_ID, + GITLAB_OAUTH2_CLIENT_SECRET: process.env.GITLAB_OAUTH2_CLIENT_SECRET, + GITLAB_OAUTH2_REDIRECT_URL: process.env.GITLAB_OAUTH2_REDIRECT_URL, + + // base url matters for the redirect url, i think? + GITLAB_OAUTH2_BASE_URL: process.env.GITLAB_OAUTH2_BASE_URL, // http://localhost:3002 + + // TODO: maybe thse can be formed based off of the ISSUER_URL? im not sure... (could introduce problems if gitlab ever changes these endpoints, though i doubt they will) + GITLAB_OAUTH2_TOKEN_URL: process.env.GITLAB_OAUTH2_TOKEN_URL, // https://gitlab.com/oauth/token + GITLAB_OAUTH2_AUTHORIZATION_URL: process.env.GITLAB_OAUTH2_AUTHORIZATION_URL, // https://gitlab.com/oauth/authorize + GITLAB_OAUTH2_REVOCATION_URL: process.env.GITLAB_OAUTH2_REVOCATION_URL, // https://gitlab.com/oauth/revoke + GITLAB_OAUTH2_INTROSPECTION_URL: process.env.GITLAB_OAUTH2_INTROSPECTION_URL, // https://gitlab.com/oauth/introspect + GITLAB_OAUTH2_REGISTRATION_URL: process.env.GITLAB_OAUTH2_REGISTRATION_URL, // ? + + GITLAB_OAUTH2_ISSUER_URL: process.env.GITLAB_OAUTH2_ISSUER_URL, // https://gitlab.com } -if (!config.GITLAB_PERSONAL_ACCESS_TOKEN) { - console.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set"); - process.exit(1); -} + /** * Smart URL handling for GitLab API * diff --git a/src/mcpserver.ts b/src/mcpserver.ts index 9ef2e9a..8f0b0e7 100644 --- a/src/mcpserver.ts +++ b/src/mcpserver.ts @@ -597,8 +597,9 @@ server.setRequestHandler(CallToolRequestSchema, async request => { // Create GitlabSession instance const gitlabSession = new GitlabHandler(cookieJar); - await gitlabSession.ensureSessionForCookieJar(); - + if(cookieJar) { + await gitlabSession.ensureSessionForCookieJar(); + } switch (request.params.name) { case "fork_repository": { if (config.GITLAB_PROJECT_ID) { diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..c86d367 --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,100 @@ +import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js'; +import { config } from './config.js'; +import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; + +export const createOAuth2Router = () => { + if(!config.GITLAB_OAUTH2_AUTHORIZATION_URL) { + throw new Error("GITLAB_OAUTH2_AUTHORIZATION_URL is not set") + } + if(!config.GITLAB_OAUTH2_CLIENT_ID) { + throw new Error("GITLAB_OAUTH2_CLIENT_ID is not set") + } + const clientId = config.GITLAB_OAUTH2_CLIENT_ID + if(!config.GITLAB_OAUTH2_CLIENT_SECRET) { + throw new Error("GITLAB_OAUTH2_CLIENT_SECRET is not set") + } + if(!config.GITLAB_OAUTH2_REDIRECT_URL) { + throw new Error("GITLAB_OAUTH2_REDIRECT_URIS is not set") + } + const redirectUrl = config.GITLAB_OAUTH2_REDIRECT_URL + if(!config.GITLAB_OAUTH2_TOKEN_URL) { + throw new Error("GITLAB_OAUTH2_TOKEN_URL is not set") + } + if(!config.GITLAB_OAUTH2_ISSUER_URL) { + throw new Error("GITLAB_OAUTH2_ISSUER_URL is not set") + } + if (!config.GITLAB_OAUTH2_BASE_URL) { + throw new Error("GITLAB_OAUTH2_BASE_URL is not set") + } + + return mcpAuthRouter({ + issuerUrl: new URL(config.GITLAB_OAUTH2_ISSUER_URL), + baseUrl: new URL(config.GITLAB_OAUTH2_BASE_URL), + authorizationOptions: { + }, + resourceName: "https://gfx.cafe", + provider: new ProxyOAuthServerProvider({ + endpoints: { + authorizationUrl: config.GITLAB_OAUTH2_AUTHORIZATION_URL, + tokenUrl: config.GITLAB_OAUTH2_TOKEN_URL, + revocationUrl: config.GITLAB_OAUTH2_REVOCATION_URL, + registrationUrl: config.GITLAB_OAUTH2_REGISTRATION_URL, + }, + verifyAccessToken: async (token) => { + return { + token, + clientId: clientId, + scopes: ["openid", "email", "profile"], + } + }, + getClient: async (client_id) => { + return { + client_id, + client_secret: config.GITLAB_OAUTH2_CLIENT_SECRET, + redirect_uris: [redirectUrl], + } + } + }) + }) +} + +export const createTokenVerifier = () => { + if(!config.GITLAB_OAUTH2_INTROSPECTION_URL) { + throw new Error("GITLAB_OAUTH2_INTROSPECTION_URL is not set") + } + const introspectionEndpoint = config.GITLAB_OAUTH2_INTROSPECTION_URL + const tokenVerifier = { + verifyAccessToken: async (token: string) => { + + const response = await fetch(introspectionEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + token: token + }).toString() + }); + + + if (!response.ok) { + throw new Error(`Invalid or expired token: ${await response.text()}`); + } + + const data = await response.json(); + + // NOTE: we don't implement "strict oauth" here yet, as per this example: https://github.com/modelcontextprotocol/typescript-sdk/blob/1b14bd7fa4dcc436df0fcb2718f86dc376cdd904/src/examples/server/simpleStreamableHttp.ts#L7 + + // Convert the response to AuthInfo format + return { + token, + clientId: data.client_id, + scopes: data.scope ? data.scope.split(' ') : [], + expiresAt: data.exp, + }; + } + } + + return tokenVerifier + +} From 0467d2f902aad6367f0ada42ea7b12186315882d Mon Sep 17 00:00:00 2001 From: a Date: Wed, 2 Jul 2025 13:27:23 -0500 Subject: [PATCH 04/24] wip --- docs/oauth-setup.md | 82 ++++++++++++ index.ts | 131 ++++++++++++++---- package-lock.json | 310 +++++++++++++++++++++++++++++++++++++++++++ package.json | 5 +- src/config.ts | 43 +++++- src/gitlabhandler.ts | 149 +++++++-------------- src/gitlabsession.ts | 9 +- src/logger.ts | 27 ++++ src/mcpserver.ts | 20 ++- src/oauth.ts | 218 ++++++++++++++++++++++++++---- src/schemas.ts | 2 + 11 files changed, 831 insertions(+), 165 deletions(-) create mode 100644 docs/oauth-setup.md create mode 100644 src/logger.ts diff --git a/docs/oauth-setup.md b/docs/oauth-setup.md new file mode 100644 index 0000000..3e2b547 --- /dev/null +++ b/docs/oauth-setup.md @@ -0,0 +1,82 @@ +# GitLab OAuth2 Setup Guide + +This guide explains how to set up OAuth2 authentication for the GitLab MCP server. + +## Prerequisites + +1. A GitLab instance (GitLab.com or self-hosted) +2. Admin access to create OAuth applications in GitLab + +## Setting up GitLab OAuth Application + +1. Go to your GitLab instance +2. Navigate to **User Settings** > **Applications** (for user-owned apps) or **Admin Area** > **Applications** (for instance-wide apps) +3. Create a new application with: + - **Name**: `GitLab MCP Server` + - **Redirect URI**: You need to add the callback URL that your MCP client will use. This is typically `http://localhost:PORT/callback` where PORT is the port your MCP client uses. + - **Scopes**: Select the appropriate scopes (api, read_api, etc.) + +## The Redirect URI Challenge + +MCP clients typically start their own OAuth callback server on a random port. This creates a challenge because GitLab requires exact redirect URI matching. + +### Solutions: + +1. **Add Multiple Redirect URIs**: Add a range of possible callback URLs to your GitLab OAuth app: + ``` + http://localhost:50000/callback + http://localhost:50001/callback + http://localhost:50002/callback + ... (add more as needed) + ``` + +2. **Use Personal Access Token Instead**: For simpler setup, use a GitLab Personal Access Token: + ```json + { + "mcpServers": { + "gitlab": { + "command": "node", + "args": ["path/to/gitlab-mcp/build/index.js"], + "env": { + "GITLAB_PERSONAL_ACCESS_TOKEN": "your-token-here", + "GITLAB_API_URL": "https://gitlab.com/api/v4" + } + } + } + } + ``` + +## Environment Variables for OAuth2 + +If using OAuth2, set these environment variables: + +```bash +# OAuth2 Configuration +GITLAB_OAUTH2_CLIENT_ID="your-client-id" +GITLAB_OAUTH2_CLIENT_SECRET="your-client-secret" +GITLAB_OAUTH2_REDIRECT_URL="http://localhost:3002/callback" + +# GitLab URLs +GITLAB_OAUTH2_AUTHORIZATION_URL="https://gitlab.com/oauth/authorize" +GITLAB_OAUTH2_TOKEN_URL="https://gitlab.com/oauth/token" +GITLAB_OAUTH2_REVOCATION_URL="https://gitlab.com/oauth/revoke" +GITLAB_OAUTH2_INTROSPECTION_URL="https://gitlab.com/oauth/introspect" + +# Server Configuration +GITLAB_OAUTH2_BASE_URL="http://localhost:3002" +GITLAB_OAUTH2_ISSUER_URL="https://gitlab.com" +``` + +## Troubleshooting + +### "Invalid redirect URI" error + +This means the callback URL the MCP client is using doesn't match any of the redirect URIs configured in your GitLab OAuth application. Check the error URL to see what port the client is using and add that to your GitLab OAuth app. + +### "Client authentication failed" error + +This typically means the client ID or secret is incorrect. Double-check your environment variables. + +## Recommendation + +For most use cases, using a GitLab Personal Access Token is simpler and more reliable than OAuth2, as it doesn't require managing redirect URIs and callback servers. \ No newline at end of file diff --git a/index.ts b/index.ts index 13e9f08..00df5a6 100644 --- a/index.ts +++ b/index.ts @@ -4,24 +4,15 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express, { Request, Response } from "express"; import {mcpserver} from "./src/mcpserver.js"; -import { config } from "./src/config.js"; -import { createOAuth2Router, createTokenVerifier } from "./src/oauth.js"; +import { config, validateConfiguration} from "./src/config.js"; +import { createOAuth2Router, createTokenVerifier, handleOAuthCallback } from "./src/oauth.js"; import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; +import { logger } from "./src/logger.js"; +import argon2 from "@node-rs/argon2"; +import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; -const SSE = process.env.SSE === "true"; -// ensure there is a valid configuration -if (config.GITLAB_PERSONAL_ACCESS_TOKEN) { - if (config.GITLAB_OAUTH2_CLIENT_ID) { - console.error("only one of GITLAB_OAUTH2_CLIENT_ID or GITLAB_PERSONAL_ACCESS_TOKEN must be set"); - process.exit(1); - } -}else { - if(!config.GITLAB_OAUTH2_CLIENT_ID) { - console.error("one of GITLAB_OAUTH2_CLIENT_ID or GITLAB_PERSONAL_ACCESS_TOKEN must be set"); - process.exit(1); - } -} +validateConfiguration() /** * Initialize and run the server @@ -33,31 +24,101 @@ async function runServer() { // Server version banner removed - inappropriate use of console.error for logging // API URL banner removed - inappropriate use of console.error for logging // Server startup banner removed - inappropriate use of console.error for logging - if (!SSE) { + if (!config.SSE) { const transport = new StdioServerTransport(); await mcpserver.connect(transport); } else { const app = express(); - const transports: { [sessionId: string]: SSEServerTransport } = {}; + const transports: { + [sessionId: string]: { + transport: SSEServerTransport + tokenHash?: string + } + } = {}; - let authMiddleware: express.RequestHandler = (req: Request, res: Response, next: express.NextFunction) => { + // TODO: this should be refactored into a function that returns the auth middleware and mount any required middleware + let authMiddleware: express.RequestHandler = (_req: Request, _res: Response, next: express.NextFunction) => { next(); } + app.use((req, res, next) => { + logger.debug(`got request ${req.method} ${req.url}`); + next(); + }) // if gitlab oauth client id is set, then we attempt to enable the oauth proxy + // NOTE: this is... incredibly insecure. you shouldn't really be using this until more has been worked on it and some semi-proper security review. if (config.GITLAB_OAUTH2_CLIENT_ID) { + // Add the callback handler route BEFORE the OAuth router + app.get("/callback", handleOAuthCallback); + const oauth2Proxy = createOAuth2Router() console.log("Gitlab OAuth2 proxy enabled"); app.use(oauth2Proxy); const tokenVerifier = createTokenVerifier() - authMiddleware = requireBearerAuth({ + const bearerAuthMiddleware = requireBearerAuth({ verifier:tokenVerifier, + resourceMetadataUrl: `${config.GITLAB_OAUTH2_BASE_URL}/.well-known/oauth-protected-resource` }) + authMiddleware = (req: Request, res: Response, next: express.NextFunction) => { + const gitlabToken = req.headers["gitlab-token"]; + if(gitlabToken) { + res.status(401).send("Gitlab-Token header must not be set when MCP is running in OAuth2 mode"); + return; + } + bearerAuthMiddleware(req, res, next) + const authState = req.auth + if(authState) { + // so this means that auth was successful and we have a token + // supposedly, if our server is implemented correctly, we would issue the token that corresponded to the correct user token on the gitlab side + res.locals["gitlabAuthToken"] = authState.token + } + } + } else if(config.GITLAB_PAT_PASSTHROUGH) { + console.log("Gitlab PAT passthrough enabled"); + authMiddleware = (req: Request, res: Response, next: express.NextFunction) => { + // check the Gitlab-Token header + const token = req.headers["gitlab-token"]; + if(!token) { + res.status(401).send("Please set a Gitlab-Token header in your request"); + return; + } + if(typeof token !== "string") { + res.status(401).send("Gitlab-Token must only be set once"); + return; + } + req.auth = { + token: token, + clientId: "!passthrough", + scopes: [], + } + next(); + } + }else if(config.GITLAB_PERSONAL_ACCESS_TOKEN){ + console.log("Using GITLAB_PERSONAL_ACCESS_TOKEN for all requests") + const accessToken = config.GITLAB_PERSONAL_ACCESS_TOKEN + authMiddleware = (req: Request, res: Response, next: express.NextFunction) => { + req.auth = { + token: accessToken, + clientId: "!global", + scopes: [], + } + next(); + } } + const argon2Salt = new TextEncoder().encode(config.ARGON2_SALT) app.get("/sse", authMiddleware, async (req: Request, res: Response) => { const transport = new SSEServerTransport("/messages", res); - transports[transport.sessionId] = transport; + transports[transport.sessionId] = { + transport, + }; + // if we have a valid auth info here, either obtained from the passthrough token or oauth, we tie it to a session. + if(req.auth) { + transports[transport.sessionId].tokenHash = await argon2.hash(req.auth.token, { + salt: argon2Salt, + }); + } + console.log("accessed with token hash", transports[transport.sessionId].tokenHash); res.on("close", () => { delete transports[transport.sessionId]; }); @@ -66,11 +127,35 @@ async function runServer() { app.post("/messages",authMiddleware, async (req: Request, res: Response) => { const sessionId = req.query.sessionId as string; - const transport = transports[sessionId]; + const transportDetails = transports[sessionId]; + if(!transportDetails) { + res.status(400).send("No transport found for sessionId"); + return; + } + const {transport, tokenHash} = transportDetails + // means we have a token hash to verify. + if(tokenHash) { + // NOTE: at this point, we assume that this req.auth is a "valid" AuthInfo + // TODO: consider the security implications of this when verifying dcr clients. + if(!req.auth) { + res.status(401).send("No authorization information sent"); + return; + } + const gitlabToken = req.auth.token; + if(!gitlabToken) { + res.status(401).send("No valid token info found in request"); + return; + } + const verified = await argon2.verify(tokenHash, gitlabToken, { + salt: argon2Salt, + }); + if(!verified) { + res.status(401).send("Token does not match session"); + return; + } + } if (transport) { await transport.handlePostMessage(req, res); - } else { - res.status(400).send("No transport found for sessionId"); } }); diff --git a/package-lock.json b/package-lock.json index 7c80820..6ba7342 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.13.3", + "@node-rs/argon2": "^2.0.2", "@types/node-fetch": "^2.6.12", "express": "^5.1.0", "fetch-cookie": "^3.1.0", @@ -64,6 +65,37 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@emnapi/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", + "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", + "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -356,6 +388,267 @@ "node": ">=18" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", + "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, + "node_modules/@node-rs/argon2": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.0.2.tgz", + "integrity": "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@node-rs/argon2-android-arm-eabi": "2.0.2", + "@node-rs/argon2-android-arm64": "2.0.2", + "@node-rs/argon2-darwin-arm64": "2.0.2", + "@node-rs/argon2-darwin-x64": "2.0.2", + "@node-rs/argon2-freebsd-x64": "2.0.2", + "@node-rs/argon2-linux-arm-gnueabihf": "2.0.2", + "@node-rs/argon2-linux-arm64-gnu": "2.0.2", + "@node-rs/argon2-linux-arm64-musl": "2.0.2", + "@node-rs/argon2-linux-x64-gnu": "2.0.2", + "@node-rs/argon2-linux-x64-musl": "2.0.2", + "@node-rs/argon2-wasm32-wasi": "2.0.2", + "@node-rs/argon2-win32-arm64-msvc": "2.0.2", + "@node-rs/argon2-win32-ia32-msvc": "2.0.2", + "@node-rs/argon2-win32-x64-msvc": "2.0.2" + } + }, + "node_modules/@node-rs/argon2-android-arm-eabi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz", + "integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-android-arm64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz", + "integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-arm64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz", + "integrity": "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-x64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz", + "integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-freebsd-x64": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz", + "integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz", + "integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-gnu": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz", + "integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-musl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz", + "integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-gnu": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz", + "integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-musl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz", + "integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-wasm32-wasi": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz", + "integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@node-rs/argon2-win32-arm64-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz", + "integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-ia32-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz", + "integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-x64-msvc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz", + "integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -422,6 +715,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -3221,6 +3524,13 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 28af23c..03bba2d 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.13.3", + "@node-rs/argon2": "^2.0.2", "@types/node-fetch": "^2.6.12", "express": "^5.1.0", "fetch-cookie": "^3.1.0", @@ -47,11 +48,11 @@ "@types/node": "^22.13.10", "@typescript-eslint/eslint-plugin": "^8.21.0", "@typescript-eslint/parser": "^8.21.0", + "auto-changelog": "^2.4.0", "eslint": "^9.18.0", "prettier": "^3.4.2", "ts-node": "^10.9.2", "typescript": "^5.8.2", - "zod": "^3.24.2", - "auto-changelog": "^2.4.0" + "zod": "^3.24.2" } } diff --git a/src/config.ts b/src/config.ts index 383f488..2e71ed5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,8 @@ export const config = { - GITLAB_PERSONAL_ACCESS_TOKEN : process.env.GITLAB_PERSONAL_ACCESS_TOKEN, - GITLAB_AUTH_COOKIE_PATH : process.env.GITLAB_AUTH_COOKIE_PATH, + SSE: process.env.SSE === "true", IS_OLD : process.env.GITLAB_IS_OLD === "true", GITLAB_READ_ONLY_MODE : process.env.GITLAB_READ_ONLY_MODE === "true", + VERBOSE: ["true","TRUE","1"].includes(process.env.VERBOSE || "0"), USE_GITLAB_WIKI : process.env.USE_GITLAB_WIKI === "true", USE_MILESTONE : process.env.USE_MILESTONE === "true", USE_PIPELINE : process.env.USE_PIPELINE === "true", @@ -12,9 +12,24 @@ export const config = { NODE_TLS_REJECT_UNAUTHORIZED : process.env.NODE_TLS_REJECT_UNAUTHORIZED, GITLAB_CA_CERT_PATH : process.env.GITLAB_CA_CERT_PATH, // Use the normalizeGitLabApiUrl function to handle various URL formats - GITLAB_API_URL: normalizeGitLabApiUrl(process.env.GITLAB_API_URL || ""), + GITLAB_API_URL: normalizeGitLabApiUrl(process.env.GITLAB_API_URL || undefined), GITLAB_PROJECT_ID: process.env.GITLAB_PROJECT_ID, + + ARGON2_SALT: process.env.ARGON2_SALT || "change-me-in-production", + // Configure cookie auth path, for gitlab instances which require it + // TODO: investigate the consequences of this with oauth2 and pat passthrough. should it only be used in PAT mode and not passthrough? + GITLAB_AUTH_COOKIE_PATH : process.env.GITLAB_AUTH_COOKIE_PATH, + + // only one of GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_OAUTH2_CLIENT_ID, GITLAB_PAT_PASSTHROUGH + + // Gitlab PAT configuration. use this PAT to authenticate all requests + GITLAB_PERSONAL_ACCESS_TOKEN : process.env.GITLAB_PERSONAL_ACCESS_TOKEN, + + // Gitlab PAT passthrough. pass through the PRIVATE-TOKEN header to make the request to the Gitlab API + // should be "true" to enable + GITLAB_PAT_PASSTHROUGH : process.env.GITLAB_PAT_PASSTHROUGH === "true", + // GitLab OAuth2 configuration GITLAB_OAUTH2_CLIENT_ID: process.env.GITLAB_OAUTH2_CLIENT_ID, GITLAB_OAUTH2_CLIENT_SECRET: process.env.GITLAB_OAUTH2_CLIENT_SECRET, @@ -33,6 +48,28 @@ export const config = { GITLAB_OAUTH2_ISSUER_URL: process.env.GITLAB_OAUTH2_ISSUER_URL, // https://gitlab.com } +export const validateConfiguration = ()=> { + + + // check that only one of GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_OAUTH2_CLIENT_ID, GITLAB_PAT_PASSTHROUGH is set + const onlyOnOf = [ + config.GITLAB_PERSONAL_ACCESS_TOKEN, + config.GITLAB_OAUTH2_CLIENT_ID, + config.GITLAB_PAT_PASSTHROUGH + ] + const countOfSet = onlyOnOf.filter(x=>!!x).length + + const allVariableNames = "GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_OAUTH2_CLIENT_ID, GITLAB_PAT_PASSTHROUGH" + if (countOfSet == 0 ) { + console.error(`One of the following variables must be set: ${allVariableNames}`) + process.exit(1) + } + if (countOfSet > 1) { + console.error(`Only one of the following variables can be set: ${allVariableNames}`) + process.exit(1) + } +} + /** * Smart URL handling for GitLab API * diff --git a/src/gitlabhandler.ts b/src/gitlabhandler.ts index f3e1d7a..df89dad 100644 --- a/src/gitlabhandler.ts +++ b/src/gitlabhandler.ts @@ -192,9 +192,7 @@ export class GitlabHandler extends GitlabSession { const effectiveProjectId = this.getEffectiveProjectId(projectId); const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(effectiveProjectId)}`); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const project = GitLabRepositorySchema.parse(await response.json()); @@ -223,9 +221,7 @@ export class GitlabHandler extends GitlabSession { url.searchParams.append("ref", ref); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); if (response.status === 404) { throw new Error(`File not found: ${filePath}`); @@ -305,9 +301,7 @@ export class GitlabHandler extends GitlabSession { } }); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); @@ -334,9 +328,7 @@ export class GitlabHandler extends GitlabSession { } }); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); @@ -352,9 +344,7 @@ export class GitlabHandler extends GitlabSession { `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}` ); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); @@ -419,9 +409,7 @@ export class GitlabHandler extends GitlabSession { `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}/links` ); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); @@ -443,9 +431,7 @@ this.getEffectiveProjectId(projectId) )}/issues/${issueIid}/links/${issueLinkId}` ); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); @@ -571,9 +557,7 @@ this.getEffectiveProjectId(projectId) url.searchParams.append("per_page", options.per_page.toString()); } - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const discussions = await response.json(); @@ -928,9 +912,7 @@ this.getEffectiveProjectId(projectId) url.searchParams.append("order_by", "id"); url.searchParams.append("sort", "desc"); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); if (!response.ok) { const errorBody = await response.text(); @@ -1006,9 +988,7 @@ this.getEffectiveProjectId(projectId) throw new Error("Either mergeRequestIid or branchName must be provided"); } - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); @@ -1050,9 +1030,7 @@ this.getEffectiveProjectId(projectId) url.searchParams.append("view", view); } - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = (await response.json()) as { changes: unknown }; @@ -1098,9 +1076,7 @@ this.getEffectiveProjectId(projectId) url.searchParams.append("unidiff", "true"); } - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); return await response.json(); @@ -1128,9 +1104,7 @@ this.getEffectiveProjectId(projectId) url.searchParams.append("straight", straight.toString()); } - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); if (!response.ok) { const errorBody = await response.text(); @@ -1267,9 +1241,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); url.searchParams.append("top_level_only", "true"); } - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); @@ -1282,9 +1254,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); async getNamespace(id: string): Promise { const url = new URL(`${config.GITLAB_API_URL}/namespaces/${encodeURIComponent(id)}`); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); @@ -1304,9 +1274,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); url.searchParams.append("parent_id", parentId.toString()); } - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); @@ -1339,9 +1307,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); url.searchParams.append("with_custom_attributes", "true"); } - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); @@ -1365,9 +1331,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); } } - const response = await this.fetch(`${config.GITLAB_API_URL}/projects?${params.toString()}`, { - - }); + const response = await this.fetch(`${config.GITLAB_API_URL}/projects?${params.toString()}`, {}); await this.handleGitLabError(response); @@ -1395,9 +1359,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); } }); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); @@ -1424,9 +1386,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); url.searchParams.append("include_ancestor_groups", includeAncestorGroups ? "true" : "false"); } - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); @@ -1537,9 +1497,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); if (options.with_security_reports !== undefined) url.searchParams.append("with_security_reports", options.with_security_reports.toString()); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const projects = await response.json(); @@ -1560,9 +1518,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); if (options.with_content) url.searchParams.append("with_content", options.with_content.toString()); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); return GitLabWikiPageSchema.array().parse(data); @@ -1666,9 +1622,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); } }); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); @@ -1684,9 +1638,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines/${pipelineId}` ); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); if (response.status === 404) { throw new Error(`Pipeline not found`); @@ -1720,9 +1672,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); } }); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); if (response.status === 404) { throw new Error(`Pipeline not found`); @@ -1740,9 +1690,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); projectId = decodeURIComponent(projectId); const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/jobs/${jobId}`); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); if (response.status === 404) { throw new Error(`Job not found`); @@ -1934,9 +1882,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); } }); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); return z.array(GitLabMilestonesSchema).parse(data); @@ -1954,9 +1900,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}` ); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); return GitLabMilestonesSchema.parse(data); @@ -2030,9 +1974,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}/issues` ); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); return z.array(GitLabIssueSchema).parse(data); @@ -2052,9 +1994,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); )}/milestones/${milestoneId}/merge_requests` ); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); return z.array(GitLabMergeRequestSchema).parse(data); @@ -2092,9 +2032,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); )}/milestones/${milestoneId}/burndown_events` ); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); return data as any[]; @@ -2174,9 +2112,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); if (options.page) url.searchParams.append("page", options.page.toString()); if (options.per_page) url.searchParams.append("per_page", options.per_page.toString()); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); @@ -2201,9 +2137,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); url.searchParams.append("stats", "true"); } - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); @@ -2223,13 +2157,24 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/repository/commits/${encodeURIComponent(sha)}/diff` ); - const response = await this.fetch(url.toString(), { - - }); + const response = await this.fetch(url.toString(), {}); await this.handleGitLabError(response); const data = await response.json(); return z.array(GitLabDiffSchema).parse(data); } + + /** + * Get details of the current authenticated User + */ + async getCurrentUser(): Promise { + const url = new URL(`${config.GITLAB_API_URL}/user`); + const response = await this.fetch(url.toString(), {}); + + await this.handleGitLabError(response); + + const data = await response.json(); + return GitLabUserSchema.parse(data); + } } diff --git a/src/gitlabsession.ts b/src/gitlabsession.ts index f94fcce..e33f3e4 100644 --- a/src/gitlabsession.ts +++ b/src/gitlabsession.ts @@ -17,7 +17,10 @@ export class GitlabSession { private defaultConfig: RequestInit private defaultHeaders: Record - constructor(private readonly cookieJar?: CookieJar) { + constructor( + private readonly authToken: string, + private readonly cookieJar?: CookieJar + ) { // Configure proxy agents if proxies are set let httpAgent: Agent | undefined = undefined; let httpsAgent: Agent | undefined = undefined; @@ -52,9 +55,9 @@ export class GitlabSession { }; if (config.IS_OLD) { - this.defaultHeaders["Private-Token"] = `${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; + this.defaultHeaders["Private-Token"] = `${this.authToken}`; } else { - this.defaultHeaders["Authorization"] = `Bearer ${config.GITLAB_PERSONAL_ACCESS_TOKEN}`; + this.defaultHeaders["Authorization"] = `Bearer ${this.authToken}`; } this.defaultConfig = { diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..665d475 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,27 @@ +import { config } from './config.js' +export const logger = { + error: (...data: any[]) => { + console.error(...data) + }, + warn: (...data: any[]) => { + console.warn(...data) + }, + log: (...data: any[]) => { + if(!config.VERBOSE) { + return + } + console.log(...data) + }, + info: (...data: any[]) => { + if(!config.VERBOSE) { + return + } + console.log('[info]', ...data) + }, + debug: (...data: any[]) => { + if(!config.VERBOSE) { + return + } + console.debug('[debug]', ...data) + }, +} diff --git a/src/mcpserver.ts b/src/mcpserver.ts index 8f0b0e7..bd4bd4d 100644 --- a/src/mcpserver.ts +++ b/src/mcpserver.ts @@ -80,6 +80,7 @@ import { GetCommitSchema, GetCommitDiffSchema, ListMergeRequestDiffsSchema, + GetCurrentUserSchema, } from "./schemas.js"; import { createCookieJar } from "./authhelpers.js"; import { CookieJar } from "tough-cookie"; @@ -465,6 +466,11 @@ const allTools = [ description: "Get changes/diffs of a specific commit", inputSchema: zodToJsonSchema(GetCommitDiffSchema), }, + { + name: "get_current_user", + description: "Get details of the current authenticated user", + inputSchema: zodToJsonSchema(GetCurrentUserSchema), + } ]; // Define which tools are read-only @@ -506,6 +512,7 @@ const readOnlyTools = [ "list_commits", "get_commit", "get_commit_diff", + "get_current_user", ]; // Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI @@ -584,19 +591,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { const globalCookieJar = createCookieJar(); -server.setRequestHandler(CallToolRequestSchema, async request => { +server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { try { if (!request.params.arguments) { throw new Error("Arguments are required"); } - let cookieJar: CookieJar | undefined = undefined; if(config.GITLAB_AUTH_COOKIE_PATH) { cookieJar = globalCookieJar; } - // Create GitlabSession instance - const gitlabSession = new GitlabHandler(cookieJar); + // TODO: we silently do nothing if the authInfo is not properly forwared. should we do something? + const gitlabSession = new GitlabHandler(extra.authInfo?.token || "", cookieJar); if(cookieJar) { await gitlabSession.ensureSessionForCookieJar(); } @@ -1451,6 +1457,12 @@ server.setRequestHandler(CallToolRequestSchema, async request => { content: [{ type: "text", text: JSON.stringify(diff, null, 2) }], }; } + case "get_current_user": { + const user = await gitlabSession.getCurrentUser(); + return { + content: [{ type: "text", text: JSON.stringify(user, null, 2) }], + }; + } default: throw new Error(`Unknown tool: ${request.params.name}`); diff --git a/src/oauth.ts b/src/oauth.ts index c86d367..8e827d3 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,6 +1,177 @@ import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js'; import { config } from './config.js'; import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; +import { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; +import { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js'; +import { Request, Response } from 'express'; + +// Store for dynamically registered clients +const clientRegistry = new Map(); + +// Map client IDs to their redirect URIs +const clientRedirectUris = new Map(); + +// Map OAuth state parameters to client redirect URIs +const stateToRedirectUri = new Map(); + +// Clean up expired state mappings every 5 minutes +setInterval(() => { + const expirationTime = 10 * 60 * 1000; // 10 minutes + const now = Date.now(); + + // In a production system, you'd store timestamps with the state + // For now, we'll just clear all mappings older than the interval + if (stateToRedirectUri.size > 100) { + console.log(`Clearing ${stateToRedirectUri.size} state mappings`); + stateToRedirectUri.clear(); + } +}, 5 * 60 * 1000); + +// Custom provider that handles dynamic registration and maps to GitLab OAuth +class GitLabProxyProvider extends ProxyOAuthServerProvider { + get clientsStore() { + return { + getClient: async (clientId: string) => { + // Check if this is a registered dynamic client + const client = clientRegistry.get(clientId); + if (client) { + return client; + } + + // Check if this is the actual GitLab client + if (clientId === config.GITLAB_OAUTH2_CLIENT_ID) { + return { + client_id: config.GITLAB_OAUTH2_CLIENT_ID!, + client_secret: config.GITLAB_OAUTH2_CLIENT_SECRET!, + redirect_uris: [config.GITLAB_OAUTH2_REDIRECT_URL!], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post' + }; + } + + return undefined; + }, + + registerClient: async (clientMetadata: any) => { + // Generate a unique client ID for this MCP client + const clientId = `mcp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // Store the client's redirect URIs + clientRedirectUris.set(clientId, clientMetadata.redirect_uris || []); + + // Create the client registration + const client: OAuthClientInformationFull = { + ...clientMetadata, + client_id: clientId, + client_secret: `secret_${Math.random().toString(36).substr(2, 20)}`, + client_id_issued_at: Math.floor(Date.now() / 1000), + grant_types: clientMetadata.grant_types || ['authorization_code', 'refresh_token'], + response_types: clientMetadata.response_types || ['code'], + token_endpoint_auth_method: clientMetadata.token_endpoint_auth_method || 'client_secret_post' + }; + + // Store the client + clientRegistry.set(clientId, client); + + return client; + } + }; + } + + // Override authorization to use GitLab OAuth credentials + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + // Store the mapping between state and client's actual redirect URI + if (params.state && params.redirectUri) { + stateToRedirectUri.set(params.state, params.redirectUri); + console.log(`Stored state mapping: ${params.state} -> ${params.redirectUri}`); + } + + // Use GitLab OAuth credentials for the actual authorization + const gitlabClient = { + ...client, + client_id: config.GITLAB_OAUTH2_CLIENT_ID!, + client_secret: config.GITLAB_OAUTH2_CLIENT_SECRET!, + // Use GitLab's registered redirect URI + redirect_uris: [config.GITLAB_OAUTH2_REDIRECT_URL!] + }; + + // Use GitLab's redirect URI for the authorization + const gitlabParams = { + ...params, + redirectUri: config.GITLAB_OAUTH2_REDIRECT_URL! + }; + + return super.authorize(gitlabClient, gitlabParams, res); + } + + // Override token exchange to use GitLab OAuth credentials + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + codeVerifier?: string, + redirectUri?: string, + resource?: URL + ): Promise { + // Use GitLab OAuth credentials for token exchange + const gitlabClient = { + ...client, + client_id: config.GITLAB_OAUTH2_CLIENT_ID!, + client_secret: config.GITLAB_OAUTH2_CLIENT_SECRET!, + redirect_uris: [config.GITLAB_OAUTH2_REDIRECT_URL!] + }; + + // Use GitLab's redirect URI for the token exchange + return super.exchangeAuthorizationCode( + gitlabClient, + authorizationCode, + codeVerifier, + config.GITLAB_OAUTH2_REDIRECT_URL!, + resource + ); + } +} + + + + +// Handle OAuth callback and redirect to client's actual callback URL +export const handleOAuthCallback = (req: Request, res: Response): void => { + const { code, state, error, error_description } = req.query; + + console.log('OAuth callback received:', { code: !!code, state, error }); + + if (!state) { + res.status(400).send('Missing state parameter'); + return; + } + + // Get the client's actual redirect URI + const clientRedirectUri = stateToRedirectUri.get(state as string); + + if (!clientRedirectUri) { + console.error(`No redirect URI found for state: ${state}`); + res.status(400).send('Invalid state parameter'); + return; + } + + // Clean up the state mapping + stateToRedirectUri.delete(state as string); + + // Build the redirect URL with all parameters + const redirectUrl = new URL(clientRedirectUri); + + // Pass through all query parameters + if (code) redirectUrl.searchParams.set('code', code as string); + if (state) redirectUrl.searchParams.set('state', state as string); + if (error) redirectUrl.searchParams.set('error', error as string); + if (error_description) redirectUrl.searchParams.set('error_description', error_description as string); + + console.log(`Redirecting to client callback: ${redirectUrl.toString()}`); + + // Redirect to the client's actual callback URL + res.redirect(redirectUrl.toString()); +}; export const createOAuth2Router = () => { if(!config.GITLAB_OAUTH2_AUTHORIZATION_URL) { @@ -9,10 +180,6 @@ export const createOAuth2Router = () => { if(!config.GITLAB_OAUTH2_CLIENT_ID) { throw new Error("GITLAB_OAUTH2_CLIENT_ID is not set") } - const clientId = config.GITLAB_OAUTH2_CLIENT_ID - if(!config.GITLAB_OAUTH2_CLIENT_SECRET) { - throw new Error("GITLAB_OAUTH2_CLIENT_SECRET is not set") - } if(!config.GITLAB_OAUTH2_REDIRECT_URL) { throw new Error("GITLAB_OAUTH2_REDIRECT_URIS is not set") } @@ -27,34 +194,29 @@ export const createOAuth2Router = () => { throw new Error("GITLAB_OAUTH2_BASE_URL is not set") } + const provider = new GitLabProxyProvider({ + endpoints: { + authorizationUrl: config.GITLAB_OAUTH2_AUTHORIZATION_URL, + tokenUrl: config.GITLAB_OAUTH2_TOKEN_URL, + revocationUrl: config.GITLAB_OAUTH2_REVOCATION_URL, + }, + verifyAccessToken: async (token: string) => { + // Use the introspection endpoint to verify the token + const tokenVerifier = createTokenVerifier(); + return await tokenVerifier.verifyAccessToken(token); + }, + getClient: async (client_id: string) => { + // This is handled by our custom provider's clientsStore + return undefined; + } + }) + return mcpAuthRouter({ - issuerUrl: new URL(config.GITLAB_OAUTH2_ISSUER_URL), + issuerUrl: new URL(config.GITLAB_OAUTH2_BASE_URL), baseUrl: new URL(config.GITLAB_OAUTH2_BASE_URL), authorizationOptions: { }, - resourceName: "https://gfx.cafe", - provider: new ProxyOAuthServerProvider({ - endpoints: { - authorizationUrl: config.GITLAB_OAUTH2_AUTHORIZATION_URL, - tokenUrl: config.GITLAB_OAUTH2_TOKEN_URL, - revocationUrl: config.GITLAB_OAUTH2_REVOCATION_URL, - registrationUrl: config.GITLAB_OAUTH2_REGISTRATION_URL, - }, - verifyAccessToken: async (token) => { - return { - token, - clientId: clientId, - scopes: ["openid", "email", "profile"], - } - }, - getClient: async (client_id) => { - return { - client_id, - client_secret: config.GITLAB_OAUTH2_CLIENT_SECRET, - redirect_uris: [redirectUrl], - } - } - }) + provider: provider, }) } diff --git a/src/schemas.ts b/src/schemas.ts index 5a6bc1d..8679754 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1380,6 +1380,8 @@ export const GetCommitDiffSchema = z.object({ sha: z.string().describe("The commit hash or name of a repository branch or tag"), }); +export const GetCurrentUserSchema = z.object({}); + // Export types export type GitLabAuthor = z.infer; export type GitLabFork = z.infer; From 8babb38ff39363f840236887853a6eeef0218193 Mon Sep 17 00:00:00 2001 From: a Date: Wed, 2 Jul 2025 13:27:34 -0500 Subject: [PATCH 05/24] wip --- docs/oauth-setup.md | 82 --------------------------------------------- 1 file changed, 82 deletions(-) delete mode 100644 docs/oauth-setup.md diff --git a/docs/oauth-setup.md b/docs/oauth-setup.md deleted file mode 100644 index 3e2b547..0000000 --- a/docs/oauth-setup.md +++ /dev/null @@ -1,82 +0,0 @@ -# GitLab OAuth2 Setup Guide - -This guide explains how to set up OAuth2 authentication for the GitLab MCP server. - -## Prerequisites - -1. A GitLab instance (GitLab.com or self-hosted) -2. Admin access to create OAuth applications in GitLab - -## Setting up GitLab OAuth Application - -1. Go to your GitLab instance -2. Navigate to **User Settings** > **Applications** (for user-owned apps) or **Admin Area** > **Applications** (for instance-wide apps) -3. Create a new application with: - - **Name**: `GitLab MCP Server` - - **Redirect URI**: You need to add the callback URL that your MCP client will use. This is typically `http://localhost:PORT/callback` where PORT is the port your MCP client uses. - - **Scopes**: Select the appropriate scopes (api, read_api, etc.) - -## The Redirect URI Challenge - -MCP clients typically start their own OAuth callback server on a random port. This creates a challenge because GitLab requires exact redirect URI matching. - -### Solutions: - -1. **Add Multiple Redirect URIs**: Add a range of possible callback URLs to your GitLab OAuth app: - ``` - http://localhost:50000/callback - http://localhost:50001/callback - http://localhost:50002/callback - ... (add more as needed) - ``` - -2. **Use Personal Access Token Instead**: For simpler setup, use a GitLab Personal Access Token: - ```json - { - "mcpServers": { - "gitlab": { - "command": "node", - "args": ["path/to/gitlab-mcp/build/index.js"], - "env": { - "GITLAB_PERSONAL_ACCESS_TOKEN": "your-token-here", - "GITLAB_API_URL": "https://gitlab.com/api/v4" - } - } - } - } - ``` - -## Environment Variables for OAuth2 - -If using OAuth2, set these environment variables: - -```bash -# OAuth2 Configuration -GITLAB_OAUTH2_CLIENT_ID="your-client-id" -GITLAB_OAUTH2_CLIENT_SECRET="your-client-secret" -GITLAB_OAUTH2_REDIRECT_URL="http://localhost:3002/callback" - -# GitLab URLs -GITLAB_OAUTH2_AUTHORIZATION_URL="https://gitlab.com/oauth/authorize" -GITLAB_OAUTH2_TOKEN_URL="https://gitlab.com/oauth/token" -GITLAB_OAUTH2_REVOCATION_URL="https://gitlab.com/oauth/revoke" -GITLAB_OAUTH2_INTROSPECTION_URL="https://gitlab.com/oauth/introspect" - -# Server Configuration -GITLAB_OAUTH2_BASE_URL="http://localhost:3002" -GITLAB_OAUTH2_ISSUER_URL="https://gitlab.com" -``` - -## Troubleshooting - -### "Invalid redirect URI" error - -This means the callback URL the MCP client is using doesn't match any of the redirect URIs configured in your GitLab OAuth application. Check the error URL to see what port the client is using and add that to your GitLab OAuth app. - -### "Client authentication failed" error - -This typically means the client ID or secret is incorrect. Double-check your environment variables. - -## Recommendation - -For most use cases, using a GitLab Personal Access Token is simpler and more reliable than OAuth2, as it doesn't require managing redirect URIs and callback servers. \ No newline at end of file From 3d6fc4dc3cc5dae94c13344f673aea3591167656 Mon Sep 17 00:00:00 2001 From: a Date: Wed, 2 Jul 2025 13:44:54 -0500 Subject: [PATCH 06/24] noot --- src/mcpserver.ts | 2 + src/oauth.ts | 98 ++++++++++++++++++++++++------------------------ 2 files changed, 51 insertions(+), 49 deletions(-) diff --git a/src/mcpserver.ts b/src/mcpserver.ts index bd4bd4d..4e0c1cd 100644 --- a/src/mcpserver.ts +++ b/src/mcpserver.ts @@ -589,6 +589,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }; }); +// TODO: im pretty sure that the cookie jar should be scoped by token? instead of being global +// but i need to look into it. just don't use the cookie jar feature with oauth or passthrough... const globalCookieJar = createCookieJar(); server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { diff --git a/src/oauth.ts b/src/oauth.ts index 8e827d3..054301d 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -14,19 +14,6 @@ const clientRedirectUris = new Map(); // Map OAuth state parameters to client redirect URIs const stateToRedirectUri = new Map(); -// Clean up expired state mappings every 5 minutes -setInterval(() => { - const expirationTime = 10 * 60 * 1000; // 10 minutes - const now = Date.now(); - - // In a production system, you'd store timestamps with the state - // For now, we'll just clear all mappings older than the interval - if (stateToRedirectUri.size > 100) { - console.log(`Clearing ${stateToRedirectUri.size} state mappings`); - stateToRedirectUri.clear(); - } -}, 5 * 60 * 1000); - // Custom provider that handles dynamic registration and maps to GitLab OAuth class GitLabProxyProvider extends ProxyOAuthServerProvider { get clientsStore() { @@ -37,7 +24,7 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { if (client) { return client; } - + // Check if this is the actual GitLab client if (clientId === config.GITLAB_OAUTH2_CLIENT_ID) { return { @@ -49,17 +36,17 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { token_endpoint_auth_method: 'client_secret_post' }; } - + return undefined; }, - + registerClient: async (clientMetadata: any) => { // Generate a unique client ID for this MCP client const clientId = `mcp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - + // Store the client's redirect URIs clientRedirectUris.set(clientId, clientMetadata.redirect_uris || []); - + // Create the client registration const client: OAuthClientInformationFull = { ...clientMetadata, @@ -70,15 +57,15 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { response_types: clientMetadata.response_types || ['code'], token_endpoint_auth_method: clientMetadata.token_endpoint_auth_method || 'client_secret_post' }; - + // Store the client clientRegistry.set(clientId, client); - + return client; } }; } - + // Override authorization to use GitLab OAuth credentials async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { // Store the mapping between state and client's actual redirect URI @@ -86,25 +73,38 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { stateToRedirectUri.set(params.state, params.redirectUri); console.log(`Stored state mapping: ${params.state} -> ${params.redirectUri}`); } - - // Use GitLab OAuth credentials for the actual authorization - const gitlabClient = { - ...client, - client_id: config.GITLAB_OAUTH2_CLIENT_ID!, - client_secret: config.GITLAB_OAUTH2_CLIENT_SECRET!, - // Use GitLab's registered redirect URI - redirect_uris: [config.GITLAB_OAUTH2_REDIRECT_URL!] - }; - - // Use GitLab's redirect URI for the authorization - const gitlabParams = { - ...params, - redirectUri: config.GITLAB_OAUTH2_REDIRECT_URL! - }; - - return super.authorize(gitlabClient, gitlabParams, res); + + // Construct the authorization URL directly to ensure proper formatting + const authUrl = new URL(this._endpoints.authorizationUrl); + + // Add required OAuth parameters + authUrl.searchParams.set('client_id', config.GITLAB_OAUTH2_CLIENT_ID!); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('redirect_uri', config.GITLAB_OAUTH2_REDIRECT_URL!.trim()); + authUrl.searchParams.set('code_challenge', params.codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + + // Add optional parameters + if (params.state) { + authUrl.searchParams.set('state', params.state); + } + + + const gitlabScopes = ['api', 'openid','profile','email']; + authUrl.searchParams.set('scope', gitlabScopes.join(' ')); + + // GitLab doesn't support the 'resource' parameter, so we skip it + + console.log(`Redirecting to GitLab OAuth:`, { + url: authUrl.toString(), + scopes: gitlabScopes, + requested_scopes: params.scopes + }); + + // Redirect to GitLab + res.redirect(authUrl.toString()); } - + // Override token exchange to use GitLab OAuth credentials async exchangeAuthorizationCode( client: OAuthClientInformationFull, @@ -120,7 +120,7 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { client_secret: config.GITLAB_OAUTH2_CLIENT_SECRET!, redirect_uris: [config.GITLAB_OAUTH2_REDIRECT_URL!] }; - + // Use GitLab's redirect URI for the token exchange return super.exchangeAuthorizationCode( gitlabClient, @@ -138,37 +138,37 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { // Handle OAuth callback and redirect to client's actual callback URL export const handleOAuthCallback = (req: Request, res: Response): void => { const { code, state, error, error_description } = req.query; - + console.log('OAuth callback received:', { code: !!code, state, error }); - + if (!state) { res.status(400).send('Missing state parameter'); return; } - + // Get the client's actual redirect URI const clientRedirectUri = stateToRedirectUri.get(state as string); - + if (!clientRedirectUri) { console.error(`No redirect URI found for state: ${state}`); res.status(400).send('Invalid state parameter'); return; } - + // Clean up the state mapping stateToRedirectUri.delete(state as string); - + // Build the redirect URL with all parameters const redirectUrl = new URL(clientRedirectUri); - + // Pass through all query parameters if (code) redirectUrl.searchParams.set('code', code as string); if (state) redirectUrl.searchParams.set('state', state as string); if (error) redirectUrl.searchParams.set('error', error as string); if (error_description) redirectUrl.searchParams.set('error_description', error_description as string); - + console.log(`Redirecting to client callback: ${redirectUrl.toString()}`); - + // Redirect to the client's actual callback URL res.redirect(redirectUrl.toString()); }; From 4132d136bc09d7221f830395eaa0bb44ddc62305 Mon Sep 17 00:00:00 2001 From: a Date: Wed, 2 Jul 2025 14:11:16 -0500 Subject: [PATCH 07/24] noot --- index.ts | 44 +++++--- src/oauth.ts | 289 ++++++++++++++++++++++++++++++++++----------------- 2 files changed, 221 insertions(+), 112 deletions(-) diff --git a/index.ts b/index.ts index 00df5a6..f8e4b9a 100644 --- a/index.ts +++ b/index.ts @@ -5,11 +5,10 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express, { Request, Response } from "express"; import {mcpserver} from "./src/mcpserver.js"; import { config, validateConfiguration} from "./src/config.js"; -import { createOAuth2Router, createTokenVerifier, handleOAuthCallback } from "./src/oauth.js"; +import { createGitLabOAuthProvider, GitLabProxyProvider } from "./src/oauth.js"; import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; import { logger } from "./src/logger.js"; import argon2 from "@node-rs/argon2"; -import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; validateConfiguration() @@ -20,11 +19,9 @@ validateConfiguration() */ async function runServer() { try { - // Server startup banner removed - inappropriate use of console.error for logging - // Server version banner removed - inappropriate use of console.error for logging - // API URL banner removed - inappropriate use of console.error for logging - // Server startup banner removed - inappropriate use of console.error for logging + // SSE is actually used to determine whether or not to run in http mode. if (!config.SSE) { + // no authorization in stdio mode const transport = new StdioServerTransport(); await mcpserver.connect(transport); } else { @@ -47,14 +44,16 @@ async function runServer() { // if gitlab oauth client id is set, then we attempt to enable the oauth proxy // NOTE: this is... incredibly insecure. you shouldn't really be using this until more has been worked on it and some semi-proper security review. if (config.GITLAB_OAUTH2_CLIENT_ID) { + // Create the provider + const provider = createGitLabOAuthProvider(); + // Add the callback handler route BEFORE the OAuth router - app.get("/callback", handleOAuthCallback); + app.get("/callback", (req, res) => provider.handleOAuthCallback(req, res)); - const oauth2Proxy = createOAuth2Router() + const oauth2Proxy = provider.createOAuth2Router() console.log("Gitlab OAuth2 proxy enabled"); app.use(oauth2Proxy); - - const tokenVerifier = createTokenVerifier() + const tokenVerifier = provider.createTokenVerifier() const bearerAuthMiddleware = requireBearerAuth({ verifier:tokenVerifier, resourceMetadataUrl: `${config.GITLAB_OAUTH2_BASE_URL}/.well-known/oauth-protected-resource` @@ -65,13 +64,24 @@ async function runServer() { res.status(401).send("Gitlab-Token header must not be set when MCP is running in OAuth2 mode"); return; } - bearerAuthMiddleware(req, res, next) - const authState = req.auth - if(authState) { - // so this means that auth was successful and we have a token - // supposedly, if our server is implemented correctly, we would issue the token that corresponded to the correct user token on the gitlab side - res.locals["gitlabAuthToken"] = authState.token - } + bearerAuthMiddleware(req, res, (err) => { + if (err) { + next(err); + return; + } + // If authentication was successful, get the GitLab token + if (req.auth && req.auth.token) { + const gitlabAccessToken = provider.getGitLabTokenFromProxyToken(req.auth.token); + if (gitlabAccessToken) { + // Update the auth state with the GitLab token + req.auth = { + ...req.auth, + token: gitlabAccessToken + }; + } + } + next(); + }) } } else if(config.GITLAB_PAT_PASSTHROUGH) { console.log("Gitlab PAT passthrough enabled"); diff --git a/src/oauth.ts b/src/oauth.ts index 054301d..b8580ad 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -3,24 +3,62 @@ import { config } from './config.js'; import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; import { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; import { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js'; +import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; import { Request, Response } from 'express'; +import { logger } from './logger.js'; -// Store for dynamically registered clients -const clientRegistry = new Map(); +// Custom provider that handles dynamic registration and maps to GitLab OAuth +class GitLabProxyProvider extends ProxyOAuthServerProvider { + // Store for dynamically registered clients + private clientRegistry = new Map(); -// Map client IDs to their redirect URIs -const clientRedirectUris = new Map(); + // Map client IDs to their redirect URIs + private clientRedirectUris = new Map(); -// Map OAuth state parameters to client redirect URIs -const stateToRedirectUri = new Map(); + // Map OAuth state parameters to client redirect URIs with timestamps + private stateToRedirectUri = new Map(); -// Custom provider that handles dynamic registration and maps to GitLab OAuth -class GitLabProxyProvider extends ProxyOAuthServerProvider { + // Map access tokens to auth info + private tokenToAuthInfo = new Map(); + + // State expiry time in milliseconds (15 minutes) + private readonly STATE_EXPIRY_MS = 15 * 60 * 1000; + + // Token expiry time in milliseconds (1 hour) + private readonly TOKEN_EXPIRY_MS = 60 * 60 * 1000; + + // Cleanup interval + private cleanupInterval: NodeJS.Timeout; + + constructor(options: any) { + super(options); + + // Start cleanup interval + this.cleanupInterval = setInterval(() => { + const now = Date.now(); + + // Clean up expired state mappings + for (const [state, mapping] of this.stateToRedirectUri.entries()) { + if (now - mapping.timestamp > this.STATE_EXPIRY_MS) { + this.stateToRedirectUri.delete(state); + logger.debug(`Cleaned up expired state mapping: ${state}`); + } + } + + // Clean up expired tokens + for (const [token, info] of this.tokenToAuthInfo.entries()) { + if (now - info.timestamp > this.TOKEN_EXPIRY_MS) { + this.tokenToAuthInfo.delete(token); + logger.debug(`Cleaned up expired token`); + } + } + }, 5 * 60 * 1000); + } get clientsStore() { return { getClient: async (clientId: string) => { // Check if this is a registered dynamic client - const client = clientRegistry.get(clientId); + const client = this.clientRegistry.get(clientId); if (client) { return client; } @@ -45,7 +83,7 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { const clientId = `mcp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // Store the client's redirect URIs - clientRedirectUris.set(clientId, clientMetadata.redirect_uris || []); + this.clientRedirectUris.set(clientId, clientMetadata.redirect_uris || []); // Create the client registration const client: OAuthClientInformationFull = { @@ -59,7 +97,7 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { }; // Store the client - clientRegistry.set(clientId, client); + this.clientRegistry.set(clientId, client); return client; } @@ -68,10 +106,13 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { // Override authorization to use GitLab OAuth credentials async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { - // Store the mapping between state and client's actual redirect URI + // Store the mapping between state and client's actual redirect URI with timestamp if (params.state && params.redirectUri) { - stateToRedirectUri.set(params.state, params.redirectUri); - console.log(`Stored state mapping: ${params.state} -> ${params.redirectUri}`); + this.stateToRedirectUri.set(params.state, { + redirectUri: params.redirectUri, + timestamp: Date.now() + }); + console.log(`Stored state mapping: ${params.state} -> ${params.redirectUri} at ${new Date().toISOString()}`); } // Construct the authorization URL directly to ensure proper formatting @@ -105,6 +146,39 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { res.redirect(authUrl.toString()); } + // Method to get redirect URI from state + getRedirectUriFromState(state: string): { redirectUri: string; timestamp: number } | undefined { + return this.stateToRedirectUri.get(state); + } + + // Method to delete state mapping + deleteStateMapping(state: string): void { + this.stateToRedirectUri.delete(state); + } + + // Method to verify token + verifyToken(token: string): { authInfo: AuthInfo; gitlabToken: string; timestamp: number } | undefined { + const tokenInfo = this.tokenToAuthInfo.get(token); + + if (!tokenInfo) { + return undefined; + } + + // Check if token has expired + if (Date.now() - tokenInfo.timestamp > this.TOKEN_EXPIRY_MS) { + this.tokenToAuthInfo.delete(token); + return undefined; + } + + // Check if token has an explicit expiry time + if (tokenInfo.authInfo.expiresAt && tokenInfo.authInfo.expiresAt < Math.floor(Date.now() / 1000)) { + this.tokenToAuthInfo.delete(token); + return undefined; + } + + return tokenInfo; + } + // Override token exchange to use GitLab OAuth credentials async exchangeAuthorizationCode( client: OAuthClientInformationFull, @@ -122,58 +196,130 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { }; // Use GitLab's redirect URI for the token exchange - return super.exchangeAuthorizationCode( + const tokens = await super.exchangeAuthorizationCode( gitlabClient, authorizationCode, codeVerifier, config.GITLAB_OAUTH2_REDIRECT_URL!, resource ); - } -} + // Store the token mapping for our own verification + if (tokens.access_token) { + const authInfo: AuthInfo = { + token: tokens.access_token, + clientId: client.client_id, + scopes: tokens.scope ? tokens.scope.split(' ') : [], + expiresAt: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : undefined + }; + this.tokenToAuthInfo.set(tokens.access_token, { + authInfo, + gitlabToken: tokens.access_token, // The GitLab token is the same as our proxy token + timestamp: Date.now() + }); + } + return tokens; + } -// Handle OAuth callback and redirect to client's actual callback URL -export const handleOAuthCallback = (req: Request, res: Response): void => { - const { code, state, error, error_description } = req.query; + // Override verifyAccessToken to use our internal token store + async verifyAccessToken(token: string): Promise { + const tokenInfo = this.verifyToken(token); - console.log('OAuth callback received:', { code: !!code, state, error }); + if (!tokenInfo) { + throw new Error('Invalid or expired token'); + } - if (!state) { - res.status(400).send('Missing state parameter'); - return; + return tokenInfo.authInfo; } - // Get the client's actual redirect URI - const clientRedirectUri = stateToRedirectUri.get(state as string); + // Handle OAuth callback and redirect to client's actual callback URL + handleOAuthCallback = (req: Request, res: Response): void => { + const { code, state, error, error_description } = req.query; + + logger.debug('OAuth callback received:', { code: !!code, state, error }); + + if (!state) { + res.status(400).send('Missing state parameter'); + return; + } + + // Get the client's actual redirect URI with timestamp + const stateMapping = this.getRedirectUriFromState(state as string); + + if (!stateMapping) { + console.error(`No redirect URI found for state: ${state}`); + res.status(400).send('Invalid state parameter'); + return; + } + + // Check if the state mapping has expired + if (Date.now() - stateMapping.timestamp > this.STATE_EXPIRY_MS) { + console.error(`State mapping expired for state: ${state}`); + this.deleteStateMapping(state as string); + res.status(400).send('State parameter expired'); + return; + } + + const clientRedirectUri = stateMapping.redirectUri; - if (!clientRedirectUri) { - console.error(`No redirect URI found for state: ${state}`); - res.status(400).send('Invalid state parameter'); - return; + // Clean up the state mapping + this.deleteStateMapping(state as string); + + // Build the redirect URL with all parameters + const redirectUrl = new URL(clientRedirectUri); + + // Pass through all query parameters + if (code) redirectUrl.searchParams.set('code', code as string); + if (state) redirectUrl.searchParams.set('state', state as string); + if (error) redirectUrl.searchParams.set('error', error as string); + if (error_description) redirectUrl.searchParams.set('error_description', error_description as string); + + logger.debug(`Redirecting to client callback: ${redirectUrl.toString()}`); + + // Redirect to the client's actual callback URL + res.redirect(redirectUrl.toString()); } - // Clean up the state mapping - stateToRedirectUri.delete(state as string); + // Create OAuth2 router + createOAuth2Router() { + if (!config.GITLAB_OAUTH2_BASE_URL) { + throw new Error("GITLAB_OAUTH2_BASE_URL is not set") + } - // Build the redirect URL with all parameters - const redirectUrl = new URL(clientRedirectUri); + return mcpAuthRouter({ + issuerUrl: new URL(config.GITLAB_OAUTH2_BASE_URL), + baseUrl: new URL(config.GITLAB_OAUTH2_BASE_URL), + authorizationOptions: { + }, + provider: this, + }) + } + + // Create token verifier + createTokenVerifier() { + const tokenVerifier = { + verifyAccessToken: async (token: string) => { + return this.verifyAccessToken(token); + } + } - // Pass through all query parameters - if (code) redirectUrl.searchParams.set('code', code as string); - if (state) redirectUrl.searchParams.set('state', state as string); - if (error) redirectUrl.searchParams.set('error', error as string); - if (error_description) redirectUrl.searchParams.set('error_description', error_description as string); + return tokenVerifier + } - console.log(`Redirecting to client callback: ${redirectUrl.toString()}`); + // Get GitLab token from our proxy token + getGitLabTokenFromProxyToken(proxyToken: string): string | undefined { + const tokenInfo = this.verifyToken(proxyToken); + return tokenInfo?.gitlabToken; + } +} - // Redirect to the client's actual callback URL - res.redirect(redirectUrl.toString()); -}; +// Export the provider class +export { GitLabProxyProvider }; -export const createOAuth2Router = () => { +// Create the GitLab OAuth provider +export const createGitLabOAuthProvider = () => { if(!config.GITLAB_OAUTH2_AUTHORIZATION_URL) { throw new Error("GITLAB_OAUTH2_AUTHORIZATION_URL is not set") } @@ -183,7 +329,6 @@ export const createOAuth2Router = () => { if(!config.GITLAB_OAUTH2_REDIRECT_URL) { throw new Error("GITLAB_OAUTH2_REDIRECT_URIS is not set") } - const redirectUrl = config.GITLAB_OAUTH2_REDIRECT_URL if(!config.GITLAB_OAUTH2_TOKEN_URL) { throw new Error("GITLAB_OAUTH2_TOKEN_URL is not set") } @@ -200,10 +345,9 @@ export const createOAuth2Router = () => { tokenUrl: config.GITLAB_OAUTH2_TOKEN_URL, revocationUrl: config.GITLAB_OAUTH2_REVOCATION_URL, }, - verifyAccessToken: async (token: string) => { - // Use the introspection endpoint to verify the token - const tokenVerifier = createTokenVerifier(); - return await tokenVerifier.verifyAccessToken(token); + verifyAccessToken: async () => { + // This will be overridden by the class method + throw new Error('Should not be called'); }, getClient: async (client_id: string) => { // This is handled by our custom provider's clientsStore @@ -211,52 +355,7 @@ export const createOAuth2Router = () => { } }) - return mcpAuthRouter({ - issuerUrl: new URL(config.GITLAB_OAUTH2_BASE_URL), - baseUrl: new URL(config.GITLAB_OAUTH2_BASE_URL), - authorizationOptions: { - }, - provider: provider, - }) + return provider; } -export const createTokenVerifier = () => { - if(!config.GITLAB_OAUTH2_INTROSPECTION_URL) { - throw new Error("GITLAB_OAUTH2_INTROSPECTION_URL is not set") - } - const introspectionEndpoint = config.GITLAB_OAUTH2_INTROSPECTION_URL - const tokenVerifier = { - verifyAccessToken: async (token: string) => { - - const response = await fetch(introspectionEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - token: token - }).toString() - }); - - if (!response.ok) { - throw new Error(`Invalid or expired token: ${await response.text()}`); - } - - const data = await response.json(); - - // NOTE: we don't implement "strict oauth" here yet, as per this example: https://github.com/modelcontextprotocol/typescript-sdk/blob/1b14bd7fa4dcc436df0fcb2718f86dc376cdd904/src/examples/server/simpleStreamableHttp.ts#L7 - - // Convert the response to AuthInfo format - return { - token, - clientId: data.client_id, - scopes: data.scope ? data.scope.split(' ') : [], - expiresAt: data.exp, - }; - } - } - - return tokenVerifier - -} From 2679714af3738727b290a596d8d73790c413f4fc Mon Sep 17 00:00:00 2001 From: a Date: Wed, 2 Jul 2025 14:12:07 -0500 Subject: [PATCH 08/24] noot --- index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/index.ts b/index.ts index f8e4b9a..d2fc74b 100644 --- a/index.ts +++ b/index.ts @@ -128,7 +128,6 @@ async function runServer() { salt: argon2Salt, }); } - console.log("accessed with token hash", transports[transport.sessionId].tokenHash); res.on("close", () => { delete transports[transport.sessionId]; }); From 4d8d02f2a82fd4ef89ecf47478fef8d170595eac Mon Sep 17 00:00:00 2001 From: a Date: Thu, 3 Jul 2025 17:12:41 -0500 Subject: [PATCH 09/24] noot --- index.ts | 13 +- package-lock.json | 1282 ++++++++++++++++++++++++++++++++++++++++- package.json | 3 + src/authentication.ts | 25 +- src/config.ts | 4 + src/oauth.ts | 341 +++++++---- 6 files changed, 1523 insertions(+), 145 deletions(-) diff --git a/index.ts b/index.ts index ecc051f..61b2d19 100644 --- a/index.ts +++ b/index.ts @@ -56,16 +56,9 @@ async function startStdioServer(): Promise { async function startExpressServer(mode: TransportMode): Promise { const app = express(); - // Add request logging middleware - app.use((req, res, next) => { - logger.debug(`got request ${req.method} ${req.url}`); - next(); - }); - - // Configure authentication based on the environment - const authMiddleware = configureAuthentication(app); + const authMiddleware = await configureAuthentication(app); const argon2Salt = new TextEncoder().encode(config.ARGON2_SALT) - if(mode === TransportMode.STREAMABLE_HTTP) { + if(mode === TransportMode.SSE) { const transports: { [sessionId: string]: { transport: SSEServerTransport @@ -123,7 +116,7 @@ async function startExpressServer(mode: TransportMode): Promise { } }); - } else if (mode === TransportMode.SSE) { + } else if (mode === TransportMode.STREAMABLE_HTTP) { const transports : { [sessionId: string]: { transport: StreamableHTTPServerTransport tokenHash?: string diff --git a/package-lock.json b/package-lock.json index a51a1f4..d4d2db4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,9 @@ "dependencies": { "@modelcontextprotocol/sdk": "1.13.3", "@node-rs/argon2": "^2.0.2", + "@types/better-sqlite3": "^7.6.13", "@types/node-fetch": "^2.6.12", + "better-sqlite3": "^12.2.0", "express": "^5.1.0", "fetch-cookie": "^3.1.0", "form-data": "^4.0.0", @@ -19,6 +21,7 @@ "https-proxy-agent": "^7.0.6", "node-fetch": "^3.3.2", "socks-proxy-agent": "^8.0.5", + "sqlite3": "^5.1.7", "tough-cookie": "^5.1.2", "zod-to-json-schema": "^3.23.5" }, @@ -251,6 +254,13 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -436,6 +446,42 @@ "node": ">= 8" } }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -464,6 +510,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -825,6 +880,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -883,6 +945,33 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -899,6 +988,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -915,6 +1014,28 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC", + "optional": true + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -981,9 +1102,63 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.2.0.tgz", + "integrity": "sha512-eGbYq2CT+tos1fBwLQ/tkBt9J5M3JEHjku4hbvQUePCckkvVf14xWj+1m7dGoK81M/fOjFT7yM9UMeKT/+vFLQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -1027,6 +1202,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1036,6 +1235,36 @@ "node": ">= 0.8" } }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1092,6 +1321,25 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1112,6 +1360,16 @@ "dev": true, "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1138,9 +1396,16 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -1240,6 +1505,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1256,6 +1545,13 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1265,6 +1561,15 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -1295,6 +1600,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1304,6 +1616,42 @@ "node": ">= 0.8" } }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1613,6 +1961,15 @@ "node": ">=18.0.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -1775,6 +2132,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1909,6 +2272,31 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1918,6 +2306,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1955,6 +2364,34 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1968,6 +2405,30 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -1993,6 +2454,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2059,6 +2527,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2071,6 +2546,13 @@ "node": ">= 0.4" } }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -2113,6 +2595,16 @@ "node": ">= 14" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -2125,6 +2617,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", @@ -2192,18 +2704,53 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", @@ -2236,6 +2783,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2249,6 +2806,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2357,6 +2921,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -2364,6 +2941,101 @@ "dev": true, "license": "ISC" }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/make-fetch-happen/node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2439,6 +3111,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -2459,18 +3143,136 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2494,6 +3296,24 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", + "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", @@ -2531,6 +3351,64 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2623,6 +3501,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2668,6 +3562,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2708,6 +3612,32 @@ "node": ">=16.20.0" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2734,6 +3664,27 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2747,6 +3698,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2816,6 +3777,44 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2826,6 +3825,16 @@ "node": ">=4" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -2837,6 +3846,23 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2907,7 +3933,6 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2953,6 +3978,13 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC", + "optional": true + }, "node_modules/set-cookie-parser": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", @@ -3058,6 +4090,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -3112,6 +4196,43 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "license": "BSD-3-Clause" }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -3121,6 +4242,43 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3147,6 +4305,66 @@ "node": ">=8" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -3263,6 +4481,18 @@ } } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3324,6 +4554,26 @@ "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "license": "MIT" }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -3342,6 +4592,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -3400,6 +4656,16 @@ "node": ">= 8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3423,6 +4689,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 238c092..3beeb37 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "dependencies": { "@modelcontextprotocol/sdk": "1.13.3", "@node-rs/argon2": "^2.0.2", + "@types/better-sqlite3": "^7.6.13", "@types/node-fetch": "^2.6.12", + "better-sqlite3": "^12.2.0", "express": "^5.1.0", "fetch-cookie": "^3.1.0", "form-data": "^4.0.0", @@ -42,6 +44,7 @@ "https-proxy-agent": "^7.0.6", "node-fetch": "^3.3.2", "socks-proxy-agent": "^8.0.5", + "sqlite3": "^5.1.7", "tough-cookie": "^5.1.2", "zod-to-json-schema": "^3.23.5" }, diff --git a/src/authentication.ts b/src/authentication.ts index 511825c..ffe1391 100644 --- a/src/authentication.ts +++ b/src/authentication.ts @@ -18,7 +18,7 @@ declare global { * Configure authentication middleware based on the configuration * Supports OAuth2, PAT passthrough, and static PAT modes */ -export function configureAuthentication(app: Express): RequestHandler { +export async function configureAuthentication(app: Express): Promise { // Default middleware that does nothing let authMiddleware: RequestHandler = (_req: Request, _res: Response, next: NextFunction) => { next(); @@ -30,7 +30,7 @@ export function configureAuthentication(app: Express): RequestHandler { logger.warn("Please note that GitLab OAuth2 proxy authentication is not yet fully supported. Use this feature at your own risk"); // Create the provider - const provider = createGitLabOAuthProvider(); + const provider = await createGitLabOAuthProvider(); // Add the callback handler route BEFORE the OAuth router app.get("/callback", (req, res) => provider.handleOAuthCallback(req, res)); @@ -53,26 +53,7 @@ export function configureAuthentication(app: Express): RequestHandler { res.status(401).send("Gitlab-Token header must not be set when MCP is running in OAuth2 mode"); return; } - - bearerAuthMiddleware(req, res, (err) => { - if (err) { - next(err); - return; - } - - // If authentication was successful, get the GitLab token - if (req.auth && req.auth.token) { - const gitlabAccessToken = provider.getGitLabTokenFromProxyToken(req.auth.token); - if (gitlabAccessToken) { - // Update the auth state with the GitLab token - req.auth = { - ...req.auth, - token: gitlabAccessToken - }; - } - } - next(); - }); + bearerAuthMiddleware(req, res, next); }; } // PAT passthrough mode diff --git a/src/config.ts b/src/config.ts index ecd7763..f2a7456 100644 --- a/src/config.ts +++ b/src/config.ts @@ -38,6 +38,9 @@ export const config = { GITLAB_OAUTH2_CLIENT_SECRET: process.env.GITLAB_OAUTH2_CLIENT_SECRET, GITLAB_OAUTH2_REDIRECT_URL: process.env.GITLAB_OAUTH2_REDIRECT_URL, + // we need a database in order for the oauth2 provider to persist clients across restarts. + GITLAB_OAUTH2_DB_PATH: process.env.GITLAB_OAUTH2_DB_PATH || ":memory:", + // base url matters for the redirect url, i think? GITLAB_OAUTH2_BASE_URL: process.env.GITLAB_OAUTH2_BASE_URL, // http://localhost:3002 @@ -49,6 +52,7 @@ export const config = { GITLAB_OAUTH2_REGISTRATION_URL: process.env.GITLAB_OAUTH2_REGISTRATION_URL, // ? GITLAB_OAUTH2_ISSUER_URL: process.env.GITLAB_OAUTH2_ISSUER_URL, // https://gitlab.com + } export const validateConfiguration = ()=> { diff --git a/src/oauth.ts b/src/oauth.ts index 78d2fdb..373cc92 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,25 +1,59 @@ -import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js'; +import { ProxyOAuthServerProvider, ProxyOptions } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js'; import { config } from './config.js'; import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js'; import { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; import { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js'; import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'; +import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js'; import { Request, Response } from 'express'; import { logger } from './logger.js'; +import { Database } from 'better-sqlite3'; // Custom provider that handles dynamic registration and maps to GitLab OAuth class GitLabProxyProvider extends ProxyOAuthServerProvider { - // Store for dynamically registered clients - private clientRegistry = new Map(); - - // Map client IDs to their redirect URIs - private clientRedirectUris = new Map(); - - // Map OAuth state parameters to client redirect URIs with timestamps - private stateToRedirectUri = new Map(); - - // Map access tokens to auth info - private tokenToAuthInfo = new Map(); + // Static async factory method + static async New(options: any): Promise { + // we put this here so we dont initialize this unless we are using the oauth provider + const Database = (await import('better-sqlite3')).default; + const db = new Database(config.GITLAB_OAUTH2_DB_PATH); + + // Create tables if they don't exist + db.exec(` + CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + client_secret TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + grant_types TEXT NOT NULL, + response_types TEXT NOT NULL, + token_endpoint_auth_method TEXT NOT NULL, + client_id_issued_at INTEGER NOT NULL, + metadata TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS client_redirect_uris ( + client_id TEXT PRIMARY KEY, + redirect_uris TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS state_mappings ( + state TEXT PRIMARY KEY, + redirect_uri TEXT NOT NULL, + timestamp INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS access_tokens ( + token TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + scopes TEXT NOT NULL, + expires_at INTEGER, + gitlab_token TEXT NOT NULL, + timestamp INTEGER NOT NULL + ); + `); + + const provider = new GitLabProxyProvider(options, db); + return provider; + } // State expiry time in milliseconds (15 minutes) private readonly STATE_EXPIRY_MS = 15 * 60 * 1000; @@ -30,39 +64,31 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { // Cleanup interval private cleanupInterval: NodeJS.Timeout; - constructor(options: any) { + private db: Database + + constructor(options: ProxyOptions, db: Database) { super(options); + this.db = db; // Start cleanup interval this.cleanupInterval = setInterval(() => { const now = Date.now(); - // Clean up expired state mappings - for (const [state, mapping] of this.stateToRedirectUri.entries()) { - if (now - mapping.timestamp > this.STATE_EXPIRY_MS) { - this.stateToRedirectUri.delete(state); - logger.debug(`Cleaned up expired state mapping: ${state}`); - } - } + try { + // Clean up expired state mappings + db.prepare('DELETE FROM state_mappings WHERE timestamp < ?').run(now - this.STATE_EXPIRY_MS); - // Clean up expired tokens - for (const [token, info] of this.tokenToAuthInfo.entries()) { - if (now - info.timestamp > this.TOKEN_EXPIRY_MS) { - this.tokenToAuthInfo.delete(token); - logger.debug(`Cleaned up expired token`); - } + // Clean up expired tokens + db.prepare('DELETE FROM access_tokens WHERE timestamp < ? OR (expires_at IS NOT NULL AND expires_at < ?)') + .run(now - this.TOKEN_EXPIRY_MS, Math.floor(now / 1000)); + } catch (err) { + logger.error('Error during cleanup:', err); } }, 5 * 60 * 1000); } - get clientsStore() { + get clientsStore(): OAuthRegisteredClientsStore { return { getClient: async (clientId: string) => { - // Check if this is a registered dynamic client - const client = this.clientRegistry.get(clientId); - if (client) { - return client; - } - // Check if this is the actual GitLab client if (clientId === config.GITLAB_OAUTH2_CLIENT_ID) { return { @@ -75,31 +101,78 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { }; } - return undefined; + // Check if this is a registered dynamic client + const row = this.db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(clientId) as { + client_id: string; + client_secret: string; + redirect_uris: string; + grant_types: string; + response_types: string; + token_endpoint_auth_method: string; + client_id_issued_at: number; + metadata: string; + } | undefined; + + if (!row) { + return undefined; + } + + const client: OAuthClientInformationFull = { + ...JSON.parse(row.metadata), + client_id: row.client_id, + client_secret: row.client_secret, + redirect_uris: JSON.parse(row.redirect_uris), + grant_types: JSON.parse(row.grant_types), + response_types: JSON.parse(row.response_types), + token_endpoint_auth_method: row.token_endpoint_auth_method, + client_id_issued_at: row.client_id_issued_at + }; + + return client; }, registerClient: async (clientMetadata: any) => { // Generate a unique client ID for this MCP client - const clientId = `mcp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - // Store the client's redirect URIs - this.clientRedirectUris.set(clientId, clientMetadata.redirect_uris || []); + const clientId = `mcp_${Date.now()}_${Math.random().toString(36)}`; // Create the client registration const client: OAuthClientInformationFull = { ...clientMetadata, client_id: clientId, - client_secret: `secret_${Math.random().toString(36).substr(2, 20)}`, + client_secret: `secret_${Math.random().toString(36)}`, client_id_issued_at: Math.floor(Date.now() / 1000), grant_types: clientMetadata.grant_types || ['authorization_code', 'refresh_token'], response_types: clientMetadata.response_types || ['code'], token_endpoint_auth_method: clientMetadata.token_endpoint_auth_method || 'client_secret_post' }; - // Store the client - this.clientRegistry.set(clientId, client); + // Store the client in database + try { + // Store client + this.db.prepare(` + INSERT INTO oauth_clients + (client_id, client_secret, redirect_uris, grant_types, response_types, + token_endpoint_auth_method, client_id_issued_at, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + client.client_id, + client.client_secret, + JSON.stringify(client.redirect_uris), + JSON.stringify(client.grant_types), + JSON.stringify(client.response_types), + client.token_endpoint_auth_method, + client.client_id_issued_at, + JSON.stringify(clientMetadata) + ); + + // Store redirect URIs + this.db.prepare('INSERT INTO client_redirect_uris (client_id, redirect_uris) VALUES (?, ?)') + .run(clientId, JSON.stringify(clientMetadata.redirect_uris || [])); - return client; + return client; + } catch (err) { + throw err; + } } }; } @@ -108,11 +181,14 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { // Store the mapping between state and client's actual redirect URI with timestamp if (params.state && params.redirectUri) { - this.stateToRedirectUri.set(params.state, { - redirectUri: params.redirectUri, - timestamp: Date.now() - }); - console.log(`Stored state mapping: ${params.state} -> ${params.redirectUri} at ${new Date().toISOString()}`); + try { + this.db.prepare('INSERT OR REPLACE INTO state_mappings (state, redirect_uri, timestamp) VALUES (?, ?, ?)') + .run(params.state, params.redirectUri, Date.now()); + logger.debug(`Stored state mapping: ${params.state} -> ${params.redirectUri} at ${new Date().toISOString()}`); + } catch (err) { + logger.error('Error storing state mapping:', err); + throw err; + } } // Construct the authorization URL directly to ensure proper formatting @@ -136,7 +212,7 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { // GitLab doesn't support the 'resource' parameter, so we skip it - console.log(`Redirecting to GitLab OAuth:`, { + logger.debug(`Redirecting to GitLab OAuth:`, { url: authUrl.toString(), scopes: gitlabScopes, requested_scopes: params.scopes @@ -147,36 +223,73 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { } // Method to get redirect URI from state - getRedirectUriFromState(state: string): { redirectUri: string; timestamp: number } | undefined { - return this.stateToRedirectUri.get(state); + async getRedirectUriFromState(state: string): Promise<{ redirectUri: string; timestamp: number } | undefined> { + const row = this.db.prepare('SELECT redirect_uri, timestamp FROM state_mappings WHERE state = ?').get(state) as { + redirect_uri: string; + timestamp: number; + } | undefined; + + if (!row) { + return undefined; + } + + return { + redirectUri: row.redirect_uri, + timestamp: row.timestamp + }; } // Method to delete state mapping - deleteStateMapping(state: string): void { - this.stateToRedirectUri.delete(state); + async deleteStateMapping(state: string): Promise { + this.db.prepare('DELETE FROM state_mappings WHERE state = ?').run(state); } // Method to verify token - verifyToken(token: string): { authInfo: AuthInfo; gitlabToken: string; timestamp: number } | undefined { - const tokenInfo = this.tokenToAuthInfo.get(token); - - if (!tokenInfo) { + async verifyToken(token: string): Promise<{ authInfo: AuthInfo; gitlabToken: string; timestamp: number } | undefined> { + const row = this.db.prepare('SELECT * FROM access_tokens WHERE token = ?').get(token) as { + token: string; + client_id: string; + scopes: string; + expires_at: number | null; + gitlab_token: string; + timestamp: number; + } | undefined; + + if (!row) { return undefined; } - // Check if token has expired - if (Date.now() - tokenInfo.timestamp > this.TOKEN_EXPIRY_MS) { - this.tokenToAuthInfo.delete(token); + const now = Date.now(); + + // Check if token has expired by timestamp + if (now - row.timestamp > this.TOKEN_EXPIRY_MS) { + await this.deleteAccessToken(token); return undefined; } // Check if token has an explicit expiry time - if (tokenInfo.authInfo.expiresAt && tokenInfo.authInfo.expiresAt < Math.floor(Date.now() / 1000)) { - this.tokenToAuthInfo.delete(token); + if (row.expires_at && row.expires_at < Math.floor(now / 1000)) { + await this.deleteAccessToken(token); return undefined; } - return tokenInfo; + const authInfo: AuthInfo = { + token: row.token, + clientId: row.client_id, + scopes: JSON.parse(row.scopes), + expiresAt: row.expires_at ?? undefined + }; + + return { + authInfo, + gitlabToken: row.gitlab_token, + timestamp: row.timestamp + }; + } + + // Helper method to delete access token + private async deleteAccessToken(token: string): Promise { + this.db.prepare('DELETE FROM access_tokens WHERE token = ?').run(token); } // Override token exchange to use GitLab OAuth credentials @@ -203,22 +316,29 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { config.GITLAB_OAUTH2_REDIRECT_URL!, resource ); - logger.debug("GitLab OAuth2 token exchange response:", tokens); // Store the token mapping for our own verification if (tokens.access_token) { - const authInfo: AuthInfo = { - token: tokens.access_token, - clientId: client.client_id, - scopes: tokens.scope ? tokens.scope.split(' ') : [], - expiresAt: tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : undefined - }; - - this.tokenToAuthInfo.set(tokens.access_token, { - authInfo, - gitlabToken: tokens.access_token, // The GitLab token is the same as our proxy token - timestamp: Date.now() - }); + const expiresAt = tokens.expires_in ? Math.floor(Date.now() / 1000) + tokens.expires_in : null; + const scopes = tokens.scope ? tokens.scope.split(' ') : []; + + try { + this.db.prepare(` + INSERT OR REPLACE INTO access_tokens + (token, client_id, scopes, expires_at, gitlab_token, timestamp) + VALUES (?, ?, ?, ?, ?, ?) + `).run( + tokens.access_token, + client.client_id, + JSON.stringify(scopes), + expiresAt, + tokens.access_token, // The GitLab token is the same as our proxy token + Date.now() + ); + } catch (err) { + logger.error('Error storing access token:', err); + throw err; + } } return tokens; @@ -226,7 +346,7 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { // Override verifyAccessToken to use our internal token store async verifyAccessToken(token: string): Promise { - const tokenInfo = this.verifyToken(token); + const tokenInfo = await this.verifyToken(token); if (!tokenInfo) { throw new Error('Invalid or expired token'); @@ -236,7 +356,7 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { } // Handle OAuth callback and redirect to client's actual callback URL - handleOAuthCallback = (req: Request, res: Response): void => { + handleOAuthCallback = async (req: Request, res: Response): Promise => { const { code, state, error, error_description } = req.query; logger.debug('OAuth callback received:', { code: !!code, state, error }); @@ -246,41 +366,46 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { return; } - // Get the client's actual redirect URI with timestamp - const stateMapping = this.getRedirectUriFromState(state as string); + try { + // Get the client's actual redirect URI with timestamp + const stateMapping = await this.getRedirectUriFromState(state as string); - if (!stateMapping) { - console.error(`No redirect URI found for state: ${state}`); - res.status(400).send('Invalid state parameter'); - return; - } + if (!stateMapping) { + logger.error(`No redirect URI found for state: ${state}`); + res.status(400).send('Invalid state parameter'); + return; + } - // Check if the state mapping has expired - if (Date.now() - stateMapping.timestamp > this.STATE_EXPIRY_MS) { - console.error(`State mapping expired for state: ${state}`); - this.deleteStateMapping(state as string); - res.status(400).send('State parameter expired'); - return; - } + // Check if the state mapping has expired + if (Date.now() - stateMapping.timestamp > this.STATE_EXPIRY_MS) { + logger.error(`State mapping expired for state: ${state}`); + await this.deleteStateMapping(state as string); + res.status(400).send('State parameter expired'); + return; + } - const clientRedirectUri = stateMapping.redirectUri; + const clientRedirectUri = stateMapping.redirectUri; - // Clean up the state mapping - this.deleteStateMapping(state as string); + // Clean up the state mapping + await this.deleteStateMapping(state as string); - // Build the redirect URL with all parameters - const redirectUrl = new URL(clientRedirectUri); + // Build the redirect URL with all parameters + const redirectUrl = new URL(clientRedirectUri); - // Pass through all query parameters - if (code) redirectUrl.searchParams.set('code', code as string); - if (state) redirectUrl.searchParams.set('state', state as string); - if (error) redirectUrl.searchParams.set('error', error as string); - if (error_description) redirectUrl.searchParams.set('error_description', error_description as string); + // Pass through all query parameters + if (code) redirectUrl.searchParams.set('code', code as string); + if (state) redirectUrl.searchParams.set('state', state as string); + if (error) redirectUrl.searchParams.set('error', error as string); + if (error_description) redirectUrl.searchParams.set('error_description', error_description as string); - logger.debug(`Redirecting to client callback: ${redirectUrl.toString()}`); + logger.debug(`Redirecting to client callback: ${redirectUrl.toString()}`); - // Redirect to the client's actual callback URL - res.redirect(redirectUrl.toString()); + // Redirect to the client's actual callback URL + res.redirect(redirectUrl.toString()); + } catch (err) { + logger.error('Error handling OAuth callback:', err); + res.status(500).send('Internal server error'); + } } // Create OAuth2 router @@ -310,8 +435,8 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { } // Get GitLab token from our proxy token - getGitLabTokenFromProxyToken(proxyToken: string): string | undefined { - const tokenInfo = this.verifyToken(proxyToken); + async getGitLabTokenFromProxyToken(proxyToken: string): Promise { + const tokenInfo = await this.verifyToken(proxyToken); return tokenInfo?.gitlabToken; } } @@ -320,7 +445,7 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { export { GitLabProxyProvider }; // Create the GitLab OAuth provider -export const createGitLabOAuthProvider = () => { +export const createGitLabOAuthProvider = async () => { if(!config.GITLAB_OAUTH2_AUTHORIZATION_URL) { throw new Error("GITLAB_OAUTH2_AUTHORIZATION_URL is not set") } @@ -340,7 +465,7 @@ export const createGitLabOAuthProvider = () => { throw new Error("GITLAB_OAUTH2_BASE_URL is not set") } - const provider = new GitLabProxyProvider({ + const provider = await GitLabProxyProvider.New({ endpoints: { authorizationUrl: config.GITLAB_OAUTH2_AUTHORIZATION_URL, tokenUrl: config.GITLAB_OAUTH2_TOKEN_URL, From 99c40b269ae337cc9826d67fac480243895a436c Mon Sep 17 00:00:00 2001 From: a Date: Thu, 3 Jul 2025 17:23:55 -0500 Subject: [PATCH 10/24] noot --- package-lock.json | 45 ++++++++++++++++++++++++++++++ src/oauth.ts | 70 +++++++++++++++++++++++++++-------------------- 2 files changed, 85 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index d4d2db4..fd06a19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@node-rs/argon2": "^2.0.2", "@types/better-sqlite3": "^7.6.13", "@types/node-fetch": "^2.6.12", + "argon2": "^0.43.0", "better-sqlite3": "^12.2.0", "express": "^5.1.0", "fetch-cookie": "^3.1.0", @@ -472,6 +473,15 @@ "node": ">=10" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -1043,6 +1053,30 @@ "dev": true, "license": "MIT" }, + "node_modules/argon2": { + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.43.0.tgz", + "integrity": "sha512-u/HKLcbWShVDhkfwI4hWyiUf3qyX8QhTfaIv2cWE18uqhXCmR5hb6Ed7oqYi2KCQegeAnRhiFzbjzm7i5yl1GA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "node-addon-api": "^8.3.1", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/argon2/node_modules/node-addon-api": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.4.0.tgz", + "integrity": "sha512-D9DI/gXHvVmjHS08SVch0Em8G5S1P+QWtU31appcKT/8wFSPRcdHadIFSAntdMMVM5zz+/DL+bL/gz3UDppqtg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3376,6 +3410,17 @@ "node": ">= 10.12.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", diff --git a/src/oauth.ts b/src/oauth.ts index 373cc92..5beff7a 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -8,6 +8,7 @@ import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/au import { Request, Response } from 'express'; import { logger } from './logger.js'; import { Database } from 'better-sqlite3'; +import * as argon2 from 'argon2'; // Custom provider that handles dynamic registration and maps to GitLab OAuth class GitLabProxyProvider extends ProxyOAuthServerProvider { @@ -42,11 +43,10 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { ); CREATE TABLE IF NOT EXISTS access_tokens ( - token TEXT PRIMARY KEY, + token_hash TEXT PRIMARY KEY, client_id TEXT NOT NULL, scopes TEXT NOT NULL, expires_at INTEGER, - gitlab_token TEXT NOT NULL, timestamp INTEGER NOT NULL ); `); @@ -246,50 +246,64 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { // Method to verify token async verifyToken(token: string): Promise<{ authInfo: AuthInfo; gitlabToken: string; timestamp: number } | undefined> { - const row = this.db.prepare('SELECT * FROM access_tokens WHERE token = ?').get(token) as { - token: string; + // Get all token hashes to check against + const rows = this.db.prepare('SELECT * FROM access_tokens').all() as Array<{ + token_hash: string; client_id: string; scopes: string; expires_at: number | null; - gitlab_token: string; timestamp: number; - } | undefined; + }>; - if (!row) { + // Find the matching token by verifying against each hash + let matchingRow = null; + for (const row of rows) { + try { + if (await argon2.verify(row.token_hash, token)) { + matchingRow = row; + break; + } + } catch (err) { + // Skip invalid hashes + continue; + } + } + + if (!matchingRow) { return undefined; } const now = Date.now(); // Check if token has expired by timestamp - if (now - row.timestamp > this.TOKEN_EXPIRY_MS) { - await this.deleteAccessToken(token); + if (now - matchingRow.timestamp > this.TOKEN_EXPIRY_MS) { + await this.deleteAccessTokenByHash(matchingRow.token_hash); return undefined; } // Check if token has an explicit expiry time - if (row.expires_at && row.expires_at < Math.floor(now / 1000)) { - await this.deleteAccessToken(token); + if (matchingRow.expires_at && matchingRow.expires_at < Math.floor(now / 1000)) { + await this.deleteAccessTokenByHash(matchingRow.token_hash); return undefined; } const authInfo: AuthInfo = { - token: row.token, - clientId: row.client_id, - scopes: JSON.parse(row.scopes), - expiresAt: row.expires_at ?? undefined + token: token, // Return the original token + clientId: matchingRow.client_id, + scopes: JSON.parse(matchingRow.scopes), + expiresAt: matchingRow.expires_at ?? undefined }; return { authInfo, - gitlabToken: row.gitlab_token, - timestamp: row.timestamp + gitlabToken: token, // Return the original token since we don't store gitlab_token anymore + timestamp: matchingRow.timestamp }; } - // Helper method to delete access token - private async deleteAccessToken(token: string): Promise { - this.db.prepare('DELETE FROM access_tokens WHERE token = ?').run(token); + // Helper method to delete access token by hash + private async deleteAccessTokenByHash(tokenHash: string): Promise { + this.db.prepare('DELETE FROM access_tokens WHERE token_hash = ?').run(tokenHash); } // Override token exchange to use GitLab OAuth credentials @@ -323,16 +337,18 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { const scopes = tokens.scope ? tokens.scope.split(' ') : []; try { + // Hash the token before storing + const tokenHash = await argon2.hash(tokens.access_token); + this.db.prepare(` INSERT OR REPLACE INTO access_tokens - (token, client_id, scopes, expires_at, gitlab_token, timestamp) - VALUES (?, ?, ?, ?, ?, ?) + (token_hash, client_id, scopes, expires_at, timestamp) + VALUES (?, ?, ?, ?, ?) `).run( - tokens.access_token, + tokenHash, client.client_id, JSON.stringify(scopes), expiresAt, - tokens.access_token, // The GitLab token is the same as our proxy token Date.now() ); } catch (err) { @@ -433,12 +449,6 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { return tokenVerifier } - - // Get GitLab token from our proxy token - async getGitLabTokenFromProxyToken(proxyToken: string): Promise { - const tokenInfo = await this.verifyToken(proxyToken); - return tokenInfo?.gitlabToken; - } } // Export the provider class From 884930902ae74bce5cdc84d1083804aab05158b4 Mon Sep 17 00:00:00 2001 From: a Date: Thu, 3 Jul 2025 17:35:48 -0500 Subject: [PATCH 11/24] add some ai documentation --- docs/oauth2_proxy.md | 150 ++++++++++++++++++++++++++++++++ docs/passthrough_mode.md | 183 +++++++++++++++++++++++++++++++++++++++ src/oauth.ts | 11 ++- 3 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 docs/oauth2_proxy.md create mode 100644 docs/passthrough_mode.md diff --git a/docs/oauth2_proxy.md b/docs/oauth2_proxy.md new file mode 100644 index 0000000..5e594eb --- /dev/null +++ b/docs/oauth2_proxy.md @@ -0,0 +1,150 @@ +# GitLab MCP OAuth2 Proxy Configuration + +This guide explains how to configure the GitLab MCP server to use OAuth2 proxy authentication, allowing users to authenticate with GitLab OAuth2 applications instead of using personal access tokens. + +## Overview + +The OAuth2 proxy mode enables dynamic client registration and token management, providing a more secure and flexible authentication method compared to static personal access tokens. + +**Note:** OAuth2 proxy mode is only available when using SSE or STREAMABLE_HTTP transport modes. It is not supported with STDIO transport. + +## Required Environment Variables + +To enable OAuth2 proxy mode, you must set the following environment variables: + +### Core OAuth2 Configuration + +```bash +# GitLab OAuth2 Application Credentials +GITLAB_OAUTH2_CLIENT_ID=your_gitlab_app_id +GITLAB_OAUTH2_CLIENT_SECRET=your_gitlab_app_secret + +# GitLab OAuth2 Endpoints +GITLAB_OAUTH2_AUTHORIZATION_URL=https://gitlab.com/oauth/authorize +GITLAB_OAUTH2_TOKEN_URL=https://gitlab.com/oauth/token +GITLAB_OAUTH2_ISSUER_URL=https://gitlab.com +GITLAB_OAUTH2_BASE_URL=http://localhost:3000 # Your MCP server URL + +# OAuth2 Redirect Configuration +GITLAB_OAUTH2_REDIRECT_URL=http://localhost:3000/callback +``` + +### Optional Configuration + +```bash +# Token Revocation Endpoint (optional) +GITLAB_OAUTH2_REVOCATION_URL=https://gitlab.com/oauth/revoke + +# Database Path (defaults to in-memory if not set) +GITLAB_OAUTH2_DB_PATH=/path/to/oauth.db +``` + +## Setting Up a GitLab OAuth2 Application + +1. Go to your GitLab instance (e.g., https://gitlab.com) +2. Navigate to **User Settings** → **Applications** +3. Create a new application with: + - **Name**: Your MCP Server (or any descriptive name) + - **Redirect URI**: Must match `GITLAB_OAUTH2_REDIRECT_URL` exactly (e.g., `http://localhost:3000/callback`) + - **Scopes**: Select the following: + - `api` - Access the authenticated user's API + - `openid` - Authenticate using OpenID Connect + - `profile` - Read user's profile data + - `email` - Read user's email address + +4. After creation, GitLab will provide: + - **Application ID**: Use this for `GITLAB_OAUTH2_CLIENT_ID` + - **Secret**: Use this for `GITLAB_OAUTH2_CLIENT_SECRET` + +## Configuration Examples + +### Development Setup (localhost) + +```bash +# .env file +GITLAB_OAUTH2_CLIENT_ID=your_app_id_here +GITLAB_OAUTH2_CLIENT_SECRET=your_app_secret_here +GITLAB_OAUTH2_AUTHORIZATION_URL=https://gitlab.com/oauth/authorize +GITLAB_OAUTH2_TOKEN_URL=https://gitlab.com/oauth/token +GITLAB_OAUTH2_ISSUER_URL=https://gitlab.com +GITLAB_OAUTH2_BASE_URL=http://localhost:3000 +GITLAB_OAUTH2_REDIRECT_URL=http://localhost:3000/callback +``` + +### Production Setup + +```bash +# .env file +GITLAB_OAUTH2_CLIENT_ID=your_app_id_here +GITLAB_OAUTH2_CLIENT_SECRET=your_app_secret_here +GITLAB_OAUTH2_AUTHORIZATION_URL=https://gitlab.company.com/oauth/authorize +GITLAB_OAUTH2_TOKEN_URL=https://gitlab.company.com/oauth/token +GITLAB_OAUTH2_ISSUER_URL=https://gitlab.company.com +GITLAB_OAUTH2_BASE_URL=https://mcp.company.com +GITLAB_OAUTH2_REDIRECT_URL=https://mcp.company.com/callback +GITLAB_OAUTH2_DB_PATH=/var/lib/gitlab-mcp/oauth.db +``` + +## Database Storage + +By default, the OAuth2 proxy uses an in-memory SQLite database. For production use, specify a persistent database path: + +```bash +GITLAB_OAUTH2_DB_PATH=/path/to/persistent/oauth.db +``` + +The database stores: +- OAuth client registrations +- State mappings for OAuth flow +- Access token hashes (using Argon2 for security) + +## Security Considerations + +1. **Client Secrets**: Never commit `GITLAB_OAUTH2_CLIENT_SECRET` to version control +2. **HTTPS**: Always use HTTPS for production deployments +3. **Token Storage**: Access tokens are hashed using Argon2 before storage +4. **Token Expiry**: Tokens expire after 1 hour by default +5. **State Expiry**: OAuth state parameters expire after 15 minutes + +## Troubleshooting + +### Common Issues + +1. **"Protected resource URL mismatch"** + - Ensure `GITLAB_OAUTH2_BASE_URL` matches your server's actual URL + - Check that the redirect URI in GitLab matches `GITLAB_OAUTH2_REDIRECT_URL` + +2. **"Invalid redirect URI"** + - The redirect URI must match exactly (including protocol and port) + - No trailing slashes unless specified in GitLab + +3. **"Invalid scope"** + - Ensure your GitLab OAuth app has the required scopes enabled + - The MCP server requests: `api`, `openid`, `profile`, `email` + +### Debug Mode + +Enable debug logging to troubleshoot OAuth2 issues: + +```bash +export VERBOSE="true" +``` + +## Starting the Server + +OAuth2 proxy mode requires SSE or STREAMABLE_HTTP transport. + +The server will log: +``` +Configuring GitLab OAuth2 proxy authentication +``` + +**Note:** STDIO transport mode does not support OAuth2 proxy authentication. + +## Client Configuration + +MCP clients connecting to an OAuth2-enabled server should use the OAuth2 flow instead of providing a GitLab token directly. The server will handle dynamic client registration and token management automatically. + +## Note + +OAuth2 proxy support is currently in beta. While functional, it may have limitations compared to personal access token authentication. Please report any issues to the project's issue tracker. diff --git a/docs/passthrough_mode.md b/docs/passthrough_mode.md new file mode 100644 index 0000000..1a736eb --- /dev/null +++ b/docs/passthrough_mode.md @@ -0,0 +1,183 @@ +# GitLab MCP Passthrough Authentication Mode + +This guide explains how to configure the GitLab MCP server to use passthrough authentication mode, where users provide their own GitLab Personal Access Token (PAT) with each request. + +## Overview + +Passthrough mode allows multiple users to use the same MCP server instance with their own GitLab credentials. Each user provides their GitLab Personal Access Token via the `Gitlab-Token` header, and the server uses that token for all GitLab API requests. + +This mode is ideal for: +- Multi-user environments +- Scenarios where you don't want to store tokens on the server +- Testing with different access levels + +## Configuration + +To enable passthrough mode, set the following environment variable: + +```bash +GITLAB_PAT_PASSTHROUGH=true +``` + +**Important:** When using passthrough mode, do not set: +- `GITLAB_PERSONAL_ACCESS_TOKEN` +- `GITLAB_OAUTH2_CLIENT_ID` (or any OAuth2 configuration) + +## How It Works + +1. The MCP server starts without any pre-configured authentication +2. Each client request must include a `Gitlab-Token` header with a valid GitLab PAT +3. The server uses the provided token for that specific request +4. No tokens are stored or cached by the server + +## Client Configuration + +### Using with MCP Client + +When connecting to a passthrough-enabled server, clients must provide the GitLab token with each request: + +```typescript +// Example client configuration +const client = new MCPClient({ + url: 'http://localhost:3000', + headers: { + 'Gitlab-Token': 'your-gitlab-personal-access-token' + } +}); +``` + +### Using with cURL + +```bash +curl -H "Gitlab-Token: your-gitlab-personal-access-token" \ + http://localhost:3000/your-endpoint +``` + +### Using with HTTP Libraries + +```javascript +// Node.js with fetch +const response = await fetch('http://localhost:3000/your-endpoint', { + headers: { + 'Gitlab-Token': 'your-gitlab-personal-access-token' + } +}); +``` + +## Creating a GitLab Personal Access Token + +1. Go to GitLab (e.g., https://gitlab.com) +2. Navigate to **User Settings** → **Access Tokens** +3. Create a new token with: + - **Token name**: Descriptive name (e.g., "MCP Client") + - **Expiration date**: Set as needed + - **Scopes**: Select based on your needs: + - `api` - Full API access (recommended) + - `read_api` - Read-only API access + - `read_repository` - Read repository content + - `write_repository` - Write repository content + +4. Copy the generated token and use it in the `Gitlab-Token` header + +## Security Considerations + +1. **Token Transmission**: Tokens are sent with every request + - Always use HTTPS in production to encrypt tokens in transit + - Never log or store tokens on the client side in plain text + +2. **No Server Storage**: The server does not store any tokens + - Each request is authenticated independently + - No session management or token caching + +3. **Token Scope**: Users control their own access levels + - Each user's token determines what they can access + - Server has no control over permissions + +## Error Handling + +### Missing Token +If a request is made without the `Gitlab-Token` header: +``` +Status: 401 Unauthorized +Body: "Please set a Gitlab-Token header in your request" +``` + +### Invalid Token Format +If the token is not a string or is sent multiple times: +``` +Status: 401 Unauthorized +Body: "Gitlab-Token must only be set once" +``` + +### Invalid GitLab Token +If GitLab rejects the token: +``` +Status: 401 Unauthorized +Body: GitLab API error message +``` + +## Starting the Server + +Start the server with passthrough mode enabled: + +```bash +GITLAB_PAT_PASSTHROUGH=true npm dev +``` + +The server will log: +``` +Configuring GitLab PAT passthrough authentication. Users must set the Gitlab-Token header in their requests +``` + +## Comparison with Other Modes + +| Feature | Passthrough | Static PAT | OAuth2 Proxy | +|---------|------------|------------|--------------| +| Multi-user support | ✅ Yes | ❌ No | ✅ Yes | +| Token storage | ❌ None | ✅ Server | ✅ Database | +| Setup complexity | Low | Low | High | +| Transport support | All | All | SSE/HTTP only | +| User token control | ✅ Full | ❌ None | ⚠️ Limited | + +## Example: Full Request Flow + +1. User creates a GitLab PAT with necessary scopes +2. User configures their MCP client with the token: + ```javascript + const client = new MCPClient({ + url: 'http://localhost:3000', + headers: { + 'Gitlab-Token': 'glpat-xxxxxxxxxxxxxxxxxxxx' + } + }); + ``` +3. Client makes a request to list projects +4. MCP server receives request with token header +5. Server forwards the token to GitLab API +6. GitLab validates token and returns data +7. Server returns data to client + +## Troubleshooting + +### Token Not Working +- Verify the token has not expired +- Check that the token has the required scopes +- Ensure the token is from the correct GitLab instance +- Try the token directly with GitLab API to verify it works + +### Multiple Users Issues +- Each user must use their own token +- Tokens should not be shared between users +- Consider OAuth2 mode for better multi-user management + +## Best Practices + +1. **Token Rotation**: Regularly rotate PATs for security +2. **Minimal Scopes**: Use tokens with only necessary scopes +3. **HTTPS Only**: Always use HTTPS in production +4. **Client Security**: Store tokens securely on client side +5. **Monitoring**: Log request counts but never log tokens + +## Note + +Passthrough mode is ideal for development and multi-user scenarios where each user manages their own credentials. For production deployments with many users, consider using OAuth2 proxy mode for better token management and security. diff --git a/src/oauth.ts b/src/oauth.ts index 5beff7a..10533a7 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -9,6 +9,7 @@ import { Request, Response } from 'express'; import { logger } from './logger.js'; import { Database } from 'better-sqlite3'; import * as argon2 from 'argon2'; +import { randomBytes } from 'crypto'; // Custom provider that handles dynamic registration and maps to GitLab OAuth class GitLabProxyProvider extends ProxyOAuthServerProvider { @@ -132,14 +133,18 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { }, registerClient: async (clientMetadata: any) => { - // Generate a unique client ID for this MCP client - const clientId = `mcp_${Date.now()}_${Math.random().toString(36)}`; + // Generate a unique client ID for this MCP client using crypto-safe random + const randomId = randomBytes(16).toString('hex'); + const clientId = `mcp_${Date.now()}_${randomId}`; + // Generate a secure client secret + const randomSecret = randomBytes(32).toString('hex'); + // Create the client registration const client: OAuthClientInformationFull = { ...clientMetadata, client_id: clientId, - client_secret: `secret_${Math.random().toString(36)}`, + client_secret: `secret_${randomSecret}`, client_id_issued_at: Math.floor(Date.now() / 1000), grant_types: clientMetadata.grant_types || ['authorization_code', 'refresh_token'], response_types: clientMetadata.response_types || ['code'], From 00ef65f3f4aaf34f624165e0096b160c021c6744 Mon Sep 17 00:00:00 2001 From: a Date: Thu, 3 Jul 2025 17:39:28 -0500 Subject: [PATCH 12/24] multiple transports --- index.ts | 106 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 55 insertions(+), 51 deletions(-) diff --git a/index.ts b/index.ts index 61b2d19..dbbc541 100644 --- a/index.ts +++ b/index.ts @@ -14,34 +14,28 @@ import { randomUUID } from "crypto"; validateConfiguration() -enum TransportMode { - STDIO = 'stdio', - SSE = 'sse', - STREAMABLE_HTTP = 'streamable-http' +interface TransportModes { + stdio: boolean; + sse: boolean; + streamableHttp: boolean; } - /** - * Determine the transport mode based on environment variables and availability - * - * Transport mode priority (highest to lowest): - * 1. STREAMABLE_HTTP - * 2. SSE - * 3. STDIO + * Determine which transport modes are enabled based on environment variables + * If both SSE and STREAMABLE_HTTP are disabled, defaults to STDIO */ -function determineTransportMode(): TransportMode { - // Check for streamable-http support (highest priority) - if (config.STREAMABLE_HTTP) { - return TransportMode.STREAMABLE_HTTP; - } - - // Check for SSE support (medium priority) - if (config.SSE) { - return TransportMode.SSE; - } - - // Default to stdio (lowest priority) - return TransportMode.STDIO; +function determineTransportModes(): TransportModes { + const sseEnabled = config.SSE; + const streamableHttpEnabled = config.STREAMABLE_HTTP; + + // If neither SSE nor STREAMABLE_HTTP are enabled, use STDIO + const stdioEnabled = !sseEnabled && !streamableHttpEnabled; + + return { + stdio: stdioEnabled, + sse: sseEnabled, + streamableHttp: streamableHttpEnabled + }; } /** @@ -52,13 +46,20 @@ async function startStdioServer(): Promise { await mcpserver.connect(transport); } +interface ExpressServerOptions { + sseEnabled: boolean; + streamableHttpEnabled: boolean; +} + // used for the sse and streamable http transports to share auth -async function startExpressServer(mode: TransportMode): Promise { +async function startExpressServer(options: ExpressServerOptions): Promise { + const { sseEnabled, streamableHttpEnabled } = options; const app = express(); const authMiddleware = await configureAuthentication(app); const argon2Salt = new TextEncoder().encode(config.ARGON2_SALT) - if(mode === TransportMode.SSE) { + + if(sseEnabled) { const transports: { [sessionId: string]: { transport: SSEServerTransport @@ -116,7 +117,9 @@ async function startExpressServer(mode: TransportMode): Promise { } }); - } else if (mode === TransportMode.STREAMABLE_HTTP) { + } + + if (streamableHttpEnabled) { const transports : { [sessionId: string]: { transport: StreamableHTTPServerTransport tokenHash?: string @@ -189,8 +192,6 @@ async function startExpressServer(mode: TransportMode): Promise { } }); - } else { - throw new Error("Unknown transport mode for express server: " +mode); } app.get("/health", (_: Request, res: Response) => { @@ -202,33 +203,36 @@ async function startExpressServer(mode: TransportMode): Promise { app.listen(Number(config.PORT), config.HOST, () => { - logger.log(`GitLab MCP Server running with ${mode} transport`); + const enabledModes = []; + if (sseEnabled) enabledModes.push('SSE'); + if (streamableHttpEnabled) enabledModes.push('Streamable HTTP'); + logger.log(`GitLab MCP Server running with ${enabledModes.join(' and ')} transport(s)`); const colorGreen = "\x1b[32m"; const colorReset = "\x1b[0m"; - logger.log(`${colorGreen}Endpoint: http://${config.HOST}:${config.PORT}/sse${colorReset}`); + if (sseEnabled) { + logger.log(`${colorGreen}SSE Endpoint: http://${config.HOST}:${config.PORT}/sse${colorReset}`); + } + if (streamableHttpEnabled) { + logger.log(`${colorGreen}Streamable HTTP Endpoint: http://${config.HOST}:${config.PORT}/mcp${colorReset}`); + } }); } /** - * Initialize server with specific transport mode - * Handle transport-specific initialization logic + * Initialize server based on enabled transport modes */ -async function initializeServerByTransportMode(mode: TransportMode): Promise { - logger.log('Initializing server with transport mode:', mode); - switch (mode) { - case TransportMode.STDIO: - logger.warn('Starting GitLab MCP Server with stdio transport'); - await startStdioServer(); - break; - case TransportMode.SSE: - case TransportMode.STREAMABLE_HTTP: - logger.warn('Starting GitLab MCP Server with SSE transport'); - await startExpressServer(mode); - break; - default: - // This should never happen with proper enum usage, but TypeScript requires it - const exhaustiveCheck: never = mode; - throw new Error(`Unknown transport mode: ${exhaustiveCheck}`); +async function initializeServer(modes: TransportModes): Promise { + if (modes.stdio) { + logger.warn('Starting GitLab MCP Server with stdio transport'); + await startStdioServer(); + } else if (modes.sse || modes.streamableHttp) { + logger.warn('Starting GitLab MCP Server with HTTP transport(s)'); + await startExpressServer({ + sseEnabled: modes.sse, + streamableHttpEnabled: modes.streamableHttp + }); + } else { + throw new Error('No transport mode enabled'); } } @@ -238,8 +242,8 @@ async function initializeServerByTransportMode(mode: TransportMode): Promise Date: Mon, 7 Jul 2025 13:02:42 -0500 Subject: [PATCH 13/24] argon2 fix --- src/oauth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/oauth.ts b/src/oauth.ts index 10533a7..6bc951d 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -8,7 +8,7 @@ import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/au import { Request, Response } from 'express'; import { logger } from './logger.js'; import { Database } from 'better-sqlite3'; -import * as argon2 from 'argon2'; +import argon2 from '@node-rs/argon2'; import { randomBytes } from 'crypto'; // Custom provider that handles dynamic registration and maps to GitLab OAuth @@ -139,7 +139,7 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { // Generate a secure client secret const randomSecret = randomBytes(32).toString('hex'); - + // Create the client registration const client: OAuthClientInformationFull = { ...clientMetadata, From 6b818a2195df09dfe834cbbdb41a24f664a144e6 Mon Sep 17 00:00:00 2001 From: a Date: Mon, 7 Jul 2025 13:02:56 -0500 Subject: [PATCH 14/24] npm i --- package-lock.json | 45 --------------------------------------------- 1 file changed, 45 deletions(-) diff --git a/package-lock.json b/package-lock.json index fd06a19..d4d2db4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@node-rs/argon2": "^2.0.2", "@types/better-sqlite3": "^7.6.13", "@types/node-fetch": "^2.6.12", - "argon2": "^0.43.0", "better-sqlite3": "^12.2.0", "express": "^5.1.0", "fetch-cookie": "^3.1.0", @@ -473,15 +472,6 @@ "node": ">=10" } }, - "node_modules/@phc/format": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", - "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -1053,30 +1043,6 @@ "dev": true, "license": "MIT" }, - "node_modules/argon2": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.43.0.tgz", - "integrity": "sha512-u/HKLcbWShVDhkfwI4hWyiUf3qyX8QhTfaIv2cWE18uqhXCmR5hb6Ed7oqYi2KCQegeAnRhiFzbjzm7i5yl1GA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@phc/format": "^1.0.0", - "node-addon-api": "^8.3.1", - "node-gyp-build": "^4.8.4" - }, - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/argon2/node_modules/node-addon-api": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.4.0.tgz", - "integrity": "sha512-D9DI/gXHvVmjHS08SVch0Em8G5S1P+QWtU31appcKT/8wFSPRcdHadIFSAntdMMVM5zz+/DL+bL/gz3UDppqtg==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3410,17 +3376,6 @@ "node": ">= 10.12.0" } }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", From 5d1a32e65512411ac2839983220fb3ed093554e5 Mon Sep 17 00:00:00 2001 From: a Date: Tue, 8 Jul 2025 12:03:47 -0500 Subject: [PATCH 15/24] change default token expiry --- src/oauth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/oauth.ts b/src/oauth.ts index 6bc951d..a0d4fd6 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -59,8 +59,8 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { // State expiry time in milliseconds (15 minutes) private readonly STATE_EXPIRY_MS = 15 * 60 * 1000; - // Token expiry time in milliseconds (1 hour) - private readonly TOKEN_EXPIRY_MS = 60 * 60 * 1000; + // Token expiry time in milliseconds (7 days) + private readonly TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // Cleanup interval private cleanupInterval: NodeJS.Timeout; From cb1896241e2a0fef1a9899eb59b14b8e36e177a0 Mon Sep 17 00:00:00 2001 From: a Date: Tue, 15 Jul 2025 20:31:08 -0500 Subject: [PATCH 16/24] noot --- index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/index.ts b/index.ts index 0473d6e..2a62fe8 100644 --- a/index.ts +++ b/index.ts @@ -10,7 +10,6 @@ import { configureAuthentication } from "./src/authentication.js"; import { logger } from "./src/logger.js"; import argon2 from "@node-rs/argon2"; import { randomUUID } from "crypto"; -import { pino } from 'pino'; validateConfiguration() From c6a15ad881e064130f629e7bbd4ef15a007c7a3a Mon Sep 17 00:00:00 2001 From: a Date: Thu, 17 Jul 2025 09:29:59 -0500 Subject: [PATCH 17/24] fix build --- customSchemas.ts => src/customSchemas.ts | 2 +- src/gitlabhandler.ts | 76 ++++++++++++------------ src/mcpserver.ts | 1 + 3 files changed, 40 insertions(+), 39 deletions(-) rename customSchemas.ts => src/customSchemas.ts (93%) diff --git a/customSchemas.ts b/src/customSchemas.ts similarity index 93% rename from customSchemas.ts rename to src/customSchemas.ts index 2ba0191..77addfc 100644 --- a/customSchemas.ts +++ b/src/customSchemas.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { pino } from 'pino'; -const logger = pino({ +export const logger = pino({ level: process.env.LOG_LEVEL || 'info', transport: { target: 'pino-pretty', diff --git a/src/gitlabhandler.ts b/src/gitlabhandler.ts index d4b51ec..adcff13 100644 --- a/src/gitlabhandler.ts +++ b/src/gitlabhandler.ts @@ -337,7 +337,7 @@ export class GitlabHandler extends GitlabSession { /** * Get a single issue from a GitLab project */ - async getIssue(projectId: string, issueIid: number): Promise { + async getIssue(projectId: string, issueIid: number | string): Promise { projectId = decodeURIComponent(projectId); const url = new URL( `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}` @@ -355,7 +355,7 @@ export class GitlabHandler extends GitlabSession { */ async updateIssue( projectId: string, - issueIid: number, + issueIid: number | string, options: Omit, "project_id" | "issue_iid"> ): Promise { projectId = decodeURIComponent(projectId); @@ -382,7 +382,7 @@ export class GitlabHandler extends GitlabSession { /** * Delete an issue from a GitLab project */ - async deleteIssue(projectId: string, issueIid: number): Promise { + async deleteIssue(projectId: string, issueIid: number | string): Promise { projectId = decodeURIComponent(projectId); const url = new URL( `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/issues/${issueIid}` @@ -401,7 +401,7 @@ export class GitlabHandler extends GitlabSession { */ async listIssueLinks( projectId: string, - issueIid: number + issueIid: number | string ): Promise { projectId = decodeURIComponent(projectId); const url = new URL( @@ -420,8 +420,8 @@ export class GitlabHandler extends GitlabSession { */ async getIssueLink( projectId: string, - issueIid: number, - issueLinkId: number + issueIid: number | string, + issueLinkId: number | string ): Promise { projectId = decodeURIComponent(projectId); const url = new URL( @@ -442,9 +442,9 @@ this.getEffectiveProjectId(projectId) */ async createIssueLink( projectId: string, - issueIid: number, + issueIid: number | string, targetProjectId: string, - targetIssueIid: number, + targetIssueIid: number | string, linkType: "relates_to" | "blocks" | "is_blocked_by" = "relates_to" ): Promise { projectId = decodeURIComponent(projectId); @@ -473,8 +473,8 @@ this.getEffectiveProjectId(projectId) */ async deleteIssueLink( projectId: string, - issueIid: number, - issueLinkId: number + issueIid: number | string, + issueLinkId: number | string ): Promise { projectId = decodeURIComponent(projectId); const url = new URL( @@ -538,7 +538,7 @@ this.getEffectiveProjectId(projectId) async listDiscussions( projectId: string, resourceType: "issues" | "merge_requests", - resourceIid: number, + resourceIid: number | string, options: PaginationOptions = {} ): Promise { projectId = decodeURIComponent(projectId); @@ -588,7 +588,7 @@ this.getEffectiveProjectId(projectId) */ async listMergeRequestDiscussions( projectId: string, - mergeRequestIid: number, + mergeRequestIid: number | string, options: PaginationOptions = {} ): Promise { return this.listDiscussions(projectId, "merge_requests", mergeRequestIid, options); @@ -599,7 +599,7 @@ this.getEffectiveProjectId(projectId) */ async listIssueDiscussions( projectId: string, - issueIid: number, + issueIid: number | string, options: PaginationOptions = {} ): Promise { return this.listDiscussions(projectId, "issues", issueIid, options); @@ -610,9 +610,9 @@ this.getEffectiveProjectId(projectId) */ async updateMergeRequestNote( projectId: string, - mergeRequestIid: number, + mergeRequestIid: number | string, discussionId: string, - noteId: number, + noteId: number | string, body?: string, resolved?: boolean ): Promise { @@ -646,9 +646,9 @@ this.getEffectiveProjectId(projectId) */ async updateIssueNote( projectId: string, - issueIid: number, + issueIid: number | string, discussionId: string, - noteId: number, + noteId: number | string, body: string ): Promise { projectId = decodeURIComponent(projectId); @@ -676,7 +676,7 @@ this.getEffectiveProjectId(projectId) */ async createIssueNote( projectId: string, - issueIid: number, + issueIid: number | string, discussionId: string, body: string, createdAt?: string @@ -709,7 +709,7 @@ this.getEffectiveProjectId(projectId) */ async createMergeRequestNote( projectId: string, - mergeRequestIid: number, + mergeRequestIid: number | string, discussionId: string, body: string, createdAt?: string @@ -964,7 +964,7 @@ this.getEffectiveProjectId(projectId) */ async getMergeRequest( projectId: string, - mergeRequestIid?: number, + mergeRequestIid?: number | string, branchName?: string ): Promise { projectId = decodeURIComponent(projectId); @@ -1004,7 +1004,7 @@ this.getEffectiveProjectId(projectId) */ async getMergeRequestDiffs( projectId: string, - mergeRequestIid?: number, + mergeRequestIid?: number | string, branchName?: string, view?: "inline" | "parallel" ): Promise { @@ -1040,7 +1040,7 @@ this.getEffectiveProjectId(projectId) */ async listMergeRequestDiffs( projectId: string, - mergeRequestIid?: number, + mergeRequestIid?: number | string, branchName?: string, page?: number, perPage?: number, @@ -1122,7 +1122,7 @@ this.getEffectiveProjectId(projectId) z.infer, "project_id" | "merge_request_iid" | "source_branch" >, - mergeRequestIid?: number, + mergeRequestIid?: number | string, branchName?: string ): Promise { projectId = decodeURIComponent(projectId); @@ -1155,7 +1155,7 @@ this.getEffectiveProjectId(projectId) async createNote( projectId: string, noteableType: "issue" | "merge_request", - noteableIid: number, + noteableIid: number | string, body: string ): Promise { projectId = decodeURIComponent(projectId); @@ -1184,7 +1184,7 @@ this.getEffectiveProjectId(projectId) */ async createMergeRequestThread( projectId: string, - mergeRequestIid: number, + mergeRequestIid: number | string, body: string, position?: MergeRequestThreadPosition, createdAt?: string @@ -1630,7 +1630,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); /** * Get details of a specific pipeline */ - async getPipeline(projectId: string, pipelineId: number): Promise { + async getPipeline(projectId: string, pipelineId: number | string): Promise { projectId = decodeURIComponent(projectId); const url = new URL( `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines/${pipelineId}` @@ -1652,7 +1652,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); */ async listPipelineJobs( projectId: string, - pipelineId: number, + pipelineId: number | string, options: Omit = {} ): Promise { projectId = decodeURIComponent(projectId); @@ -1684,7 +1684,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); /** * Get a specific pipeline job */ - async getPipelineJob(projectId: string, jobId: number): Promise { + async getPipelineJob(projectId: string, jobId: number | string): Promise { projectId = decodeURIComponent(projectId); const url = new URL(`${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/jobs/${jobId}`); @@ -1702,7 +1702,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); /** * Get the output/trace of a pipeline job */ - async getPipelineJobOutput(projectId: string, jobId: number, limit?: number, offset?: number): Promise { + async getPipelineJobOutput(projectId: string, jobId: number | string, limit?: number, offset?: number): Promise { projectId = decodeURIComponent(projectId); const url = new URL( `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/jobs/${jobId}/trace` @@ -1777,7 +1777,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); /** * Retry a pipeline */ - async retryPipeline(projectId: string, pipelineId: number): Promise { + async retryPipeline(projectId: string, pipelineId: number | string): Promise { projectId = decodeURIComponent(projectId); const url = new URL( `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry` @@ -1795,7 +1795,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); /** * Cancel a pipeline */ - async cancelPipeline(projectId: string, pipelineId: number): Promise { + async cancelPipeline(projectId: string, pipelineId: number | string): Promise { projectId = decodeURIComponent(projectId); const url = new URL( `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel` @@ -1885,7 +1885,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); */ async getProjectMilestone( projectId: string, - milestoneId: number + milestoneId: number | string ): Promise { projectId = decodeURIComponent(projectId); const url = new URL( @@ -1923,7 +1923,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); */ async editProjectMilestone( projectId: string, - milestoneId: number, + milestoneId: number | string, options: Omit, "project_id" | "milestone_id"> ): Promise { projectId = decodeURIComponent(projectId); @@ -1944,7 +1944,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); /** * Delete a milestone from a GitLab project */ - async deleteProjectMilestone(projectId: string, milestoneId: number): Promise { + async deleteProjectMilestone(projectId: string, milestoneId: number | string): Promise { projectId = decodeURIComponent(projectId); const url = new URL( `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}` @@ -1960,7 +1960,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); /** * Get all issues assigned to a single milestone */ - async getMilestoneIssues(projectId: string, milestoneId: number): Promise { + async getMilestoneIssues(projectId: string, milestoneId: number | string): Promise { projectId = decodeURIComponent(projectId); const url = new URL( `${config.GITLAB_API_URL}/projects/${encodeURIComponent(this.getEffectiveProjectId(projectId))}/milestones/${milestoneId}/issues` @@ -1977,7 +1977,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); */ async getMilestoneMergeRequests( projectId: string, - milestoneId: number + milestoneId: number | string ): Promise { projectId = decodeURIComponent(projectId); const url = new URL( @@ -1997,7 +1997,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); */ async promoteProjectMilestone( projectId: string, - milestoneId: number + milestoneId: number | string ): Promise { projectId = decodeURIComponent(projectId); const url = new URL( @@ -2016,7 +2016,7 @@ const url = new URL(`${config.GITLAB_API_URL}/namespaces`); /** * Get all burndown chart events for a single milestone */ - async getMilestoneBurndownEvents(projectId: string, milestoneId: number): Promise { + async getMilestoneBurndownEvents(projectId: string, milestoneId: number | string): Promise { projectId = decodeURIComponent(projectId); const url = new URL( `${config.GITLAB_API_URL}/projects/${encodeURIComponent( diff --git a/src/mcpserver.ts b/src/mcpserver.ts index c830b22..257ab60 100644 --- a/src/mcpserver.ts +++ b/src/mcpserver.ts @@ -1,5 +1,6 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import {logger} from "./customSchemas.js" import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; From ba6a75d3efb8d5b60bbb0df1a1327663d0ffc1f0 Mon Sep 17 00:00:00 2001 From: a Date: Thu, 17 Jul 2025 13:06:41 -0500 Subject: [PATCH 18/24] remove unused env var --- docs/oauth2_proxy.md | 7 ------- src/config.ts | 1 - 2 files changed, 8 deletions(-) diff --git a/docs/oauth2_proxy.md b/docs/oauth2_proxy.md index 5e594eb..895f533 100644 --- a/docs/oauth2_proxy.md +++ b/docs/oauth2_proxy.md @@ -122,13 +122,6 @@ The database stores: - Ensure your GitLab OAuth app has the required scopes enabled - The MCP server requests: `api`, `openid`, `profile`, `email` -### Debug Mode - -Enable debug logging to troubleshoot OAuth2 issues: - -```bash -export VERBOSE="true" -``` ## Starting the Server diff --git a/src/config.ts b/src/config.ts index f2a7456..016a8c5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,7 +5,6 @@ export const config = { STREAMABLE_HTTP: process.env.STREAMABLE_HTTP === "true", IS_OLD : process.env.GITLAB_IS_OLD === "true", GITLAB_READ_ONLY_MODE : process.env.GITLAB_READ_ONLY_MODE === "true", - VERBOSE: ["true","TRUE","1"].includes(process.env.VERBOSE || "0"), USE_GITLAB_WIKI : process.env.USE_GITLAB_WIKI === "true", USE_MILESTONE : process.env.USE_MILESTONE === "true", USE_PIPELINE : process.env.USE_PIPELINE === "true", From a5c9db89f247ae636bc3e333508d58019b320b91 Mon Sep 17 00:00:00 2001 From: a Date: Thu, 17 Jul 2025 13:33:29 -0500 Subject: [PATCH 19/24] add some logging --- index.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++-------- src/oauth.ts | 11 ++++++---- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/index.ts b/index.ts index 2a62fe8..2875db0 100644 --- a/index.ts +++ b/index.ts @@ -76,11 +76,19 @@ async function startExpressServer(options: ExpressServerOptions): Promise transports[transport.sessionId].tokenHash = await argon2.hash(req.auth.token, { salt: argon2Salt, }); + logger.debug({ + tokenHash: transports[transport.sessionId].tokenHash?.slice(-8), + }, "created new auth session") } res.on("close", () => { delete transports[transport.sessionId]; }); - await mcpserver.connect(transport); + try { + await mcpserver.connect(transport); + }catch(e) { + logger.error({e}, "Transport error connecting to MCP server:"); + res.status(500).send("Internal server error"); + } }); app.post("/messages",authMiddleware, async (req: Request, res: Response) => { @@ -104,6 +112,7 @@ async function startExpressServer(options: ExpressServerOptions): Promise res.status(401).send("No valid token info found in request"); return; } + const verified = await argon2.verify(tokenHash, gitlabToken, { salt: argon2Salt, }); @@ -111,19 +120,27 @@ async function startExpressServer(options: ExpressServerOptions): Promise res.status(401).send("Token does not match session"); return; } + logger.debug({ + tokenHash: tokenHash.slice(-8), + }, "auth token verified") } if (transport) { - await transport.handlePostMessage(req, res); + try { + await transport.handlePostMessage(req, res); + } catch (e) { + logger.error({e}, "Transport error handling message"); + res.status(500).send("Internal server error"); + } } }); } if (streamableHttpEnabled) { - const transports : { [sessionId: string]: { - transport: StreamableHTTPServerTransport - tokenHash?: string - }} = {}; + const transports : { [sessionId: string]: { + transport: StreamableHTTPServerTransport + tokenHash?: string + }} = {}; // Streamable HTTP endpoint - handles both session creation and message handling app.post('/mcp', authMiddleware, async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string; @@ -149,9 +166,18 @@ async function startExpressServer(options: ExpressServerOptions): Promise res.status(401).send("Token does not match session"); return; } + logger.debug({ + tokenHash: session.tokenHash.slice(-8), + }, "auth token verified") } transport = session.transport; - await transport.handleRequest(req, res, req.body); + try { + await transport.handleRequest(req, res, req.body); + } catch (e) { + logger.error("Transport error handling request:", e); + res.status(500).send("Internal server error"); + return + } } else { // Create new transport for new session transport = new StreamableHTTPServerTransport({ @@ -165,6 +191,9 @@ async function startExpressServer(options: ExpressServerOptions): Promise transports[newSessionId].tokenHash = argon2.hashSync(req.auth.token, { salt: argon2Salt, }); + logger.debug({ + tokenHash: transports[newSessionId].tokenHash.slice(-8), + }, "auth session created for token") } logger.warn(`Streamable HTTP session initialized: ${newSessionId}`); } @@ -180,8 +209,20 @@ async function startExpressServer(options: ExpressServerOptions): Promise }; // Connect transport to MCP server before handling the request - await mcpserver.connect(transport); - await transport.handleRequest(req, res, req.body); + try { + await mcpserver.connect(transport); + } catch (e) { + logger.error({e}, "Transport error connecting to MCP server:"); + res.status(500).send("Internal server error"); + return + } + try { + await transport.handleRequest(req, res, req.body); + } catch (e) { + logger.error({e},"Transport error handling request:"); + res.status(500).send("Internal server error"); + return + } } } catch (error) { logger.error('Streamable HTTP error:', error); diff --git a/src/oauth.ts b/src/oauth.ts index a0d4fd6..73d7c7e 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -217,11 +217,11 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { // GitLab doesn't support the 'resource' parameter, so we skip it - logger.debug(`Redirecting to GitLab OAuth:`, { + logger.debug({ url: authUrl.toString(), scopes: gitlabScopes, requested_scopes: params.scopes - }); + },`Redirecting to GitLab OAuth`); // Redirect to GitLab res.redirect(authUrl.toString()); @@ -380,7 +380,7 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { handleOAuthCallback = async (req: Request, res: Response): Promise => { const { code, state, error, error_description } = req.query; - logger.debug('OAuth callback received:', { code: !!code, state, error }); + logger.debug({ code: !!code, state, error }, 'OAuth callback received'); if (!state) { res.status(400).send('Missing state parameter'); @@ -418,8 +418,11 @@ class GitLabProxyProvider extends ProxyOAuthServerProvider { if (state) redirectUrl.searchParams.set('state', state as string); if (error) redirectUrl.searchParams.set('error', error as string); if (error_description) redirectUrl.searchParams.set('error_description', error_description as string); + if(error) { + logger.debug({error}, "oauth callback error"); + } - logger.debug(`Redirecting to client callback: ${redirectUrl.toString()}`); + logger.debug(`sending redirecting to client callback ${state}`); // Redirect to the client's actual callback URL res.redirect(redirectUrl.toString()); From bcc48638eefb47c9a640b83ebc4d20229e1c3b2d Mon Sep 17 00:00:00 2001 From: a Date: Thu, 17 Jul 2025 13:36:18 -0500 Subject: [PATCH 20/24] add GITLAB_API_URL to the examples --- docs/oauth2_proxy.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/oauth2_proxy.md b/docs/oauth2_proxy.md index 895f533..005d32c 100644 --- a/docs/oauth2_proxy.md +++ b/docs/oauth2_proxy.md @@ -62,6 +62,8 @@ GITLAB_OAUTH2_DB_PATH=/path/to/oauth.db ```bash # .env file +GITLAB_API_URL=https://gitlab.com + GITLAB_OAUTH2_CLIENT_ID=your_app_id_here GITLAB_OAUTH2_CLIENT_SECRET=your_app_secret_here GITLAB_OAUTH2_AUTHORIZATION_URL=https://gitlab.com/oauth/authorize @@ -75,6 +77,8 @@ GITLAB_OAUTH2_REDIRECT_URL=http://localhost:3000/callback ```bash # .env file +GITLAB_API_URL=https://gitlab.company.com + GITLAB_OAUTH2_CLIENT_ID=your_app_id_here GITLAB_OAUTH2_CLIENT_SECRET=your_app_secret_here GITLAB_OAUTH2_AUTHORIZATION_URL=https://gitlab.company.com/oauth/authorize From 7f7062ac9cbec7110f239c79394006e0dad69e29 Mon Sep 17 00:00:00 2001 From: a Date: Mon, 4 Aug 2025 18:00:01 -0500 Subject: [PATCH 21/24] argon2 warning --- index.ts | 2 +- package-lock.json | 45 +++++------------------ package.json | 2 +- src/argon2wrapper.ts | 85 ++++++++++++++++++++++++++++++++++++++++++++ src/config.ts | 18 +++++++++- src/oauth.ts | 2 +- 6 files changed, 113 insertions(+), 41 deletions(-) create mode 100644 src/argon2wrapper.ts diff --git a/index.ts b/index.ts index 2875db0..533f775 100644 --- a/index.ts +++ b/index.ts @@ -8,7 +8,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ import { config, validateConfiguration} from "./src/config.js"; import { configureAuthentication } from "./src/authentication.js"; import { logger } from "./src/logger.js"; -import argon2 from "@node-rs/argon2"; +import argon2 from "./src/argon2wrapper.js"; import { randomUUID } from "crypto"; diff --git a/package-lock.json b/package-lock.json index 05c8317..f8a4f72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.13.3", - "@node-rs/argon2": "^2.0.2", + "@noble/hashes": "^1.8.0", "@types/better-sqlite3": "^7.6.13", "@types/node-fetch": "^2.6.12", "better-sqlite3": "^12.2.0", @@ -369,45 +369,16 @@ "node": ">=18" } }, - "node_modules/@node-rs/argon2": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.0.2.tgz", - "integrity": "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==", + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", "engines": { - "node": ">= 10" + "node": "^14.21.3 || >=16" }, - "optionalDependencies": { - "@node-rs/argon2-android-arm-eabi": "2.0.2", - "@node-rs/argon2-android-arm64": "2.0.2", - "@node-rs/argon2-darwin-arm64": "2.0.2", - "@node-rs/argon2-darwin-x64": "2.0.2", - "@node-rs/argon2-freebsd-x64": "2.0.2", - "@node-rs/argon2-linux-arm-gnueabihf": "2.0.2", - "@node-rs/argon2-linux-arm64-gnu": "2.0.2", - "@node-rs/argon2-linux-arm64-musl": "2.0.2", - "@node-rs/argon2-linux-x64-gnu": "2.0.2", - "@node-rs/argon2-linux-x64-musl": "2.0.2", - "@node-rs/argon2-wasm32-wasi": "2.0.2", - "@node-rs/argon2-win32-arm64-msvc": "2.0.2", - "@node-rs/argon2-win32-ia32-msvc": "2.0.2", - "@node-rs/argon2-win32-x64-msvc": "2.0.2" - } - }, - "node_modules/@node-rs/argon2-linux-x64-gnu": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz", - "integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/@nodelib/fs.scandir": { diff --git a/package.json b/package.json index a289e25..3835b61 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.13.3", - "@node-rs/argon2": "^2.0.2", + "@noble/hashes": "^1.8.0", "@types/better-sqlite3": "^7.6.13", "@types/node-fetch": "^2.6.12", "better-sqlite3": "^12.2.0", diff --git a/src/argon2wrapper.ts b/src/argon2wrapper.ts new file mode 100644 index 0000000..60dde94 --- /dev/null +++ b/src/argon2wrapper.ts @@ -0,0 +1,85 @@ +import { argon2id } from '@noble/hashes/argon2'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; +import { randomBytes } from 'crypto'; + +// Default options similar to @node-rs/argon2 +const DEFAULT_OPTIONS = { + t: 3, // iterations + m: 65536, // 64MB memory + p: 4, // parallelism + maxmem: 2 ** 32 - 1 +}; + +interface Argon2Options { + salt?: Uint8Array; +} + +/** + * Hash a password using argon2id + */ +export async function hash(password: string, options?: Argon2Options): Promise { + const salt = options?.salt || randomBytes(16); + const passwordBytes = utf8ToBytes(password); + + const hashBytes = argon2id(passwordBytes, salt, DEFAULT_OPTIONS); + + // Store salt and hash together for verification later + // Format: salt:hash (both as hex) + return `${bytesToHex(salt)}:${bytesToHex(hashBytes)}`; +} + +/** + * Synchronous version of hash + */ +export function hashSync(password: string, options?: Argon2Options): string { + const salt = options?.salt || randomBytes(16); + const passwordBytes = utf8ToBytes(password); + + const hashBytes = argon2id(passwordBytes, salt, DEFAULT_OPTIONS); + + // Store salt and hash together for verification later + // Format: salt:hash (both as hex) + return `${bytesToHex(salt)}:${bytesToHex(hashBytes)}`; +} + +/** + * Verify a password against a hash + */ +export async function verify(storedHash: string, password: string, options?: Argon2Options): Promise { + try { + // If options.salt is provided, it means we're using the old format + // where salt was provided separately + if (options?.salt) { + const passwordBytes = utf8ToBytes(password); + const hashBytes = argon2id(passwordBytes, options.salt, DEFAULT_OPTIONS); + const newHash = bytesToHex(hashBytes); + + // storedHash might be just the hash part without salt prefix + const hashPart = storedHash.includes(':') ? storedHash.split(':')[1] : storedHash; + return newHash === hashPart; + } + + // New format: salt:hash + const [saltHex, hashHex] = storedHash.split(':'); + if (!saltHex || !hashHex) { + return false; + } + + const salt = hexToBytes(saltHex); + const passwordBytes = utf8ToBytes(password); + + const computedHashBytes = argon2id(passwordBytes, salt, DEFAULT_OPTIONS); + const computedHashHex = bytesToHex(computedHashBytes); + + return computedHashHex === hashHex; + } catch (error) { + return false; + } +} + +// Export as default object to match @node-rs/argon2 interface +export default { + hash, + hashSync, + verify +}; \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 3b0024a..f03cc79 100644 --- a/src/config.ts +++ b/src/config.ts @@ -57,7 +57,23 @@ export const config = { } export const validateConfiguration = ()=> { - + // Check if using default ARGON2_SALT + if (config.ARGON2_SALT === unsafeDefaultArgon2Salt) { + console.error('\n' + '='.repeat(80)); + console.error('⚠️ WARNING: USING DEFAULT ARGON2_SALT VALUE!'); + console.error('='.repeat(80)); + console.error(''); + console.error('You are using the default ARGON2_SALT value which is INSECURE.'); + console.error('This salt is publicly known and makes your password hashes vulnerable.'); + console.error(''); + console.error('Please set the ARGON2_SALT environment variable to a unique, random value:'); + console.error(' export ARGON2_SALT="your-unique-random-salt-here"'); + console.error(''); + console.error('You can generate a secure salt with:'); + console.error(' openssl rand -base64 32'); + console.error(''); + console.error('='.repeat(80) + '\n'); + } // check that only one of GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_OAUTH2_CLIENT_ID, GITLAB_PAT_PASSTHROUGH is set const onlyOnOf = [ diff --git a/src/oauth.ts b/src/oauth.ts index 73d7c7e..56fd193 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -8,7 +8,7 @@ import { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/au import { Request, Response } from 'express'; import { logger } from './logger.js'; import { Database } from 'better-sqlite3'; -import argon2 from '@node-rs/argon2'; +import argon2 from './argon2wrapper.js'; import { randomBytes } from 'crypto'; // Custom provider that handles dynamic registration and maps to GitLab OAuth From 4ab6eb186c16cc8534f649fe76b71f5e4dc82b9d Mon Sep 17 00:00:00 2001 From: Hunter Wittenborn Date: Tue, 12 Aug 2025 08:14:52 -0500 Subject: [PATCH 22/24] Fix list of tools --- README.md | 159 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 83 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 253a814..d4fc4e1 100644 --- a/README.md +++ b/README.md @@ -186,80 +186,87 @@ $ sh scripts/image_push.sh docker_user_name ## Tools 🛠️ + -`verify_namespace` - Verify if a namespace path exists -`update_wiki_page` - Update an existing wiki page in a GitLab project -`update_merge_request` - Update a merge request (Either mergeRequestIid or branchName must be provided) -`update_merge_request_note` - Modify an existing merge request thread note -`update_label` - Update an existing label in a project -`update_issue` - Update an issue in a GitLab project -`update_issue_note` - Modify an existing issue thread note -`update_draft_note` - Update an existing draft note -`search_repositories` - Search for GitLab projects -`retry_pipeline` - Retry a failed or canceled pipeline -`push_files` - Push multiple files to a GitLab project in a single commit -`publish_draft_note` - Publish a single draft note -`promote_milestone` - Promote a milestone to the next stage -`my_issues` - List issues assigned to the authenticated user -`mr_discussions` - List discussion items for a merge request -`list_wiki_pages` - List wiki pages in a GitLab project -`list_projects` - List projects accessible by the current user -`list_project_members` - List members of a GitLab project -`list_pipelines` - List pipelines in a GitLab project with filtering options -`list_pipeline_trigger_jobs` - List all trigger jobs (bridges) in a specific pipeline that trigger downstream pipelines -`list_pipeline_jobs` - List all jobs in a specific pipeline -`list_namespaces` - List all namespaces available to the current user -`list_milestones` - List milestones in a GitLab project with filtering options -`list_merge_requests` - List merge requests in a GitLab project with filtering options -`list_merge_request_diffs` - List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided) -`list_labels` - List labels for a project -`list_issues` - List issues in a GitLab project with filtering options -`list_issue_links` - List all issue links for a specific issue -`list_issue_discussions` - List discussions for an issue in a GitLab project -`list_group_projects` - List projects in a GitLab group with filtering options -`list_draft_notes` - List draft notes for a merge request -`get_wiki_page` - Get details of a specific wiki page -`get_users` - Get GitLab user details by usernames -`get_repository_tree` - Get the repository tree for a GitLab project (list files and directories) -`get_project` - Get details of a specific project -`get_pipeline` - Get details of a specific pipeline in a GitLab project -`get_pipeline_job` - Get details of a GitLab pipeline job number -`get_pipeline_job_output` - Get the output/trace of a GitLab pipeline job number -`get_namespace` - Get details of a namespace by ID or path -`get_milestone` - Get details of a specific milestone -`get_milestone_merge_requests` - Get merge requests associated with a specific milestone -`get_milestone_issue` - Get issues associated with a specific milestone -`get_milestone_burndown_events` - Get burndown events for a specific milestone -`get_merge_request` - Get details of a merge request (Either mergeRequestIid or branchName must be provided) -`get_merge_request_diffs` - Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided) -`get_label` - Get a single label from a project -`get_issue` - Get details of a specific issue in a GitLab project -`get_issue_link` - Get a specific issue link -`get_file_contents` - Get the contents of a file or directory from a GitLab project -`get_branch_diffs` - Get the changes/diffs between two branches or commits in a GitLab project -`fork_repository` - Fork a GitLab project to your account or specified namespace -`edit_milestone` - Edit an existing milestone in a GitLab project -`delete_wiki_page` - Delete a wiki page from a GitLab project -`delete_milestone` - Delete a milestone from a GitLab project -`delete_label` - Delete a label from a project -`delete_issue` - Delete an issue from a GitLab project -`delete_issue_link` - Delete an issue link -`delete_draft_note` - Delete a draft note -`download_attachment` - Download an uploaded file from a GitLab project by secret and filename -`create_wiki_page` - Create a new wiki page in a GitLab project -`create_repository` - Create a new GitLab project -`create_pipeline` - Create a new pipeline for a branch or tag -`create_or_update_file` - Create or update a single file in a GitLab project -`create_note` - Create a new note (comment) to an issue or merge request -`create_milestone` - Create a new milestone in a GitLab project -`create_merge_request` - Create a new merge request in a GitLab project -`create_merge_request_thread` - Create a new thread on a merge request -`create_merge_request_note` - Add a new note to an existing merge request thread -`create_label` - Create a new label in a project -`create_issue` - Create a new issue in a GitLab project -`create_issue_note` - Add a new note to an existing issue thread -`create_issue_link` - Create an issue link between two issues -`create_draft_note` - Create a draft note for a merge request -`create_branch` - Create a new branch in a GitLab project -`cancel_pipeline` - Cancel a running pipeline -`bulk_publish_draft_notes` - Publish all draft notes for a merge request +1. `merge_merge_request` - Merge a merge request in a GitLab project +2. `create_or_update_file` - Create or update a single file in a GitLab project +3. `search_repositories` - Search for GitLab projects +4. `create_repository` - Create a new GitLab project +5. `get_file_contents` - Get the contents of a file or directory from a GitLab project +6. `push_files` - Push multiple files to a GitLab project in a single commit +7. `create_issue` - Create a new issue in a GitLab project +8. `create_merge_request` - Create a new merge request in a GitLab project +9. `fork_repository` - Fork a GitLab project to your account or specified namespace +10. `create_branch` - Create a new branch in a GitLab project +11. `get_merge_request` - Get details of a merge request (Either mergeRequestIid or branchName must be provided) +12. `get_merge_request_diffs` - Get the changes/diffs of a merge request (Either mergeRequestIid or branchName must be provided) +13. `list_merge_request_diffs` - List merge request diffs with pagination support (Either mergeRequestIid or branchName must be provided) +14. `get_branch_diffs` - Get the changes/diffs between two branches or commits in a GitLab project +15. `update_merge_request` - Update a merge request (Either mergeRequestIid or branchName must be provided) +16. `create_note` - Create a new note (comment) to an issue or merge request +17. `create_merge_request_thread` - Create a new thread on a merge request +18. `mr_discussions` - List discussion items for a merge request +19. `update_merge_request_note` - Modify an existing merge request thread note +20. `create_merge_request_note` - Add a new note to an existing merge request thread +21. `get_draft_note` - Get a single draft note from a merge request +22. `list_draft_notes` - List draft notes for a merge request +23. `create_draft_note` - Create a draft note for a merge request +24. `update_draft_note` - Update an existing draft note +25. `delete_draft_note` - Delete a draft note +26. `publish_draft_note` - Publish a single draft note +27. `bulk_publish_draft_notes` - Publish all draft notes for a merge request +28. `update_issue_note` - Modify an existing issue thread note +29. `create_issue_note` - Add a new note to an existing issue thread +30. `list_issues` - List issues (default: created by current user only; use scope='all' for all accessible issues) +31. `my_issues` - List issues assigned to the authenticated user (defaults to open issues) +32. `get_issue` - Get details of a specific issue in a GitLab project +33. `update_issue` - Update an issue in a GitLab project +34. `delete_issue` - Delete an issue from a GitLab project +35. `list_issue_links` - List all issue links for a specific issue +36. `list_issue_discussions` - List discussions for an issue in a GitLab project +37. `get_issue_link` - Get a specific issue link +38. `create_issue_link` - Create an issue link between two issues +39. `delete_issue_link` - Delete an issue link +40. `list_namespaces` - List all namespaces available to the current user +41. `get_namespace` - Get details of a namespace by ID or path +42. `verify_namespace` - Verify if a namespace path exists +43. `get_project` - Get details of a specific project +44. `list_projects` - List projects accessible by the current user +45. `list_project_members` - List members of a GitLab project +46. `list_labels` - List labels for a project +47. `get_label` - Get a single label from a project +48. `create_label` - Create a new label in a project +49. `update_label` - Update an existing label in a project +50. `delete_label` - Delete a label from a project +51. `list_group_projects` - List projects in a GitLab group with filtering options +52. `list_wiki_pages` - List wiki pages in a GitLab project +53. `get_wiki_page` - Get details of a specific wiki page +54. `create_wiki_page` - Create a new wiki page in a GitLab project +55. `update_wiki_page` - Update an existing wiki page in a GitLab project +56. `delete_wiki_page` - Delete a wiki page from a GitLab project +57. `get_repository_tree` - Get the repository tree for a GitLab project (list files and directories) +58. `list_pipelines` - List pipelines in a GitLab project with filtering options +59. `get_pipeline` - Get details of a specific pipeline in a GitLab project +60. `list_pipeline_jobs` - List all jobs in a specific pipeline +61. `list_pipeline_trigger_jobs` - List all trigger jobs (bridges) in a specific pipeline that trigger downstream pipelines +62. `get_pipeline_job` - Get details of a GitLab pipeline job number +63. `get_pipeline_job_output` - Get the output/trace of a GitLab pipeline job with optional pagination to limit context window usage +64. `create_pipeline` - Create a new pipeline for a branch or tag +65. `retry_pipeline` - Retry a failed or canceled pipeline +66. `cancel_pipeline` - Cancel a running pipeline +67. `list_merge_requests` - List merge requests in a GitLab project with filtering options +68. `list_milestones` - List milestones in a GitLab project with filtering options +69. `get_milestone` - Get details of a specific milestone +70. `create_milestone` - Create a new milestone in a GitLab project +71. `edit_milestone` - Edit an existing milestone in a GitLab project +72. `delete_milestone` - Delete a milestone from a GitLab project +73. `get_milestone_issue` - Get issues associated with a specific milestone +74. `get_milestone_merge_requests` - Get merge requests associated with a specific milestone +75. `promote_milestone` - Promote a milestone to the next stage +76. `get_milestone_burndown_events` - Get burndown events for a specific milestone +77. `get_users` - Get GitLab user details by usernames +78. `list_commits` - List repository commits with filtering options +79. `get_commit` - Get details of a specific commit +80. `get_commit_diff` - Get changes/diffs of a specific commit +81. `list_group_iterations` - List group iterations with filtering options +82. `upload_markdown` - Upload a file to a GitLab project for use in markdown content +83. `download_attachment` - Download an uploaded file from a GitLab project by secret and filename From 7e985afadf3d99fe8596d89d2ffb1c92dda6276d Mon Sep 17 00:00:00 2001 From: zereight <42544600+zereight@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:57:54 +0900 Subject: [PATCH 23/24] feat: Add NPM publish workflow for automated package publishing (#208) --- .github/workflows/npm-publish.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/npm-publish.yml diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..d28f272 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,23 @@ +name: NPM Publish + +on: + release: + types: [published] + +jobs: + npm: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org/' + - name: Install dependencies + run: npm ci + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public From f6987cc662a6e33be77c8aacc1c2e7a8b9fd4e85 Mon Sep 17 00:00:00 2001 From: a Date: Wed, 13 Aug 2025 12:43:06 -0500 Subject: [PATCH 24/24] single logger --- src/customSchemas.ts | 14 -------------- src/mcpserver.ts | 2 +- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/customSchemas.ts b/src/customSchemas.ts index c22c648..cee660f 100644 --- a/src/customSchemas.ts +++ b/src/customSchemas.ts @@ -1,20 +1,6 @@ import { z } from "zod"; -import { pino } from 'pino'; const DEFAULT_NULL = process.env.DEFAULT_NULL === "true"; - -export const logger = pino({ - level: process.env.LOG_LEVEL || 'info', - transport: { - target: 'pino-pretty', - options: { - colorize: true, - levelFirst: true, - destination: 2, - }, - }, -}); - export const flexibleBoolean = z.preprocess(val => { if (typeof val === "boolean") { return val; diff --git a/src/mcpserver.ts b/src/mcpserver.ts index 8fa1b36..31f8a05 100644 --- a/src/mcpserver.ts +++ b/src/mcpserver.ts @@ -1,6 +1,6 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import {logger} from "./customSchemas.js" +import { logger } from "./logger.js" import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema";