From e5b2574f8c2b6dcb4ba1559325f413934e6a9755 Mon Sep 17 00:00:00 2001 From: Mark Wylde Date: Sat, 31 May 2025 09:20:48 +0100 Subject: [PATCH] Implement switch branch --- CLAUDE.md | 9 ++ src/create-prompt/index.ts | 1 + src/mcp/github-file-ops-server.ts | 199 ------------------------------ src/mcp/local-git-ops-server.ts | 84 +++++++++++++ 4 files changed, 94 insertions(+), 199 deletions(-) delete mode 100644 src/mcp/github-file-ops-server.ts diff --git a/CLAUDE.md b/CLAUDE.md index 196e5c2..338c319 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,3 +56,12 @@ src/ - The action creates branches for issues and pushes to PR branches directly - All actions create OIDC tokens for secure authentication - Progress is tracked through dynamic comment updates with checkboxes + +## MCP Tool Development + +When adding new MCP tools: + +1. **Add to MCP Server**: Implement the tool in the appropriate MCP server file (e.g., `src/mcp/local-git-ops-server.ts`) +2. **Expose to Claude**: Add the tool name to `BASE_ALLOWED_TOOLS` array in `src/create-prompt/index.ts` +3. **Tool Naming**: Follow the pattern `mcp__server_name__tool_name` (e.g., `mcp__local_git_ops__checkout_branch`) +4. **Documentation**: Update the prompt's "What You CAN Do" section if the tool adds new capabilities diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 30d742e..1914175 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -33,6 +33,7 @@ const BASE_ALLOWED_TOOLS = [ "mcp__local_git_ops__delete_files", "mcp__local_git_ops__push_branch", "mcp__local_git_ops__create_pull_request", + "mcp__local_git_ops__checkout_branch", "mcp__local_git_ops__git_status", ]; const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"]; diff --git a/src/mcp/github-file-ops-server.ts b/src/mcp/github-file-ops-server.ts deleted file mode 100644 index 2b87ff7..0000000 --- a/src/mcp/github-file-ops-server.ts +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env node -// GitHub File Operations MCP Server -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 { GITEA_API_URL } from "../github/api/config"; - -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", -}); - -// 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; - 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; - }); - - // NOTE: Gitea does not support GitHub's low-level git API operations - // (creating trees, commits, etc.). We need to use the contents API instead. - // For now, throw an error indicating this functionality is not available. - throw new Error( - "Multi-file commits are not supported with Gitea. " + - "Gitea does not provide the low-level git API operations (trees, commits) " + - "that are required for atomic multi-file commits. " + - "Please commit files individually using the contents API.", - ); - - return { - content: [ - { - type: "text", - text: JSON.stringify(simplifiedResult, null, 2), - }, - ], - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - return { - content: [ - { - type: "text", - text: `Error: ${errorMessage}`, - }, - ], - error: errorMessage, - isError: true, - }; - } - }, -); - -// 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; - }); - - // NOTE: Gitea does not support GitHub's low-level git API operations - // (creating trees, commits, etc.). We need to use the contents API instead. - // For now, throw an error indicating this functionality is not available. - throw new Error( - "Multi-file deletions are not supported with Gitea. " + - "Gitea does not provide the low-level git API operations (trees, commits) " + - "that are required for atomic multi-file operations. " + - "Please delete files individually using the contents API.", - ); - - return { - content: [ - { - type: "text", - text: JSON.stringify(simplifiedResult, null, 2), - }, - ], - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - return { - content: [ - { - type: "text", - text: `Error: ${errorMessage}`, - }, - ], - error: errorMessage, - isError: true, - }; - } - }, -); - -async function runServer() { - const transport = new StdioServerTransport(); - await server.connect(transport); - process.on("exit", () => { - server.close(); - }); -} - -runServer().catch(console.error); diff --git a/src/mcp/local-git-ops-server.ts b/src/mcp/local-git-ops-server.ts index 11876fb..d79cdfb 100644 --- a/src/mcp/local-git-ops-server.ts +++ b/src/mcp/local-git-ops-server.ts @@ -127,6 +127,90 @@ server.tool( }, ); +// Checkout branch tool +server.tool( + "checkout_branch", + "Checkout an existing branch using local git operations", + { + branch_name: z.string().describe("Name of the existing branch to checkout"), + create_if_missing: z + .boolean() + .optional() + .describe("Create branch if it doesn't exist locally (defaults to false)"), + fetch_remote: z + .boolean() + .optional() + .describe("Fetch from remote if branch doesn't exist locally (defaults to true)"), + }, + async ({ branch_name, create_if_missing = false, fetch_remote = true }) => { + try { + // Check if branch exists locally + let branchExists = false; + try { + runGitCommand(`git rev-parse --verify ${branch_name}`); + branchExists = true; + } catch (error) { + console.log(`[LOCAL-GIT-MCP] Branch ${branch_name} doesn't exist locally`); + } + + // If branch doesn't exist locally, try to fetch from remote + if (!branchExists && fetch_remote) { + try { + console.log(`[LOCAL-GIT-MCP] Attempting to fetch ${branch_name} from remote`); + runGitCommand(`git fetch origin ${branch_name}:${branch_name}`); + branchExists = true; + } catch (error) { + console.log(`[LOCAL-GIT-MCP] Branch ${branch_name} doesn't exist on remote`); + } + } + + // If branch still doesn't exist and create_if_missing is true, create it + if (!branchExists && create_if_missing) { + console.log(`[LOCAL-GIT-MCP] Creating new branch ${branch_name}`); + runGitCommand(`git checkout -b ${branch_name}`); + return { + content: [ + { + type: "text", + text: `Successfully created and checked out new branch: ${branch_name}`, + }, + ], + }; + } + + // If branch doesn't exist and we can't/won't create it, throw error + if (!branchExists) { + throw new Error(`Branch '${branch_name}' does not exist locally or on remote. Use create_if_missing=true to create it.`); + } + + // Checkout the existing branch + runGitCommand(`git checkout ${branch_name}`); + + return { + content: [ + { + type: "text", + text: `Successfully checked out branch: ${branch_name}`, + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Error checking out branch: ${errorMessage}`, + }, + ], + error: errorMessage, + isError: true, + }; + } + }, +); + // Commit files tool server.tool( "commit_files",