Files
buzz/crates/buzz-dev-mcp/src/lib.rs
Alex Rosenzweig 4f51b9e101 fix(mcp): support Goose discovery handshake (#8037)
## Summary

Goose 1.52 starts MCP connections with `server/discover`. `buzz-dev-mcp`
rejects that request with `ExpectedInitializeRequest`, preventing Goose
from loading its Buzz tools.

Upgrade the tool server to rmcp 3.4.1 and adopt the renamed SDK types.
Subprocess tests exercise discovery and legacy initialization, tool
listing, file reads/edits, invalid arguments, connection survival, and
EOF shutdown. Include the server in both Rust unit-test runners and make
the cancellation regression approve its shell call before testing
cancellation.

The native agent retains its independent rmcp 1.x client. Cargo also
unifies the existing 3.x dependency used by the mesh smoke harness.

## Validation

- Normal `git push` passed all hooks, including Rust tests, Tauri
checks, and repository policy checks.
- `just ci` passed on the final stack: repository checks, Rust and Tauri
tests/lint, desktop tests/build, web build, and mobile checks/tests.
- MCP: 78 existing tests and two subprocess lifecycle tests passed; the
discovery regression fails with the old SDK.
- All 54 native-agent regressions passed on the final stack, including
cancellation of a real MCP shell process.
- Previously exercised unmodified Goose 1.52 and 1.47 with Sandpit and
the real tool server. Allowed workspace operations and permitted network
traffic succeeded; disallowed file and network operations were denied.
- Live staging desktop check with Goose 1.52: the reply appeared in
Buzz, an allowed request returned HTTP 200, and a disallowed request
returned CONNECT 403.

## Stack

1. **#8042 → `main`**: deterministic handoff regression.
2. **This PR → #8042**: Goose MCP discovery compatibility.

Merge the lower PR first, then retarget this PR to `main`. This diff
contains only the compatibility change and its tests; the relay build
repair is already on `main` in #8036.

---------

Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
2026-10-02 11:24:25 -05:00

212 lines
8.1 KiB
Rust

#![cfg_attr(not(windows), forbid(unsafe_code))]
#![cfg_attr(windows, deny(unsafe_code))]
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ServerCapabilities, ServerConfig},
tool, tool_handler, tool_router,
transport::stdio,
ErrorData, ServerHandler, ServiceExt,
};
use std::path::Path;
use std::sync::Arc;
mod paths;
mod read_file;
mod rg;
mod shell;
mod shim;
mod str_replace;
mod todo;
mod tree;
mod view_image;
#[derive(Clone)]
struct DevMcp {
state: Arc<shell::SharedState>,
todos: Arc<todo::TodoState>,
tool_router: ToolRouter<DevMcp>,
}
#[tool_router]
impl DevMcp {
fn new(state: Arc<shell::SharedState>) -> Self {
Self {
state,
todos: Arc::new(todo::TodoState::new()),
tool_router: Self::tool_router(),
}
}
#[tool(
name = "shell",
description = "Run a shell command (bash by default; set `BUZZ_SHELL` to use cmd, PowerShell, or another shell). Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms defaults to 120000 (2 min) if omitted; capped at 1,200,000 (20 min). For long-running commands (git push with hooks, cargo build, test suites), use 300000+. On PATH: rg (prefer over grep; flags: -n -i -l -g <glob> -C <n> --files), tree (flags: -d <depth>; shows line counts), and buzz (Buzz relay CLI — run buzz --help for commands)."
)]
async fn shell(
&self,
Parameters(p): Parameters<shell::ShellParams>,
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<CallToolResult, ErrorData> {
shell::run(&self.state, p, context.ct).await
}
#[tool(
name = "read_file",
description = "Read a text file and return its contents with line numbers. Returns lines in `{number}:{content}` format. Use `offset` (0-based) and `limit` (default 2000) to window into large files. Path resolved relative to workdir (defaults to server cwd). Prefer over cat/head/tail."
)]
async fn read_file(
&self,
Parameters(p): Parameters<read_file::ReadFileParams>,
) -> Result<String, ErrorData> {
read_file::run(&self.state, p)
}
#[tool(
name = "view_image",
description = "Load an image from a file path, http(s) URL, or data: URL and return it as an MCP image content block that multimodal LLMs (Anthropic, OpenAI-compatible, etc.) can see. Resizes to a longest-edge of 1568px by default (override with `max_dim`, range 64..=2048). Pass-through for already-small PNG/JPEG; transcodes oversize input to PNG (if alpha) or JPEG q85. Animated GIF/WebP rejected — provide a still frame. Hard cap 20 MiB source, ~4 MiB on the wire. Relative paths resolve under `workdir` (defaults to server cwd) and may not escape it."
)]
async fn view_image(
&self,
Parameters(p): Parameters<view_image::ViewImageParams>,
) -> Result<CallToolResult, ErrorData> {
view_image::run(&self.state, p).await
}
#[tool(
name = "str_replace",
description = "Atomic find-and-replace in a file. old_str must occur exactly once unless replace_all is true, in which case all occurrences are replaced. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk."
)]
async fn str_replace(
&self,
Parameters(p): Parameters<str_replace::StrReplaceParams>,
) -> Result<String, ErrorData> {
str_replace::run(&self.state, p)
}
#[tool(
name = "todo",
description = "Session checklist only for work that must continue across turns or survive context compaction. Do not use for work you can finish in the current turn. Omit `todos` to read; provide the full {text, done} list to replace it. Open items let the _Stop hook advise against ending."
)]
async fn todo(
&self,
Parameters(p): Parameters<todo::TodoParams>,
) -> Result<CallToolResult, ErrorData> {
match self.todos.handle_todo(p) {
Ok(text) => todo::text_result(text),
Err(e) => todo::error_result(format!("Error: {e}")),
}
}
/// Hook: called by the agent before honoring end_turn. Returns
/// non-empty objection text iff items remain open.
#[tool(
name = "_Stop",
description = "Returns open todo items if any exist. Used by the agent's _Stop lifecycle hook to advise against ending with incomplete work."
)]
async fn stop_hook(
&self,
Parameters(_): Parameters<todo::HookParams>,
) -> Result<CallToolResult, ErrorData> {
todo::text_result(self.todos.stop_objection())
}
/// Hook: called by the agent after context compaction/handoff so the
/// todo list survives history truncation.
#[tool(
name = "_PostCompact",
description = "Internal hook. Agent invokes after handoff; returns todo state for re-injection."
)]
async fn post_compact_hook(
&self,
Parameters(_): Parameters<todo::HookParams>,
) -> Result<CallToolResult, ErrorData> {
todo::text_result(self.todos.post_compact())
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for DevMcp {
fn get_info(&self) -> ServerConfig {
ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(rmcp::model::Implementation::new(
"buzz-dev-mcp",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(self.state.bootstrap_instructions.clone())
}
}
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
let argv0 = std::env::args().next().unwrap_or_default();
let cmd = Path::new(&argv0)
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_ascii_lowercase();
// Multicall dispatch — sync personalities exit before any runtime is built.
// No tracing, no tokio, no allocations beyond argv parsing.
match cmd.as_str() {
"rg" => std::process::exit(rg::run(std::env::args().skip(1).collect())),
"tree" => std::process::exit(tree::run(std::env::args().skip(1).collect())),
_ => {}
}
// Async personalities and MCP server mode — build the runtime.
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async_main(cmd))
}
async fn async_main(cmd: String) -> Result<(), Box<dyn std::error::Error>> {
// HTTPS clients invoked through this MCP process need a Rustls provider;
// repeated installation is harmless.
let _ = rustls::crypto::ring::default_provider().install_default();
// buzz CLI needs tokio (async HTTP client).
if cmd == "buzz" {
std::process::exit(buzz_cli::run_from_args(std::env::args()).await);
}
// MCP server mode — safe to init tracing now.
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
let cwd = std::env::current_dir()?;
let shim = shim::Shim::install()?;
let state = Arc::new(shell::SharedState::new(cwd, shim)?);
let service = DevMcp::new(state).serve(stdio()).await?;
service.waiting().await?;
Ok(())
}
/// Suppress the console window that Windows otherwise allocates for every
/// console-subsystem child process spawned from a non-console parent.
/// No-op on non-Windows platforms.
pub(crate) fn configure_no_window(cmd: &mut std::process::Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = cmd;
}
/// Suppress the console window for async (`tokio::process::Command`) spawns.
/// Equivalent to `configure_no_window` but accepts a tokio command.
/// No-op on non-Windows platforms.
pub(crate) fn configure_no_window_async(cmd: &mut tokio::process::Command) {
#[cfg(windows)]
{
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
let _ = cmd;
}