Created
March 28, 2026 11:06
-
-
Save hanakla/2824ff8e578f6495865042e93f28dcf9 to your computer and use it in GitHub Desktop.
GitButler CLI MCP (Deno)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S deno run -A | |
| // | |
| // GitButler CLI MCP Server | |
| // | |
| // Usage: | |
| // deno run -A gitbutler-cmd.ts # start MCP server | |
| // deno run -A gitbutler-cmd.ts --allow-reset # enable destructive commands (undo, oplog restore) | |
| // deno run -A gitbutler-cmd.ts --test # run smoke test | |
| // | |
| import { Server } from "npm:@modelcontextprotocol/sdk/server/index.js"; | |
| import { StdioServerTransport } from "npm:@modelcontextprotocol/sdk/server/stdio.js"; | |
| import { | |
| CallToolRequestSchema, | |
| CallToolResult, | |
| ListToolsRequestSchema, | |
| ListToolsResult, | |
| Tool, | |
| } from "npm:@modelcontextprotocol/sdk/types.js"; | |
| import { stringify as yamlStringify } from "jsr:@std/yaml@1.0.8"; | |
| import jq from "npm:@michaelhomer/jqjs@1.6.0"; | |
| const allowReset = Deno.args.includes("--allow-reset"); | |
| const server = new Server( | |
| { name: "gitbutler", version: "1.0.0" }, | |
| { capabilities: { tools: {} } }, | |
| ); | |
| // --- Helpers --- | |
| function toYaml(data: unknown): string { | |
| return yamlStringify(data as Record<string, unknown>, { | |
| schema: "extended", | |
| flowLevel: -1, | |
| }); | |
| } | |
| async function run( | |
| args: string[], | |
| env?: Record<string, string>, | |
| ): Promise<{ code: number; out: string; err: string }> { | |
| const proc = new Deno.Command("but", { | |
| args, | |
| stdout: "piped", | |
| stderr: "piped", | |
| ...(env ? { env: { ...Deno.env.toObject(), ...env } } : {}), | |
| }); | |
| const { code, stdout, stderr } = await proc.output(); | |
| return { | |
| code, | |
| out: new TextDecoder().decode(stdout), | |
| err: new TextDecoder().decode(stderr), | |
| }; | |
| } | |
| function tryParseJson(text: string): unknown | null { | |
| const t = text.trim(); | |
| if (!t) return null; | |
| try { | |
| return JSON.parse(t); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function parseOutput(out: string, err: string): unknown | null { | |
| return tryParseJson(out) ?? tryParseJson(err); | |
| } | |
| function applyJq(data: unknown, filter: string): unknown { | |
| const results = [...jq(filter, data)]; | |
| return results.length === 1 ? results[0] : results; | |
| } | |
| async function execute( | |
| args: string[], | |
| opts: { | |
| autoJson?: boolean; | |
| fetchStatus?: boolean; | |
| jq?: string; | |
| env?: Record<string, string>; | |
| } = {}, | |
| ): Promise<CallToolResult> { | |
| const { autoJson = true, fetchStatus = true, jq, env } = opts; | |
| if (autoJson && !args.includes("--json")) args.push("--json"); | |
| const { code, out, err } = await run(args, env); | |
| if (code !== 0) { | |
| const text = [out.trim(), err.trim()].filter(Boolean).join("\n"); | |
| return { | |
| content: [{ type: "text", text: text || `exit code ${code}` }], | |
| isError: true, | |
| }; | |
| } | |
| const parsed = parseOutput(out, err); | |
| const output: Record<string, unknown> = {}; | |
| output.result = parsed ?? out.trim(); | |
| if (fetchStatus) { | |
| const s = await run(["status", "--json"], env); | |
| const sp = parseOutput(s.out, s.err); | |
| if (sp) output.status = sp; | |
| } | |
| if (jq && output.result != null) { | |
| try { | |
| output.result = applyJq(output.result, jq); | |
| } catch (e) { | |
| return { | |
| content: [ | |
| { type: "text", text: `jq error: ${(e as Error).message}` }, | |
| ], | |
| isError: true, | |
| }; | |
| } | |
| } | |
| return { | |
| content: [{ type: "text", text: toYaml(output) }], | |
| }; | |
| } | |
| // --- Schema helpers --- | |
| type Props = Record< | |
| string, | |
| { type: string; description: string; enum?: string[] } | |
| >; | |
| type A = Record<string, unknown>; | |
| type Handler = (a: A) => string[]; | |
| function str(a: A, k: string): string | undefined { | |
| const v = a[k]; | |
| return typeof v === "string" ? v : undefined; | |
| } | |
| function bool(a: A, k: string): boolean { | |
| return a[k] === true; | |
| } | |
| function pushIf(args: string[], cond: boolean, ...flags: string[]) { | |
| if (cond) args.push(...flags); | |
| } | |
| function pushStr(args: string[], flag: string, val: string | undefined) { | |
| if (val) args.push(flag, val); | |
| } | |
| // --- Tool registry --- | |
| type HandlerDef = { | |
| build: Handler; | |
| autoJson: boolean; | |
| fetchStatus: boolean; | |
| destructive: boolean; | |
| }; | |
| const handlers = new Map<string, HandlerDef>(); | |
| function defTool( | |
| name: string, | |
| description: string, | |
| props: Props, | |
| required: string[], | |
| build: Handler, | |
| opts: { autoJson?: boolean; fetchStatus?: boolean; destructive?: boolean } = | |
| {}, | |
| ): Tool { | |
| handlers.set(name, { | |
| build, | |
| autoJson: opts.autoJson ?? true, | |
| fetchStatus: opts.fetchStatus ?? true, | |
| destructive: opts.destructive ?? false, | |
| }); | |
| return { | |
| name, | |
| description, | |
| inputSchema: { | |
| type: "object", | |
| properties: { | |
| ...props, | |
| jq: { | |
| type: "string", | |
| description: "jq filter expression to apply to the result JSON", | |
| }, | |
| env: { | |
| type: "object", | |
| description: | |
| 'Environment variables to set for the but command (e.g. {"GIT_DIR": "/path"})', | |
| }, | |
| }, | |
| required, | |
| additionalProperties: false, | |
| }, | |
| }; | |
| } | |
| // --- Tool definitions --- | |
| const tools: Tool[] = [ | |
| // Inspection | |
| defTool( | |
| "status", | |
| "Get workspace status: branches, commits, uncommitted changes, CLI IDs.", | |
| { | |
| fileCentric: { type: "boolean", description: "File-centric view (-f)" }, | |
| verbose: { type: "boolean", description: "Detailed information" }, | |
| upstream: { | |
| type: "boolean", | |
| description: "Show upstream relationship", | |
| }, | |
| }, | |
| [], | |
| (a) => { | |
| const args = ["status"]; | |
| pushIf(args, bool(a, "fileCentric"), "-f"); | |
| pushIf(args, bool(a, "verbose"), "--verbose"); | |
| pushIf(args, bool(a, "upstream"), "--upstream"); | |
| return args; | |
| }, | |
| { fetchStatus: false }, | |
| ), | |
| defTool( | |
| "show", | |
| "Show details about a commit or branch.", | |
| { | |
| id: { type: "string", description: "CLI ID of commit or branch" }, | |
| verbose: { type: "boolean", description: "More detailed information" }, | |
| }, | |
| ["id"], | |
| (a) => { | |
| const args = ["show", str(a, "id")!]; | |
| pushIf(args, bool(a, "verbose"), "--verbose"); | |
| return args; | |
| }, | |
| { fetchStatus: false }, | |
| ), | |
| defTool( | |
| "diff", | |
| "Display diff for file, branch, stack, or commit. Without target, diffs entire workspace.", | |
| { | |
| target: { | |
| type: "string", | |
| description: "CLI ID of file/branch/commit (optional)", | |
| }, | |
| }, | |
| [], | |
| (a) => { | |
| const args = ["diff"]; | |
| const t = str(a, "target"); | |
| if (t) args.push(t); | |
| return args; | |
| }, | |
| { fetchStatus: false }, | |
| ), | |
| // Branching | |
| defTool( | |
| "branch_new", | |
| "Create a new branch.", | |
| { | |
| name: { type: "string", description: "Branch name" }, | |
| anchor: { | |
| type: "string", | |
| description: "Anchor branch for stacking (-a)", | |
| }, | |
| }, | |
| ["name"], | |
| (a) => { | |
| const args = ["branch", "new", str(a, "name")!]; | |
| pushStr(args, "-a", str(a, "anchor")); | |
| return args; | |
| }, | |
| { autoJson: false }, | |
| ), | |
| defTool( | |
| "branch_list", | |
| "List branches.", | |
| { | |
| filter: { | |
| type: "string", | |
| description: "Filter by name (case-insensitive substring)", | |
| }, | |
| remote: { type: "boolean", description: "Remote only (-r)" }, | |
| local: { type: "boolean", description: "Local only (-l)" }, | |
| all: { type: "boolean", description: "Show all (-a)" }, | |
| review: { type: "boolean", description: "Fetch PR/MR info" }, | |
| }, | |
| [], | |
| (a) => { | |
| const args = ["branch", "list"]; | |
| const f = str(a, "filter"); | |
| if (f) args.push(f); | |
| pushIf(args, bool(a, "remote"), "-r"); | |
| pushIf(args, bool(a, "local"), "-l"); | |
| pushIf(args, bool(a, "all"), "-a"); | |
| pushIf(args, bool(a, "review"), "--review"); | |
| return args; | |
| }, | |
| { fetchStatus: false }, | |
| ), | |
| defTool( | |
| "branch_show", | |
| "Show commits ahead of base for a branch.", | |
| { | |
| id: { type: "string", description: "Branch CLI ID or name" }, | |
| files: { | |
| type: "boolean", | |
| description: "Show files in each commit (-f)", | |
| }, | |
| ai: { type: "boolean", description: "Generate AI summary" }, | |
| check: { type: "boolean", description: "Check if merges cleanly" }, | |
| review: { type: "boolean", description: "Fetch review info (-r)" }, | |
| }, | |
| ["id"], | |
| (a) => { | |
| const args = ["branch", "show", str(a, "id")!]; | |
| pushIf(args, bool(a, "files"), "-f"); | |
| pushIf(args, bool(a, "ai"), "--ai"); | |
| pushIf(args, bool(a, "check"), "--check"); | |
| pushIf(args, bool(a, "review"), "-r"); | |
| return args; | |
| }, | |
| { fetchStatus: false }, | |
| ), | |
| defTool( | |
| "branch_delete", | |
| "Delete a branch.", | |
| { id: { type: "string", description: "Branch CLI ID or name" } }, | |
| ["id"], | |
| (a) => ["branch", "delete", str(a, "id")!], | |
| { autoJson: false }, | |
| ), | |
| defTool( | |
| "apply", | |
| "Activate a branch in the workspace.", | |
| { id: { type: "string", description: "Branch/stack CLI ID" } }, | |
| ["id"], | |
| (a) => ["apply", str(a, "id")!], | |
| { autoJson: false }, | |
| ), | |
| defTool( | |
| "unapply", | |
| "Deactivate a branch from the workspace.", | |
| { id: { type: "string", description: "Branch/stack CLI ID or name" } }, | |
| ["id"], | |
| (a) => ["unapply", str(a, "id")!], | |
| { autoJson: false }, | |
| ), | |
| // Committing | |
| defTool( | |
| "commit", | |
| "Commit changes to a branch.", | |
| { | |
| branch: { | |
| type: "string", | |
| description: "Target branch (omit if only one applied)", | |
| }, | |
| message: { type: "string", description: "Commit message (-m)" }, | |
| changes: { | |
| type: "string", | |
| description: | |
| "Comma-separated CLI IDs of files/hunks to commit (--changes)", | |
| }, | |
| only: { | |
| type: "boolean", | |
| description: "Commit only staged changes (--only)", | |
| }, | |
| create: { | |
| type: "boolean", | |
| description: "Create branch if needed (-c)", | |
| }, | |
| ai: { | |
| type: "string", | |
| description: | |
| 'AI commit message. Empty string for auto-generate, or provide instructions. e.g. "" or "fix the auth bug"', | |
| }, | |
| messageFile: { | |
| type: "string", | |
| description: "Read message from file (--message-file)", | |
| }, | |
| noHooks: { | |
| type: "boolean", | |
| description: "Bypass git hooks (-n)", | |
| }, | |
| }, | |
| [], | |
| (a) => { | |
| const args = ["commit"]; | |
| const br = str(a, "branch"); | |
| if (br) args.push(br); | |
| pushIf(args, bool(a, "only"), "--only"); | |
| pushIf(args, bool(a, "create"), "-c"); | |
| pushIf(args, bool(a, "noHooks"), "-n"); | |
| pushStr(args, "-m", str(a, "message")); | |
| pushStr(args, "--changes", str(a, "changes")); | |
| pushStr(args, "--message-file", str(a, "messageFile")); | |
| if (a.ai !== undefined) { | |
| const aiStr = String(a.ai); | |
| args.push(aiStr ? `-i=${aiStr}` : "-i"); | |
| } | |
| return args; | |
| }, | |
| ), | |
| defTool( | |
| "absorb", | |
| "Auto-amend uncommitted changes into existing commits.", | |
| { | |
| source: { | |
| type: "string", | |
| description: "File or branch CLI ID (omit for all)", | |
| }, | |
| dryRun: { | |
| type: "boolean", | |
| description: "Preview without changes", | |
| }, | |
| asNew: { | |
| type: "boolean", | |
| description: "Create new commits instead of amending (-n)", | |
| }, | |
| }, | |
| [], | |
| (a) => { | |
| const args = ["absorb"]; | |
| const s = str(a, "source"); | |
| if (s) args.push(s); | |
| pushIf(args, bool(a, "dryRun"), "--dry-run"); | |
| pushIf(args, bool(a, "asNew"), "-n"); | |
| return args; | |
| }, | |
| ), | |
| // Staging | |
| defTool( | |
| "stage", | |
| "Stage file/hunk to a specific branch.", | |
| { | |
| file: { type: "string", description: "File/hunk CLI ID" }, | |
| branch: { type: "string", description: "Target branch CLI ID" }, | |
| }, | |
| ["file", "branch"], | |
| (a) => ["stage", str(a, "file")!, str(a, "branch")!], | |
| ), | |
| // Editing history | |
| defTool( | |
| "rub", | |
| "Universal editing primitive: stage/amend/squash/move/unstage depending on source/dest types. Use 'zz' for unassigned.", | |
| { | |
| source: { | |
| type: "string", | |
| description: "Source CLI ID (file, commit, branch, or 'zz')", | |
| }, | |
| dest: { | |
| type: "string", | |
| description: "Dest CLI ID (commit, branch, or 'zz')", | |
| }, | |
| }, | |
| ["source", "dest"], | |
| (a) => ["rub", str(a, "source")!, str(a, "dest")!], | |
| ), | |
| defTool( | |
| "amend", | |
| "Amend file into a specific commit.", | |
| { | |
| file: { type: "string", description: "File CLI ID" }, | |
| commit: { type: "string", description: "Target commit CLI ID" }, | |
| }, | |
| ["file", "commit"], | |
| (a) => ["amend", str(a, "file")!, str(a, "commit")!], | |
| ), | |
| defTool( | |
| "squash", | |
| "Squash commits together.", | |
| { | |
| targets: { | |
| type: "string", | |
| description: "Space-separated commit IDs, range (c1..c4), or branch ID", | |
| }, | |
| drop: { | |
| type: "boolean", | |
| description: "Drop source messages (-d)", | |
| }, | |
| message: { type: "string", description: "New commit message (-m)" }, | |
| ai: { type: "boolean", description: "AI-generated message (-i)" }, | |
| }, | |
| ["targets"], | |
| (a) => { | |
| const targets = str(a, "targets")!.split(/\s+/); | |
| const args = ["squash", ...targets]; | |
| pushIf(args, bool(a, "drop"), "-d"); | |
| pushStr(args, "-m", str(a, "message")); | |
| pushIf(args, bool(a, "ai"), "-i"); | |
| return args; | |
| }, | |
| ), | |
| defTool( | |
| "move", | |
| "Move a commit to a different location.", | |
| { | |
| source: { type: "string", description: "Source commit CLI ID" }, | |
| target: { | |
| type: "string", | |
| description: "Target commit or branch CLI ID", | |
| }, | |
| after: { | |
| type: "boolean", | |
| description: "Move after target (default: before)", | |
| }, | |
| }, | |
| ["source", "target"], | |
| (a) => { | |
| const args = ["move", str(a, "source")!, str(a, "target")!]; | |
| pushIf(args, bool(a, "after"), "--after"); | |
| return args; | |
| }, | |
| ), | |
| defTool( | |
| "uncommit", | |
| "Uncommit changes back to unstaged area.", | |
| { | |
| source: { type: "string", description: "Commit or file CLI ID" }, | |
| }, | |
| ["source"], | |
| (a) => ["uncommit", str(a, "source")!], | |
| ), | |
| defTool( | |
| "reword", | |
| "Reword commit message or rename branch.", | |
| { | |
| id: { type: "string", description: "Commit or branch CLI ID" }, | |
| message: { type: "string", description: "New message (-m)" }, | |
| format: { | |
| type: "boolean", | |
| description: "Format to 72-char wrapping", | |
| }, | |
| }, | |
| ["id"], | |
| (a) => { | |
| const args = ["reword", str(a, "id")!]; | |
| pushStr(args, "-m", str(a, "message")); | |
| pushIf(args, bool(a, "format"), "--format"); | |
| return args; | |
| }, | |
| { autoJson: false }, | |
| ), | |
| defTool( | |
| "gitbutler_discard", | |
| "Discard uncommitted changes.", | |
| { id: { type: "string", description: "File or hunk CLI ID" } }, | |
| ["id"], | |
| (a) => ["discard", str(a, "id")!], | |
| { autoJson: false, destructive: true }, | |
| ), | |
| // Conflict resolution | |
| defTool( | |
| "resolve", | |
| "Manage conflict resolution. Pass commit CLI ID to enter, or 'status'/'finish'/'cancel'.", | |
| { | |
| action: { | |
| type: "string", | |
| description: | |
| "Commit CLI ID to enter resolution, or 'status'/'finish'/'cancel'", | |
| }, | |
| }, | |
| ["action"], | |
| (a) => ["resolve", str(a, "action")!], | |
| { autoJson: false }, | |
| ), | |
| // Remote | |
| defTool( | |
| "push", | |
| "Push branches to remote.", | |
| { | |
| branch: { | |
| type: "string", | |
| description: "Branch CLI ID (omit for all)", | |
| }, | |
| dryRun: { type: "boolean", description: "Preview only" }, | |
| force: { type: "boolean", description: "Force push (--with-force)" }, | |
| }, | |
| [], | |
| (a) => { | |
| const args = ["push"]; | |
| const br = str(a, "branch"); | |
| if (br) args.push(br); | |
| pushIf(args, bool(a, "dryRun"), "--dry-run"); | |
| pushIf(args, bool(a, "force"), "--with-force"); | |
| return args; | |
| }, | |
| ), | |
| defTool( | |
| "pull", | |
| "Update all branches with target branch changes.", | |
| { | |
| check: { | |
| type: "boolean", | |
| description: "Check if can merge cleanly (no changes)", | |
| }, | |
| }, | |
| [], | |
| (a) => { | |
| const args = ["pull"]; | |
| pushIf(args, bool(a, "check"), "--check"); | |
| return args; | |
| }, | |
| ), | |
| defTool( | |
| "pr_new", | |
| "Push branch and create a pull request.", | |
| { | |
| branch: { type: "string", description: "Branch CLI ID" }, | |
| message: { | |
| type: "string", | |
| description: "PR title+body (-m). First line = title.", | |
| }, | |
| useDefault: { | |
| type: "boolean", | |
| description: "Use commit message as default (-t)", | |
| }, | |
| }, | |
| ["branch"], | |
| (a) => { | |
| const args = ["pr", "new", str(a, "branch")!]; | |
| pushStr(args, "-m", str(a, "message")); | |
| pushIf(args, bool(a, "useDefault"), "-t"); | |
| return args; | |
| }, | |
| ), | |
| defTool( | |
| "merge", | |
| "Merge branch into local target branch.", | |
| { branch: { type: "string", description: "Branch CLI ID" } }, | |
| ["branch"], | |
| (a) => ["merge", str(a, "branch")!], | |
| { autoJson: false }, | |
| ), | |
| defTool( | |
| "pick", | |
| "Cherry-pick commits from unapplied branches.", | |
| { | |
| source: { | |
| type: "string", | |
| description: "Commit SHA, CLI ID, or unapplied branch name", | |
| }, | |
| target: { | |
| type: "string", | |
| description: "Target branch (optional if only one)", | |
| }, | |
| }, | |
| ["source"], | |
| (a) => { | |
| const args = ["pick", str(a, "source")!]; | |
| const t = str(a, "target"); | |
| if (t) args.push(t); | |
| return args; | |
| }, | |
| { autoJson: false }, | |
| ), | |
| // Automation | |
| defTool( | |
| "gitbutler_mark", | |
| "Auto-stage or auto-commit new changes to target.", | |
| { | |
| target: { | |
| type: "string", | |
| description: "Branch or commit CLI ID", | |
| }, | |
| delete: { type: "boolean", description: "Remove the mark" }, | |
| }, | |
| ["target"], | |
| (a) => { | |
| const args = ["mark", str(a, "target")!]; | |
| pushIf(args, bool(a, "delete"), "--delete"); | |
| return args; | |
| }, | |
| { autoJson: false }, | |
| ), | |
| defTool( | |
| "gitbutler_unmark", | |
| "Remove all marks.", | |
| {}, | |
| [], | |
| () => ["unmark"], | |
| { autoJson: false }, | |
| ), | |
| // History & Undo | |
| defTool( | |
| "gitbutler_undo", | |
| "Undo last operation.", | |
| {}, | |
| [], | |
| () => ["undo"], | |
| { autoJson: false, destructive: true }, | |
| ), | |
| defTool( | |
| "oplog", | |
| "View operation history.", | |
| {}, | |
| [], | |
| () => ["oplog"], | |
| { fetchStatus: false }, | |
| ), | |
| { | |
| name: "gitbutler_cwd", | |
| description: "Return the working directory of this MCP server.", | |
| inputSchema: { | |
| type: "object", | |
| properties: {}, | |
| additionalProperties: false, | |
| }, | |
| }, | |
| defTool( | |
| "oplog_restore", | |
| "Restore to a specific oplog snapshot.", | |
| { snapshot: { type: "string", description: "Snapshot ID" } }, | |
| ["snapshot"], | |
| (a) => ["oplog", "restore", str(a, "snapshot")!], | |
| { autoJson: false, destructive: true }, | |
| ), | |
| ]; | |
| // --- Request handlers --- | |
| server.setRequestHandler( | |
| ListToolsRequestSchema, | |
| (): ListToolsResult => ({ | |
| tools: tools.filter((t) => { | |
| const h = handlers.get(t.name); | |
| return !h?.destructive || allowReset; | |
| }), | |
| }), | |
| ); | |
| server.setRequestHandler( | |
| CallToolRequestSchema, | |
| async (request): Promise<CallToolResult> => { | |
| const { name, arguments: args } = request.params; | |
| const handler = handlers.get(name); | |
| if (name === "gitbutler_cwd") { | |
| return { content: [{ type: "text", text: Deno.cwd() }] }; | |
| } | |
| if (!handler) { | |
| return { | |
| content: [{ type: "text", text: `Error: Unknown tool: ${name}` }], | |
| isError: true, | |
| }; | |
| } | |
| const a = args ?? {}; | |
| const butArgs = handler.build(a); | |
| return await execute(butArgs, { | |
| autoJson: handler.autoJson, | |
| fetchStatus: handler.fetchStatus, | |
| jq: str(a, "jq"), | |
| env: a.env as Record<string, string> | undefined, | |
| }); | |
| }, | |
| ); | |
| // --- Main --- | |
| async function main() { | |
| if (Deno.args.includes("--test")) { | |
| console.log("=== GitButler CMD MCP Test ===\n"); | |
| console.log(`cwd: ${Deno.cwd()}\n`); | |
| const result = await execute(["status"], { | |
| autoJson: true, | |
| fetchStatus: false, | |
| }); | |
| for (const c of result.content) { | |
| if (c.type === "text") console.log(c.text); | |
| } | |
| return; | |
| } | |
| const transport = new StdioServerTransport(); | |
| await server.connect(transport); | |
| console.error("GitButler CMD MCP Server running on stdio"); | |
| } | |
| main().catch(console.error); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment