Skip to content

Instantly share code, notes, and snippets.

@hanakla
Created March 28, 2026 11:06
Show Gist options
  • Select an option

  • Save hanakla/2824ff8e578f6495865042e93f28dcf9 to your computer and use it in GitHub Desktop.

Select an option

Save hanakla/2824ff8e578f6495865042e93f28dcf9 to your computer and use it in GitHub Desktop.
GitButler CLI MCP (Deno)
#!/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