From 2c706c0128aa609afed211d4a9342b90b293a7ef Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Thu, 8 May 2025 06:18:01 +0200 Subject: [PATCH 1/2] simple gitbutler-mcp server binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of needing to set env vars and run a node server, this implements a simple Rust compiled binary that has direct access to all the GitButler apis. Should be possible to build this into a single binary that can be run as an MCP server that doesn’t need anything else. --- Cargo.lock | 61 ++++++++++ crates/gitbutler-mcp/Cargo.toml | 32 +++++ crates/gitbutler-mcp/README.md | 47 ++++++++ crates/gitbutler-mcp/src/common/butler.rs | 139 ++++++++++++++++++++++ crates/gitbutler-mcp/src/common/mod.rs | 1 + crates/gitbutler-mcp/src/main.rs | 26 ++++ 6 files changed, 306 insertions(+) create mode 100644 crates/gitbutler-mcp/Cargo.toml create mode 100644 crates/gitbutler-mcp/README.md create mode 100644 crates/gitbutler-mcp/src/common/butler.rs create mode 100644 crates/gitbutler-mcp/src/common/mod.rs create mode 100644 crates/gitbutler-mcp/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 437c1f154b..67ec61c93b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2698,6 +2698,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "gitbutler-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "rmcp", + "serde", + "serde_json", + "temp-dir", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "gitbutler-notify-debouncer" version = "0.0.0" @@ -6060,6 +6077,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -6963,6 +6986,38 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rmcp" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33a0110d28bd076f39e14bfd5b0340216dd18effeb5d02b43215944cc3e5c751" +dependencies = [ + "base64 0.21.7", + "chrono", + "futures", + "paste", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e2b2fd7497540489fa2db285edd43b7ed14c49157157438664278da6e42a7a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "rstest" version = "0.23.0" @@ -8376,6 +8431,12 @@ dependencies = [ "toml", ] +[[package]] +name = "temp-dir" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83176759e9416cf81ee66cb6508dbfe9c96f20b8b56265a39917551c23c70964" + [[package]] name = "tempfile" version = "3.19.1" diff --git a/crates/gitbutler-mcp/Cargo.toml b/crates/gitbutler-mcp/Cargo.toml new file mode 100644 index 0000000000..b243baf1cf --- /dev/null +++ b/crates/gitbutler-mcp/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "gitbutler-mcp" +version = "0.1.0" +edition = "2021" +description = "Model Context Protocol server for GitButler" +authors = ["GitButler "] +publish = false + +[dependencies] +tokio = { version = "1", features = [ + "macros", + "rt", + "rt-multi-thread", + "io-std", + "signal", +] } +anyhow = "1.0" +async-trait = "0.1" +clap = { version = "4.4", features = ["derive"] } +rmcp = { version = "0.1.5", features = ["server", "transport-io"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = [ + "env-filter", + "std", + "fmt", +] } + +[dev-dependencies] +temp-dir = "0.1" diff --git a/crates/gitbutler-mcp/README.md b/crates/gitbutler-mcp/README.md new file mode 100644 index 0000000000..b180d9c4a8 --- /dev/null +++ b/crates/gitbutler-mcp/README.md @@ -0,0 +1,47 @@ +# GitButler MCP + +A Model Context Protocol server implementation for GitButler that provides AI assistants with branch management capabilities. + +## Overview + +`gitbutler-mcp` is a Rust crate that implements a Model Context Protocol (MCP) server for GitButler. It enables AI assistants to perform branch-related operations in GitButler repositories through standardized MCP interactions. + +## Features + +- **Branch Management**: Update branches with summaries and prompts +- **MCP Compliance**: Fully implements the Model Context Protocol specification +- **Tooling Integration**: Provides tools that can be used by AI assistants + +## Usage + +The MCP server can be integrated with AI assistants that support the Model Context Protocol. It exposes tools that allow these assistants to: + +- Update branches with contextual information +- Process prompts and convert them into branch-specific actions + +## Tool Reference + +### `update_branch` + +Updates a GitButler branch with a given prompt and summary. + +**Parameters:** +- `working_directory`: Path to the Git repository +- `full_prompt`: Complete prompt that was used for the branch update +- `summary`: Short description of the changes + +## Development + +To run the MCP server: + +```bash +cargo run -p gitbutler-mcp +``` + +## Integration + +This MCP server can be integrated with any AI assistant that supports the Model Context Protocol (MCP) specification. + +## License + +Same as the GitButler project. \ No newline at end of file diff --git a/crates/gitbutler-mcp/src/common/butler.rs b/crates/gitbutler-mcp/src/common/butler.rs new file mode 100644 index 0000000000..ba7f2a06aa --- /dev/null +++ b/crates/gitbutler-mcp/src/common/butler.rs @@ -0,0 +1,139 @@ +use rmcp::{ + const_string, model::*, schemars, service::RequestContext, tool, Error as McpError, RoleServer, + ServerHandler, +}; +use serde_json::json; +use std::path::PathBuf; +use tracing; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct UpdateBranchRequest { + pub working_directory: String, + pub full_prompt: String, + pub summary: String, +} + +#[derive(Clone)] +pub struct Butler {} + +#[tool(tool_box)] +impl Butler { + #[allow(dead_code)] + pub fn new() -> Self { + Self {} + } + + fn _create_resource_text(&self, uri: &str, name: &str) -> Resource { + RawResource::new(uri, name.to_string()).no_annotation() + } + + #[tool(description = "Update a branch with the given prompt and summary")] + fn update_branch( + &self, + #[tool(aggr)] UpdateBranchRequest { + working_directory, + full_prompt, + summary, + }: UpdateBranchRequest, + ) -> Result { + tracing::info!("Updating branch with prompt: {}", summary); + + // Check if the working directory exists + let project_path = PathBuf::from(&working_directory); + if !project_path.exists() { + return Err(McpError::invalid_params( + "Invalid working directory", + Some(json!({ "error": "Working directory does not exist" })), + )); + } + + // In a real implementation, we would use GitButler's branch management APIs + // But for now, we'll simulate a successful branch update + tracing::info!( + "Would update branch in {} using prompt: {} with summary: {}", + working_directory, + full_prompt, + summary + ); + + Ok(CallToolResult::success(vec![Content::text(format!( + "Branch has been updated with summary: {}", + summary + ))])) + } +} + +const_string!(UpdateBranch = "updateBranch"); + +#[tool(tool_box)] +impl ServerHandler for Butler { + fn get_info(&self) -> ServerInfo { + ServerInfo { + protocol_version: ProtocolVersion::V_2024_11_05, + capabilities: ServerCapabilities::builder() + .enable_prompts() + .enable_resources() + .enable_tools() + .build(), + server_info: Implementation::from_build_env(), + instructions: Some("This server provides a branch update tool that can process prompts and update branches accordingly.".to_string()), + } + } + + async fn list_resources( + &self, + _request: std::option::Option, + _: RequestContext, + ) -> Result { + Ok(ListResourcesResult { + resources: vec![], + next_cursor: None, + }) + } + + async fn read_resource( + &self, + ReadResourceRequestParam { uri }: ReadResourceRequestParam, + _: RequestContext, + ) -> Result { + Err(McpError::resource_not_found( + "resource_not_found", + Some(json!({ + "uri": uri + })), + )) + } + + async fn list_prompts( + &self, + _request: std::option::Option, + _: RequestContext, + ) -> Result { + Ok(ListPromptsResult { + next_cursor: None, + prompts: vec![], + }) + } + + async fn get_prompt( + &self, + GetPromptRequestParam { + name: _name, + arguments: _arguments, + }: GetPromptRequestParam, + _: RequestContext, + ) -> Result { + Err(McpError::invalid_params("prompt not found", None)) + } + + async fn list_resource_templates( + &self, + _request: std::option::Option, + _: RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + next_cursor: None, + resource_templates: Vec::new(), + }) + } +} diff --git a/crates/gitbutler-mcp/src/common/mod.rs b/crates/gitbutler-mcp/src/common/mod.rs new file mode 100644 index 0000000000..6253b13023 --- /dev/null +++ b/crates/gitbutler-mcp/src/common/mod.rs @@ -0,0 +1 @@ +pub mod butler; diff --git a/crates/gitbutler-mcp/src/main.rs b/crates/gitbutler-mcp/src/main.rs new file mode 100644 index 0000000000..7f41200c08 --- /dev/null +++ b/crates/gitbutler-mcp/src/main.rs @@ -0,0 +1,26 @@ +use anyhow::Result; +use common::butler::Butler; + +use rmcp::{transport::stdio, ServiceExt}; +use tracing_subscriber::{self, EnvFilter}; +mod common; +/// npx @modelcontextprotocol/inspector cargo run -p mcp-server-examples --example std_io +#[tokio::main] +async fn main() -> Result<()> { + // Initialize the tracing subscriber with file and stdout logging + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::DEBUG.into())) + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + + tracing::info!("Starting GitButler MCP server"); + + // Create an instance of our counter router + let service = Butler::new().serve(stdio()).await.inspect_err(|e| { + tracing::error!("serving error: {:?}", e); + })?; + + service.waiting().await?; + Ok(()) +} From 401089a6fb1ce735c0cd4c0c47276b75aa32b1e8 Mon Sep 17 00:00:00 2001 From: Scott Chacon Date: Thu, 8 May 2025 08:54:14 +0200 Subject: [PATCH 2/2] actually commit things The MCP server will now actually commit work. Just trying to see how to actually run GitButler stuff via this Rust binary. Currently it will just choose the first stack it sees and blindly commit everything with the unmodified prompt and summary. --- .github/copilot-instructions.md | 68 +------------------ Cargo.lock | 11 ++++ crates/gitbutler-mcp/Cargo.toml | 15 +++++ crates/gitbutler-mcp/README.md | 52 ++++----------- crates/gitbutler-mcp/src/common/butler.rs | 28 ++++++++ crates/gitbutler-mcp/src/common/commit.rs | 76 ++++++++++++++++++++++ crates/gitbutler-mcp/src/common/mod.rs | 2 + crates/gitbutler-mcp/src/common/prepare.rs | 8 +++ 8 files changed, 155 insertions(+), 105 deletions(-) create mode 100644 crates/gitbutler-mcp/src/common/commit.rs create mode 100644 crates/gitbutler-mcp/src/common/prepare.rs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d08c8d7894..01428f4963 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,67 +1 @@ -## General information - -This is a monorepo with multiple projects. -The main applications are found in the `apps` directory. -They are: - -- `desktop` containing the Tauri application's frontend code -- `web` containing the web application's frontend code - -The backend of the Tauri application is found in the `crates` directory. -It contains different rust packages, all used for the Tauri application. - -The `packages` directory contains different self-contained npm packages. -These are shared between the `desktop` and `web` applications. -The packages are: - -- `ui` containing the shared UI components -- `shared` containing the shared types and utils -- `mcp` containing the Model Context Protocol packages -- `no-relative-imports` containing the no-relative-imports ESLINT package - -## Version control - -- Use GitButler tools -- The MCP tools require the absolute path to the repository -- Don't use any other git commands - -### Absorb - -When told to 'absorb' follow these steps: - -1. If there were any instructions given, take them into account. -2. List the file changes -3. Get the hunk dependencies -4. For all files that depend on a **single** commit, amend the file onto that commit. Every time a file is amended, list the hunk dependencies again. -5. If there are no dependencies, list the stacks. Based on the stack branch names and descriptions, determine the best branch to commit the changes to. -6. List the commits in the branch determined in the previous step, and then determine the best commit to amend the changes to based on the description. Update the description if needed. - -### Figure out the commits - -When told to 'figure out the commits' follow these steps: - -1. List the file changes -2. List the stacks -3. Figure out, based on the changes and whether there are any applied stacks/branches: -4. Create a plan for the commits. For that, take a look at the `Create a commit plan` section below. -5. Always execute the plan, and commit the changes as previously determined unless otherwise directed. - -### Creating a commit plan - -Follow this instructions when creating a commit plan: - -1. Group the file changes into logical groups - - Take a look at their diffs and determine if they are related. - - Groups are good, but prefer to have smaller commits than larger ones. - - Granularity is good. -2. Determine if any branches should be created - - If there are any stacks with branches, take a look at the branch names and descriptions and match them with the file changes. - - Create multiple branches if needed. You can tell if multiple branches are needed if the file changes span multiple projects in the monorepo. -3. Determine the commits - - For each group of file changes, determine the commit message. Be descriptive and explain what the changes are. - - If branches need to be created, use a descriptive name for the branch. - - Determine the order of the commits. If there are any dependencies between the commits, make sure to commit them in the correct order. - - Define which commits should go into which branches. - - If there were any other instructions given, take them into account. - -In the end, the plan should contain a list of the branches to be created (if any) and the commits to be made +After every session that adds, deletes or modifies code, call the gitbutler update_branch MCP call to update the current branch and then inform the user what happened based on the output of that call. diff --git a/Cargo.lock b/Cargo.lock index 67ec61c93b..99893de1e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2704,7 +2704,18 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "but-settings", "clap", + "gitbutler-branch", + "gitbutler-branch-actions", + "gitbutler-command-context", + "gitbutler-diff", + "gitbutler-oplog", + "gitbutler-oxidize", + "gitbutler-project", + "gitbutler-reference", + "gitbutler-stack", + "gix", "rmcp", "serde", "serde_json", diff --git a/crates/gitbutler-mcp/Cargo.toml b/crates/gitbutler-mcp/Cargo.toml index b243baf1cf..afc4b3e185 100644 --- a/crates/gitbutler-mcp/Cargo.toml +++ b/crates/gitbutler-mcp/Cargo.toml @@ -6,6 +6,10 @@ description = "Model Context Protocol server for GitButler" authors = ["GitButler "] publish = false +[[bin]] +name = "gitbutler-mcp" +path = "src/main.rs" + [dependencies] tokio = { version = "1", features = [ "macros", @@ -27,6 +31,17 @@ tracing-subscriber = { version = "0.3", features = [ "std", "fmt", ] } +gitbutler-oplog.workspace = true +gitbutler-project.workspace = true +gitbutler-reference.workspace = true +gitbutler-branch-actions.workspace = true +gitbutler-command-context.workspace = true +gitbutler-branch.workspace = true +gitbutler-diff.workspace = true +but-settings.workspace = true +gitbutler-stack.workspace = true +gitbutler-oxidize.workspace = true +gix = { workspace = true, features = ["max-performance", "tracing"] } [dev-dependencies] temp-dir = "0.1" diff --git a/crates/gitbutler-mcp/README.md b/crates/gitbutler-mcp/README.md index b180d9c4a8..65b0d98634 100644 --- a/crates/gitbutler-mcp/README.md +++ b/crates/gitbutler-mcp/README.md @@ -1,47 +1,23 @@ -# GitButler MCP +# Butler MCP -A Model Context Protocol server implementation for GitButler that provides AI assistants with branch management capabilities. +This crate implments a single binary Rust MCP server that can be pointed to by an Agent to do some basic branch management work with the GitButler tooling. -## Overview +It implements a single tool called 'update_branch' that will look at uncommitted changes in the working directory and either commit them or amend an existing commit. -`gitbutler-mcp` is a Rust crate that implements a Model Context Protocol (MCP) server for GitButler. It enables AI assistants to perform branch-related operations in GitButler repositories through standardized MCP interactions. +If there is no existing branch, it will create a new one. -## Features +If AI capabilities are enabled, it will also use the AI to generate a commit message for the changes based on the prompt. -- **Branch Management**: Update branches with summaries and prompts -- **MCP Compliance**: Fully implements the Model Context Protocol specification -- **Tooling Integration**: Provides tools that can be used by AI assistants +## The Idea -## Usage +The concept is not to give an Agent an endpoint to every API that we have, which mainly results in being able to use the agent as a slow command line. The idea is to have a few very powerful tools that can be used to do a lot of work automatically. -The MCP server can be integrated with AI assistants that support the Model Context Protocol. It exposes tools that allow these assistants to: +Most of the work should be done in GitButler for more specific tasks, but updating a branch with new work generated via agentic work can be simple and powerful. -- Update branches with contextual information -- Process prompts and convert them into branch-specific actions +## TODO -## Tool Reference - -### `update_branch` - -Updates a GitButler branch with a given prompt and summary. - -**Parameters:** -- `working_directory`: Path to the Git repository -- `full_prompt`: Complete prompt that was used for the branch update -- `summary`: Short description of the changes - -## Development - -To run the MCP server: - -```bash -cargo run -p gitbutler-mcp -``` - -## Integration - -This MCP server can be integrated with any AI assistant that supports the Model Context Protocol (MCP) specification. - -## License - -Same as the GitButler project. \ No newline at end of file +- [ ] create a new branch if there is no existing one +- [ ] determine the actual branch name to use of everything existing +- [ ] determine if a new virtual branch should be created +- [ ] determine if work should be committed or amended +- [ ] use the AI to generate a commit message \ No newline at end of file diff --git a/crates/gitbutler-mcp/src/common/butler.rs b/crates/gitbutler-mcp/src/common/butler.rs index ba7f2a06aa..b901ced5c8 100644 --- a/crates/gitbutler-mcp/src/common/butler.rs +++ b/crates/gitbutler-mcp/src/common/butler.rs @@ -6,6 +6,9 @@ use serde_json::json; use std::path::PathBuf; use tracing; +use crate::common::commit::commit; +use crate::common::prepare::project_from_path; + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct UpdateBranchRequest { pub working_directory: String, @@ -47,6 +50,12 @@ impl Butler { )); } + let project = project_from_path(project_path).unwrap(); + dbg!(&project); + + let _commit = commit(project, full_prompt.clone(), summary.clone()); + dbg!(&_commit); + // In a real implementation, we would use GitButler's branch management APIs // But for now, we'll simulate a successful branch update tracing::info!( @@ -63,6 +72,25 @@ impl Butler { } } +// simple test with my working directory +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_update_branch() { + let butler = Butler::new(); + let request = UpdateBranchRequest { + working_directory: "/Users/schacon/projects/gitbutler".to_string(), + full_prompt: "Update branch with new changes".to_string(), + summary: "Updated branch with new changes".to_string(), + }; + + let result = butler.update_branch(request); + dbg!(result); + } +} + const_string!(UpdateBranch = "updateBranch"); #[tool(tool_box)] diff --git a/crates/gitbutler-mcp/src/common/commit.rs b/crates/gitbutler-mcp/src/common/commit.rs new file mode 100644 index 0000000000..34dbfee7c6 --- /dev/null +++ b/crates/gitbutler-mcp/src/common/commit.rs @@ -0,0 +1,76 @@ +use anyhow::{bail, Result}; +use but_settings::AppSettings; +use gitbutler_branch::{BranchCreateRequest, BranchIdentity, BranchUpdateRequest}; +use gitbutler_branch_actions::{get_branch_listing_details, list_branches, BranchManagerExt}; +use gitbutler_command_context::CommandContext; +use gitbutler_project::Project; +use gitbutler_reference::{LocalRefname, Refname}; +use gitbutler_stack::{Stack, VirtualBranchesHandle}; + +pub fn commit(project: Project, full_prompt: String, summary: String) -> Result<()> { + let ctx = CommandContext::open(&project, AppSettings::default())?; + let list_result = gitbutler_branch_actions::list_virtual_branches(&ctx)?; + + // just get the first stack for now + let stack = VirtualBranchesHandle::new(project.gb_dir()) + .list_stacks_in_workspace()? + .into_iter() + .next() + .ok_or(anyhow::anyhow!("No stacks found in the project directory"))?; + + dbg!(&stack); + + if !list_result.skipped_files.is_empty() { + eprintln!( + "{} files could not be processed (binary or large size)", + list_result.skipped_files.len() + ) + } + + dbg!(&list_result); + + let target_branch = list_result + .branches + .iter() + .next() + .expect("A populated branch exists for a branch we can list"); + if target_branch.ownership.claims.is_empty() { + bail!( + "Branch has no change to commit{hint}", + hint = { + let candidate_names = list_result + .branches + .iter() + .filter_map(|b| (!b.ownership.claims.is_empty()).then_some(b.name.as_str())) + .collect::>(); + let mut candidates = candidate_names.join(", "); + if !candidate_names.is_empty() { + candidates = format!( + ". {candidates} {have} changes.", + have = if candidate_names.len() == 1 { + "has" + } else { + "have" + } + ) + }; + candidates + } + ) + } + + let message = full_prompt + "\n\n" + &summary; + dbg!(&message); + + let _oid = gitbutler_branch_actions::create_commit( + &ctx, + stack.id, + &message, + Some(&target_branch.ownership), + )?; + + dbg!("Commit created successfully"); + dbg!(_oid); + + Ok(()) +} diff --git a/crates/gitbutler-mcp/src/common/mod.rs b/crates/gitbutler-mcp/src/common/mod.rs index 6253b13023..f0256369b8 100644 --- a/crates/gitbutler-mcp/src/common/mod.rs +++ b/crates/gitbutler-mcp/src/common/mod.rs @@ -1 +1,3 @@ pub mod butler; +pub mod commit; +pub mod prepare; diff --git a/crates/gitbutler-mcp/src/common/prepare.rs b/crates/gitbutler-mcp/src/common/prepare.rs new file mode 100644 index 0000000000..73d8279684 --- /dev/null +++ b/crates/gitbutler-mcp/src/common/prepare.rs @@ -0,0 +1,8 @@ +use std::path::PathBuf; + +use anyhow::{bail, Context}; +use gitbutler_project::Project; + +pub fn project_from_path(path: PathBuf) -> anyhow::Result { + Project::from_path(&path) +}