Merge pull request #10 from chad-loder/url-normalization-fix

Fix URL construction with smart API URL normalization
This commit is contained in:
bbang-dduck
2025-03-18 09:54:37 +09:00
committed by GitHub
4 changed files with 210 additions and 58 deletions

View File

@ -8,7 +8,7 @@ import { zodToJsonSchema } from "zod-to-json-schema";
import { GitLabForkSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabIssueSchema, GitLabMergeRequestSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabSearchResponseSchema, GitLabTreeSchema, GitLabCommitSchema, CreateOrUpdateFileSchema, SearchRepositoriesSchema, CreateRepositorySchema, GetFileContentsSchema, PushFilesSchema, CreateIssueSchema, CreateMergeRequestSchema, ForkRepositorySchema, CreateBranchSchema, GitLabMergeRequestDiffSchema, GetMergeRequestSchema, GetMergeRequestDiffsSchema, UpdateMergeRequestSchema, CreateNoteSchema, } from "./schemas.js"; import { GitLabForkSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabIssueSchema, GitLabMergeRequestSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabSearchResponseSchema, GitLabTreeSchema, GitLabCommitSchema, CreateOrUpdateFileSchema, SearchRepositoriesSchema, CreateRepositorySchema, GetFileContentsSchema, PushFilesSchema, CreateIssueSchema, CreateMergeRequestSchema, ForkRepositorySchema, CreateBranchSchema, GitLabMergeRequestDiffSchema, GetMergeRequestSchema, GetMergeRequestDiffsSchema, UpdateMergeRequestSchema, CreateNoteSchema, } from "./schemas.js";
const server = new Server({ const server = new Server({
name: "better-gitlab-mcp-server", name: "better-gitlab-mcp-server",
version: "0.0.1", version: "1.0.7-fix",
}, { }, {
capabilities: { capabilities: {
tools: {}, tools: {},
@ -345,14 +345,33 @@ async function updateMergeRequest(projectId, mergeRequestIid, options) {
async function createNote(projectId, noteableType, // 'issue' 또는 'merge_request' 타입 명시 async function createNote(projectId, noteableType, // 'issue' 또는 'merge_request' 타입 명시
noteableIid, body) { noteableIid, body) {
// ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능 // ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능
const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/${noteableType}/${noteableIid}/notes`); // Don't add /api/v4 again since it's already included in GITLAB_API_URL
const response = await fetch(url.toString(), { console.error("DEBUG - createNote - GITLAB_API_URL from env: " + process.env.GITLAB_API_URL);
method: "POST", console.error("DEBUG - createNote - GITLAB_API_URL in code: " + GITLAB_API_URL);
headers: DEFAULT_HEADERS, const url = new URL(`${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/${noteableType}s/${noteableIid}/notes` // Using plural form (issues/merge_requests) as per GitLab API documentation
body: JSON.stringify({ body }), );
}); // Add some debug logging
await handleGitLabError(response); console.error("DEBUG - createNote function called");
return await response.json(); // ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능, 필요하면 스키마 정의 console.error(`DEBUG - createNote - URL: ${url.toString()}`);
console.error(`DEBUG - createNote - projectId: ${projectId}, noteableType: ${noteableType}, noteableIid: ${noteableIid}`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers: DEFAULT_HEADERS,
body: JSON.stringify({ body }),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`DEBUG - createNote - Error response: ${response.status} ${response.statusText}`);
console.error(`DEBUG - createNote - Error body: ${errorText}`);
throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
}
return await response.json();
}
catch (error) {
console.error(`DEBUG - createNote - Exception: ${error instanceof Error ? error.message : String(error)}`);
throw error;
}
} }
server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(ListToolsRequestSchema, async () => {
return { return {
@ -537,10 +556,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
try { try {
const args = CreateNoteSchema.parse(request.params.arguments); const args = CreateNoteSchema.parse(request.params.arguments);
const { project_id, noteable_type, noteable_iid, body } = args; const { project_id, noteable_type, noteable_iid, body } = args;
const note = await createNote(project_id, noteable_type, noteable_iid, body); // Debug info that will be included in the response
return { const debugInfo = {
content: [{ type: "text", text: JSON.stringify(note, null, 2) }], gitlab_api_url: GITLAB_API_URL,
project_id,
noteable_type,
noteable_iid,
constructed_url: `${GITLAB_API_URL}/projects/${encodeURIComponent(project_id)}/${noteable_type}s/${noteable_iid}/notes`
}; };
try {
const note = await createNote(project_id, noteable_type, noteable_iid, body);
return {
content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
};
}
catch (error) {
// Include debug info in the error message
throw new Error(`Error with debug info: ${JSON.stringify(debugInfo)}\n${error instanceof Error ? error.message : String(error)}`);
}
} }
catch (error) { catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
@ -565,6 +598,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
} }
}); });
async function runServer() { async function runServer() {
console.error("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
console.error("!!! RUNNING VERSION 1.0.7-fix WITH URL DEBUGGING FIX !!!");
console.error("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);
console.error("GitLab MCP Server running on stdio"); console.error("GitLab MCP Server running on stdio");

136
index.ts
View File

@ -11,6 +11,8 @@ import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema"; import { zodToJsonSchema } from "zod-to-json-schema";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { dirname, resolve } from "path"; import { dirname, resolve } from "path";
import fs from "fs";
import path from "path";
import { import {
GitLabForkSchema, GitLabForkSchema,
GitLabReferenceSchema, GitLabReferenceSchema,
@ -54,10 +56,24 @@ import {
CreateNoteSchema, CreateNoteSchema,
} from "./schemas.js"; } from "./schemas.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) {
console.error("Warning: Could not read version from package.json:", error);
}
const server = new Server( const server = new Server(
{ {
name: "better-gitlab-mcp-server", name: "better-gitlab-mcp-server",
version: "0.0.1", version: SERVER_VERSION,
}, },
{ {
capabilities: { capabilities: {
@ -67,8 +83,34 @@ const server = new Server(
); );
const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN; const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_PERSONAL_ACCESS_TOKEN;
const GITLAB_API_URL =
process.env.GITLAB_API_URL || "https://gitlab.com/api/v4"; // Smart URL handling for GitLab API
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 || "");
// Add debug logging for API URL construction
console.log("=== MCP Server Configuration ===");
console.log(`GITLAB_API_URL = "${GITLAB_API_URL}"`);
console.log(`Example project API URL = "${GITLAB_API_URL}/projects/123"`);
console.log(`Example Notes API URL = "${GITLAB_API_URL}/projects/123/issues/1/notes"`);
console.log("===============================");
if (!GITLAB_PERSONAL_ACCESS_TOKEN) { if (!GITLAB_PERSONAL_ACCESS_TOKEN) {
console.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set"); console.error("GITLAB_PERSONAL_ACCESS_TOKEN environment variable is not set");
@ -101,7 +143,7 @@ async function forkProject(
): Promise<GitLabFork> { ): Promise<GitLabFork> {
// API 엔드포인트 URL 생성 // API 엔드포인트 URL 생성
const url = new URL( const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(projectId)}/fork` `${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/fork`
); );
if (namespace) { if (namespace) {
@ -129,7 +171,7 @@ async function createBranch(
options: z.infer<typeof CreateBranchOptionsSchema> options: z.infer<typeof CreateBranchOptionsSchema>
): Promise<GitLabReference> { ): Promise<GitLabReference> {
const url = new URL( const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent( `${GITLAB_API_URL}/projects/${encodeURIComponent(
projectId projectId
)}/repository/branches` )}/repository/branches`
); );
@ -150,7 +192,7 @@ async function createBranch(
// 프로젝트의 기본 브랜치 조회 // 프로젝트의 기본 브랜치 조회
async function getDefaultBranchRef(projectId: string): Promise<string> { async function getDefaultBranchRef(projectId: string): Promise<string> {
const url = new URL( const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(projectId)}` `${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}`
); );
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
@ -176,7 +218,7 @@ async function getFileContents(
} }
const url = new URL( const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent( `${GITLAB_API_URL}/projects/${encodeURIComponent(
projectId projectId
)}/repository/files/${encodedPath}` )}/repository/files/${encodedPath}`
); );
@ -213,7 +255,7 @@ async function createIssue(
options: z.infer<typeof CreateIssueOptionsSchema> options: z.infer<typeof CreateIssueOptionsSchema>
): Promise<GitLabIssue> { ): Promise<GitLabIssue> {
const url = new URL( const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(projectId)}/issues` `${GITLAB_API_URL}/projects/${encodeURIComponent(projectId)}/issues`
); );
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
@ -244,7 +286,7 @@ async function createMergeRequest(
options: z.infer<typeof CreateMergeRequestOptionsSchema> options: z.infer<typeof CreateMergeRequestOptionsSchema>
): Promise<GitLabMergeRequest> { ): Promise<GitLabMergeRequest> {
const url = new URL( const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent( `${GITLAB_API_URL}/projects/${encodeURIComponent(
projectId projectId
)}/merge_requests` )}/merge_requests`
); );
@ -292,7 +334,7 @@ async function createOrUpdateFile(
): Promise<GitLabCreateUpdateFileResponse> { ): Promise<GitLabCreateUpdateFileResponse> {
const encodedPath = encodeURIComponent(filePath); const encodedPath = encodeURIComponent(filePath);
const url = new URL( const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent( `${GITLAB_API_URL}/projects/${encodeURIComponent(
projectId projectId
)}/repository/files/${encodedPath}` )}/repository/files/${encodedPath}`
); );
@ -344,7 +386,7 @@ async function createTree(
ref?: string ref?: string
): Promise<GitLabTree> { ): Promise<GitLabTree> {
const url = new URL( const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent( `${GITLAB_API_URL}/projects/${encodeURIComponent(
projectId projectId
)}/repository/tree` )}/repository/tree`
); );
@ -392,7 +434,7 @@ async function createCommit(
actions: FileOperation[] actions: FileOperation[]
): Promise<GitLabCommit> { ): Promise<GitLabCommit> {
const url = new URL( const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent( `${GITLAB_API_URL}/projects/${encodeURIComponent(
projectId projectId
)}/repository/commits` )}/repository/commits`
); );
@ -437,7 +479,7 @@ async function searchProjects(
page: number = 1, page: number = 1,
perPage: number = 20 perPage: number = 20
): Promise<GitLabSearchResponse> { ): Promise<GitLabSearchResponse> {
const url = new URL(`${GITLAB_API_URL}/api/v4/projects`); const url = new URL(`${GITLAB_API_URL}/projects`);
url.searchParams.append("search", query); url.searchParams.append("search", query);
url.searchParams.append("page", page.toString()); url.searchParams.append("page", page.toString());
url.searchParams.append("per_page", perPage.toString()); url.searchParams.append("per_page", perPage.toString());
@ -477,7 +519,7 @@ async function searchProjects(
async function createRepository( async function createRepository(
options: z.infer<typeof CreateRepositoryOptionsSchema> options: z.infer<typeof CreateRepositoryOptionsSchema>
): Promise<GitLabRepository> { ): Promise<GitLabRepository> {
const response = await fetch(`${GITLAB_API_URL}/api/v4/projects`, { const response = await fetch(`${GITLAB_API_URL}/projects`, {
method: "POST", method: "POST",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
@ -511,7 +553,7 @@ async function getMergeRequest(
mergeRequestIid: number mergeRequestIid: number
): Promise<GitLabMergeRequest> { ): Promise<GitLabMergeRequest> {
const url = new URL( const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent( `${GITLAB_API_URL}/projects/${encodeURIComponent(
projectId projectId
)}/merge_requests/${mergeRequestIid}` )}/merge_requests/${mergeRequestIid}`
); );
@ -531,7 +573,7 @@ async function getMergeRequestDiffs(
view?: "inline" | "parallel" view?: "inline" | "parallel"
): Promise<GitLabMergeRequestDiff[]> { ): Promise<GitLabMergeRequestDiff[]> {
const url = new URL( const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent( `${GITLAB_API_URL}/projects/${encodeURIComponent(
projectId projectId
)}/merge_requests/${mergeRequestIid}/changes` )}/merge_requests/${mergeRequestIid}/changes`
); );
@ -559,7 +601,7 @@ async function updateMergeRequest(
> >
): Promise<GitLabMergeRequest> { ): Promise<GitLabMergeRequest> {
const url = new URL( const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent( `${GITLAB_API_URL}/projects/${encodeURIComponent(
projectId projectId
)}/merge_requests/${mergeRequestIid}` )}/merge_requests/${mergeRequestIid}`
); );
@ -594,8 +636,14 @@ async function createNote(
body: JSON.stringify({ body }), body: JSON.stringify({ body }),
}); });
await handleGitLabError(response); if (!response.ok) {
return await response.json(); // ⚙️ 응답 타입은 GitLab API 문서에 따라 조정 가능, 필요하면 스키마 정의 const errorText = await response.text();
throw new Error(
`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`
);
}
return await response.json();
} }
server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(ListToolsRequestSchema, async () => {
@ -828,28 +876,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
} }
case "create_note": { case "create_note": {
try { const args = CreateNoteSchema.parse(request.params.arguments);
const args = CreateNoteSchema.parse(request.params.arguments); const { project_id, noteable_type, noteable_iid, body } = args;
const { project_id, noteable_type, noteable_iid, body } = args;
const note = await createNote( const note = await createNote(
project_id, project_id,
noteable_type, noteable_type,
noteable_iid, noteable_iid,
body body
); );
return { return {
content: [{ type: "text", text: JSON.stringify(note, null, 2) }], content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
}; };
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(
`Invalid arguments: ${error.errors
.map((e) => `${e.path.join(".")}: ${e.message}`)
.join(", ")}`
);
}
throw error;
}
} }
default: default:
@ -868,9 +906,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}); });
async function runServer() { async function runServer() {
const transport = new StdioServerTransport(); try {
await server.connect(transport); console.error("========================");
console.error("GitLab MCP Server running on stdio"); console.error(`GitLab MCP Server v${SERVER_VERSION}`);
console.error(`API URL: ${GITLAB_API_URL}`);
console.error("========================");
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("GitLab MCP Server running on stdio");
} catch (error) {
console.error("Error initializing server:", error);
process.exit(1);
}
} }
runServer().catch((error) => { runServer().catch((error) => {

View File

@ -1,6 +1,6 @@
{ {
"name": "@zereight/mcp-gitlab", "name": "@zereight/mcp-gitlab",
"version": "1.0.6", "version": "1.0.7-fix",
"description": "MCP server for using the GitLab API", "description": "MCP server for using the GitLab API",
"license": "MIT", "license": "MIT",
"author": "zereight", "author": "zereight",
@ -27,6 +27,8 @@
"zod-to-json-schema": "^3.23.5" "zod-to-json-schema": "^3.23.5"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.6.2" "@types/node": "^22.13.10",
"typescript": "^5.8.2",
"zod": "3.21.4"
} }
} }

66
test-note.ts Normal file
View File

@ -0,0 +1,66 @@
/**
* This test file verifies that the createNote function works correctly
* with the fixed endpoint URL construction that uses plural resource names
* (issues instead of issue, merge_requests instead of merge_request).
*/
import fetch from "node-fetch";
// GitLab API configuration (replace with actual values when testing)
const GITLAB_API_URL = process.env.GITLAB_API_URL || "https://gitlab.com";
const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_TOKEN || "";
const PROJECT_ID = process.env.PROJECT_ID || "your/project";
const ISSUE_IID = Number(process.env.ISSUE_IID || "1");
async function testCreateIssueNote() {
try {
// Using plural form "issues" in the URL
const url = new URL(
`${GITLAB_API_URL}/api/v4/projects/${encodeURIComponent(
PROJECT_ID
)}/issues/${ISSUE_IID}/notes`
);
const response = await fetch(url.toString(), {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}`,
},
body: JSON.stringify({ body: "Test note from API - with plural endpoint" }),
});
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();
console.log("Successfully created note:");
console.log(JSON.stringify(data, null, 2));
return true;
} catch (error) {
console.error("Error creating note:", error);
return false;
}
}
// Only run the test if executed directly
if (require.main === module) {
console.log("Testing note creation with plural 'issues' endpoint...");
testCreateIssueNote().then(success => {
if (success) {
console.log("✅ Test successful!");
process.exit(0);
} else {
console.log("❌ Test failed!");
process.exit(1);
}
});
}
// Export for use in other tests
export { testCreateIssueNote };