mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
- Add detailed error logging helper function - Enhance error handling in commit_files and delete_files - Capture GitHub request IDs for 500 errors - Improve error message construction to avoid 'undefined' - Add comprehensive logging for fetch errors and API responses
585 lines
20 KiB
JavaScript
585 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
// GitHub File Operations MCP Server - Enhanced with detailed error logging
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
import { z } from "zod";
|
|
import { readFile } from "fs/promises";
|
|
import { join } from "path";
|
|
import fetch from "node-fetch";
|
|
// Import removed - define inline to ensure subprocess gets the value
|
|
const GITHUB_API_URL = process.env.GITHUB_API_URL || "https://api.github.com";
|
|
|
|
type GitHubRef = {
|
|
object: {
|
|
sha: string;
|
|
};
|
|
};
|
|
|
|
type GitHubCommit = {
|
|
tree: {
|
|
sha: string;
|
|
};
|
|
};
|
|
|
|
type GitHubTree = {
|
|
sha: string;
|
|
};
|
|
|
|
type GitHubNewCommit = {
|
|
sha: string;
|
|
message: string;
|
|
author: {
|
|
name: string;
|
|
date: string;
|
|
};
|
|
};
|
|
|
|
// Get repository information from environment variables
|
|
const REPO_OWNER = process.env.REPO_OWNER;
|
|
const REPO_NAME = process.env.REPO_NAME;
|
|
const BRANCH_NAME = process.env.BRANCH_NAME;
|
|
const REPO_DIR = process.env.REPO_DIR || process.cwd();
|
|
|
|
if (!REPO_OWNER || !REPO_NAME || !BRANCH_NAME) {
|
|
console.error(
|
|
"Error: REPO_OWNER, REPO_NAME, and BRANCH_NAME environment variables are required",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const server = new McpServer({
|
|
name: "GitHub File Operations Server",
|
|
version: "0.0.1",
|
|
});
|
|
|
|
// Enhanced error logging helper
|
|
function logDetailedError(prefix: string, error: any) {
|
|
console.error(`[${prefix}] FULL ERROR CAUGHT:`, error);
|
|
console.error(`[${prefix}] Error type:`, typeof error);
|
|
console.error(`[${prefix}] Error constructor:`, error?.constructor?.name);
|
|
console.error(`[${prefix}] Error stack:`, error instanceof Error ? error.stack : 'No stack trace');
|
|
if (error && typeof error === 'object') {
|
|
console.error(`[${prefix}] Error properties:`, Object.keys(error));
|
|
console.error(`[${prefix}] Error JSON:`, JSON.stringify(error, null, 2));
|
|
}
|
|
}
|
|
|
|
// Commit files tool
|
|
server.tool(
|
|
"commit_files",
|
|
"Commit one or more files to a repository in a single commit (this will commit them atomically in the remote repository)",
|
|
{
|
|
files: z
|
|
.array(z.string())
|
|
.describe(
|
|
'Array of file paths relative to repository root (e.g. ["src/main.js", "README.md"]). All files must exist locally.',
|
|
),
|
|
message: z.string().describe("Commit message"),
|
|
},
|
|
async ({ files, message }) => {
|
|
const owner = REPO_OWNER;
|
|
const repo = REPO_NAME;
|
|
const branch = BRANCH_NAME;
|
|
console.error(`[commit_files] Starting commit for ${files.length} files to ${owner}/${repo}:${branch}`);
|
|
console.error(`[commit_files] REPO_DIR: ${REPO_DIR}`);
|
|
console.error(`[commit_files] Input files:`, files);
|
|
console.error(`[commit_files] Environment check:`, {
|
|
GITHUB_TOKEN: process.env.GITHUB_TOKEN ? 'Present' : 'Missing',
|
|
REPO_OWNER,
|
|
REPO_NAME,
|
|
BRANCH_NAME,
|
|
REPO_DIR,
|
|
GITHUB_API_URL,
|
|
CWD: process.cwd(),
|
|
});
|
|
|
|
try {
|
|
const githubToken = process.env.GITHUB_TOKEN;
|
|
if (!githubToken) {
|
|
throw new Error("GITHUB_TOKEN environment variable is required");
|
|
}
|
|
|
|
const processedFiles = files.map((filePath) => {
|
|
if (filePath.startsWith("/")) {
|
|
return filePath.slice(1);
|
|
}
|
|
return filePath;
|
|
});
|
|
|
|
// 1. Get the branch reference
|
|
const refUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
|
const refResponse = await fetch(refUrl, {
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${githubToken}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
if (!refResponse.ok) {
|
|
throw new Error(
|
|
`Failed to get branch reference: ${refResponse.status}`,
|
|
);
|
|
}
|
|
|
|
const refData = (await refResponse.json()) as GitHubRef;
|
|
const baseSha = refData.object.sha;
|
|
|
|
// 2. Get the base commit
|
|
const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`;
|
|
const commitResponse = await fetch(commitUrl, {
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${githubToken}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
if (!commitResponse.ok) {
|
|
throw new Error(`Failed to get base commit: ${commitResponse.status}`);
|
|
}
|
|
|
|
const commitData = (await commitResponse.json()) as GitHubCommit;
|
|
const baseTreeSha = commitData.tree.sha;
|
|
|
|
// 3. Create tree entries for all files
|
|
const treeEntries = await Promise.all(
|
|
processedFiles.map(async (filePath) => {
|
|
const fullPath = filePath.startsWith("/")
|
|
? filePath
|
|
: join(REPO_DIR, filePath);
|
|
|
|
console.error(`[commit_files] Reading file: ${fullPath}`);
|
|
const content = await readFile(fullPath, "utf-8").catch((error) => {
|
|
console.error(`[commit_files] Failed to read file '${fullPath}':`, error);
|
|
throw new Error(`Failed to read file '${fullPath}': ${error.message || error}`);
|
|
});
|
|
console.error(`[commit_files] Successfully read file: ${fullPath} (${content.length} chars)`);
|
|
return {
|
|
path: filePath,
|
|
mode: "100644",
|
|
type: "blob",
|
|
content: content,
|
|
};
|
|
}),
|
|
);
|
|
|
|
// 4. Create a new tree
|
|
const treeUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/trees`;
|
|
const treeResponse = await fetch(treeUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${githubToken}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
base_tree: baseTreeSha,
|
|
tree: treeEntries,
|
|
}),
|
|
});
|
|
|
|
if (!treeResponse.ok) {
|
|
const errorText = await treeResponse.text();
|
|
throw new Error(
|
|
`Failed to create tree: ${treeResponse.status} - ${errorText}`,
|
|
);
|
|
}
|
|
|
|
const treeData = (await treeResponse.json()) as GitHubTree;
|
|
|
|
// 5. Create a new commit
|
|
const newCommitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits`;
|
|
const newCommitResponse = await fetch(newCommitUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${githubToken}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
message: message,
|
|
tree: treeData.sha,
|
|
parents: [baseSha],
|
|
}),
|
|
});
|
|
|
|
if (!newCommitResponse.ok) {
|
|
const errorText = await newCommitResponse.text();
|
|
throw new Error(
|
|
`Failed to create commit: ${newCommitResponse.status} - ${errorText}`,
|
|
);
|
|
}
|
|
|
|
const newCommitData = (await newCommitResponse.json()) as GitHubNewCommit;
|
|
|
|
// 6. Update the reference to point to the new commit
|
|
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
|
console.error(`[commit_files] Updating reference: ${updateRefUrl}`);
|
|
console.error(`[commit_files] New commit SHA: ${newCommitData.sha}`);
|
|
console.error(`[commit_files] Base SHA was: ${baseSha}`);
|
|
console.error(`[commit_files] Request body:`, JSON.stringify({
|
|
sha: newCommitData.sha,
|
|
force: false,
|
|
}));
|
|
|
|
let updateRefResponse;
|
|
try {
|
|
updateRefResponse = await fetch(updateRefUrl, {
|
|
method: "PATCH",
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${githubToken}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
sha: newCommitData.sha,
|
|
force: false,
|
|
}),
|
|
});
|
|
} catch (fetchError) {
|
|
console.error(`[commit_files] FETCH ERROR during reference update:`, fetchError);
|
|
logDetailedError('commit_files_fetch', fetchError);
|
|
throw new Error(`Network error during reference update: ${fetchError?.message || 'Unknown fetch error'}`);
|
|
}
|
|
|
|
console.error(`[commit_files] Update reference response status: ${updateRefResponse.status}`);
|
|
console.error(`[commit_files] Response headers:`, Object.fromEntries(updateRefResponse.headers.entries()));
|
|
|
|
if (!updateRefResponse.ok) {
|
|
let errorText;
|
|
try {
|
|
errorText = await updateRefResponse.text();
|
|
} catch (textError) {
|
|
console.error(`[commit_files] Failed to read error response text:`, textError);
|
|
errorText = 'Unable to read error response';
|
|
}
|
|
|
|
console.error(`[commit_files] Update reference error body: "${errorText}"`);
|
|
console.error(`[commit_files] Error body length: ${errorText?.length}`);
|
|
console.error(`[commit_files] Error body type: ${typeof errorText}`);
|
|
|
|
// Log additional debugging info for 500 errors
|
|
if (updateRefResponse.status === 500) {
|
|
const requestId = updateRefResponse.headers.get('x-github-request-id');
|
|
console.error(`[commit_files] GitHub Request ID: ${requestId}`);
|
|
console.error(`[commit_files] This appears to be an internal GitHub error`);
|
|
console.error(`[commit_files] Token was valid for tree/commit creation but failed for ref update`);
|
|
console.error(`[commit_files] Branch protection rules or permissions might be an issue`);
|
|
}
|
|
|
|
// Parse error if it's JSON
|
|
let parsedError;
|
|
try {
|
|
if (errorText && errorText.trim().startsWith('{')) {
|
|
parsedError = JSON.parse(errorText);
|
|
console.error(`[commit_files] Parsed error:`, parsedError);
|
|
}
|
|
} catch (e) {
|
|
console.error(`[commit_files] Error text is not JSON`);
|
|
}
|
|
|
|
throw new Error(
|
|
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
|
);
|
|
}
|
|
|
|
const simplifiedResult = {
|
|
commit: {
|
|
sha: newCommitData.sha,
|
|
message: newCommitData.message,
|
|
author: newCommitData.author.name,
|
|
date: newCommitData.author.date,
|
|
},
|
|
files: processedFiles.map((path) => ({ path })),
|
|
tree: {
|
|
sha: treeData.sha,
|
|
},
|
|
};
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(simplifiedResult, null, 2),
|
|
},
|
|
],
|
|
};
|
|
} catch (error) {
|
|
logDetailedError('commit_files', error);
|
|
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
console.error(`[commit_files] Final error message being thrown: "${errorMessage}"`);
|
|
|
|
// Ensure we're throwing a proper Error object with a message
|
|
if (!errorMessage || errorMessage === 'undefined' || errorMessage === '[object Object]') {
|
|
console.error(`[commit_files] WARNING: Error message is undefined or object, using fallback`);
|
|
const fallbackMessage = error instanceof Error && error.stack
|
|
? `Failed to commit files: ${error.stack.split('\n')[0]}`
|
|
: 'Failed to commit files: Unknown error occurred';
|
|
throw new Error(fallbackMessage);
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
},
|
|
);
|
|
|
|
// Delete files tool
|
|
server.tool(
|
|
"delete_files",
|
|
"Delete one or more files from a repository in a single commit",
|
|
{
|
|
paths: z
|
|
.array(z.string())
|
|
.describe(
|
|
'Array of file paths to delete relative to repository root (e.g. ["src/old-file.js", "docs/deprecated.md"])',
|
|
),
|
|
message: z.string().describe("Commit message"),
|
|
},
|
|
async ({ paths, message }) => {
|
|
const owner = REPO_OWNER;
|
|
const repo = REPO_NAME;
|
|
const branch = BRANCH_NAME;
|
|
try {
|
|
const githubToken = process.env.GITHUB_TOKEN;
|
|
if (!githubToken) {
|
|
throw new Error("GITHUB_TOKEN environment variable is required");
|
|
}
|
|
|
|
// Convert absolute paths to relative if they match CWD
|
|
const cwd = process.cwd();
|
|
const processedPaths = paths.map((filePath) => {
|
|
if (filePath.startsWith("/")) {
|
|
if (filePath.startsWith(cwd)) {
|
|
// Strip CWD from absolute path
|
|
return filePath.slice(cwd.length + 1);
|
|
} else {
|
|
throw new Error(
|
|
`Path '${filePath}' must be relative to repository root or within current working directory`,
|
|
);
|
|
}
|
|
}
|
|
return filePath;
|
|
});
|
|
|
|
// 1. Get the branch reference
|
|
const refUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
|
const refResponse = await fetch(refUrl, {
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${githubToken}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
if (!refResponse.ok) {
|
|
throw new Error(
|
|
`Failed to get branch reference: ${refResponse.status}`,
|
|
);
|
|
}
|
|
|
|
const refData = (await refResponse.json()) as GitHubRef;
|
|
const baseSha = refData.object.sha;
|
|
|
|
// 2. Get the base commit
|
|
const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`;
|
|
const commitResponse = await fetch(commitUrl, {
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${githubToken}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
if (!commitResponse.ok) {
|
|
throw new Error(`Failed to get base commit: ${commitResponse.status}`);
|
|
}
|
|
|
|
const commitData = (await commitResponse.json()) as GitHubCommit;
|
|
const baseTreeSha = commitData.tree.sha;
|
|
|
|
// 3. Create tree entries for file deletions (setting SHA to null)
|
|
const treeEntries = processedPaths.map((path) => ({
|
|
path: path,
|
|
mode: "100644",
|
|
type: "blob" as const,
|
|
sha: null,
|
|
}));
|
|
|
|
// 4. Create a new tree with deletions
|
|
const treeUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/trees`;
|
|
const treeResponse = await fetch(treeUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${githubToken}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
base_tree: baseTreeSha,
|
|
tree: treeEntries,
|
|
}),
|
|
});
|
|
|
|
if (!treeResponse.ok) {
|
|
const errorText = await treeResponse.text();
|
|
throw new Error(
|
|
`Failed to create tree: ${treeResponse.status} - ${errorText}`,
|
|
);
|
|
}
|
|
|
|
const treeData = (await treeResponse.json()) as GitHubTree;
|
|
|
|
// 5. Create a new commit
|
|
const newCommitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits`;
|
|
const newCommitResponse = await fetch(newCommitUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${githubToken}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
message: message,
|
|
tree: treeData.sha,
|
|
parents: [baseSha],
|
|
}),
|
|
});
|
|
|
|
if (!newCommitResponse.ok) {
|
|
const errorText = await newCommitResponse.text();
|
|
throw new Error(
|
|
`Failed to create commit: ${newCommitResponse.status} - ${errorText}`,
|
|
);
|
|
}
|
|
|
|
const newCommitData = (await newCommitResponse.json()) as GitHubNewCommit;
|
|
|
|
// 6. Update the reference to point to the new commit
|
|
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
|
console.error(`[delete_files] Updating reference: ${updateRefUrl}`);
|
|
console.error(`[delete_files] New commit SHA: ${newCommitData.sha}`);
|
|
console.error(`[delete_files] Base SHA was: ${baseSha}`);
|
|
console.error(`[delete_files] Request body:`, JSON.stringify({
|
|
sha: newCommitData.sha,
|
|
force: false,
|
|
}));
|
|
|
|
let updateRefResponse;
|
|
try {
|
|
updateRefResponse = await fetch(updateRefUrl, {
|
|
method: "PATCH",
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${githubToken}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
sha: newCommitData.sha,
|
|
force: false,
|
|
}),
|
|
});
|
|
} catch (fetchError) {
|
|
console.error(`[delete_files] FETCH ERROR during reference update:`, fetchError);
|
|
logDetailedError('delete_files_fetch', fetchError);
|
|
throw new Error(`Network error during reference update: ${fetchError?.message || 'Unknown fetch error'}`);
|
|
}
|
|
|
|
console.error(`[delete_files] Update reference response status: ${updateRefResponse.status}`);
|
|
console.error(`[delete_files] Response headers:`, Object.fromEntries(updateRefResponse.headers.entries()));
|
|
|
|
if (!updateRefResponse.ok) {
|
|
let errorText;
|
|
try {
|
|
errorText = await updateRefResponse.text();
|
|
} catch (textError) {
|
|
console.error(`[delete_files] Failed to read error response text:`, textError);
|
|
errorText = 'Unable to read error response';
|
|
}
|
|
|
|
console.error(`[delete_files] Update reference error body: "${errorText}"`);
|
|
console.error(`[delete_files] Error body length: ${errorText?.length}`);
|
|
console.error(`[delete_files] Error body type: ${typeof errorText}`);
|
|
|
|
// Log additional debugging info for 500 errors
|
|
if (updateRefResponse.status === 500) {
|
|
const requestId = updateRefResponse.headers.get('x-github-request-id');
|
|
console.error(`[delete_files] GitHub Request ID: ${requestId}`);
|
|
console.error(`[delete_files] This appears to be an internal GitHub error`);
|
|
console.error(`[delete_files] Token was valid for tree/commit creation but failed for ref update`);
|
|
console.error(`[delete_files] Branch protection rules or permissions might be an issue`);
|
|
}
|
|
|
|
// Parse error if it's JSON
|
|
let parsedError;
|
|
try {
|
|
if (errorText && errorText.trim().startsWith('{')) {
|
|
parsedError = JSON.parse(errorText);
|
|
console.error(`[delete_files] Parsed error:`, parsedError);
|
|
}
|
|
} catch (e) {
|
|
console.error(`[delete_files] Error text is not JSON`);
|
|
}
|
|
|
|
throw new Error(
|
|
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
|
);
|
|
}
|
|
|
|
const simplifiedResult = {
|
|
commit: {
|
|
sha: newCommitData.sha,
|
|
message: newCommitData.message,
|
|
author: newCommitData.author.name,
|
|
date: newCommitData.author.date,
|
|
},
|
|
deletedFiles: processedPaths.map((path) => ({ path })),
|
|
tree: {
|
|
sha: treeData.sha,
|
|
},
|
|
};
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: JSON.stringify(simplifiedResult, null, 2),
|
|
},
|
|
],
|
|
};
|
|
} catch (error) {
|
|
logDetailedError('delete_files', error);
|
|
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
console.error(`[delete_files] Final error message being thrown: "${errorMessage}"`);
|
|
|
|
// Ensure we're throwing a proper Error object with a message
|
|
if (!errorMessage || errorMessage === 'undefined' || errorMessage === '[object Object]') {
|
|
console.error(`[delete_files] WARNING: Error message is undefined or object, using fallback`);
|
|
const fallbackMessage = error instanceof Error && error.stack
|
|
? `Failed to delete files: ${error.stack.split('\n')[0]}`
|
|
: 'Failed to delete files: Unknown error occurred';
|
|
throw new Error(fallbackMessage);
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
},
|
|
);
|
|
|
|
async function runServer() {
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|
|
process.on("exit", () => {
|
|
server.close();
|
|
});
|
|
}
|
|
|
|
runServer().catch(console.error); |