From addff82ff48fedb840c2306507a8ae139abd2773 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Fri, 22 Aug 2025 15:51:14 +0100 Subject: [PATCH] Improve MCP server with development commands. Add these extra commands, better argument handling, progress reporting (for long-running commands), some basic refactoring and mention these commands and flags in the Copilot instructions. --- .github/copilot-instructions.md | 5 +- Library/Homebrew/mcp_server.rb | 164 ++++++++++++++++++++--- Library/Homebrew/test/mcp_server_spec.rb | 2 +- 3 files changed, 150 insertions(+), 21 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7d9913eeb8..52e4ec563d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,10 +11,11 @@ Please follow these guidelines when contributing: - Run `brew typecheck` to verify types are declared correctly using Sorbet. Individual files/directories cannot be checked. `brew typecheck` is fast enough to just be run globally every time. -- Run `brew style --fix` to lint code formatting using RuboCop. +- Run `brew style --fix --changed` to lint code formatting using RuboCop. Individual files can be checked/fixed by passing them as arguments e.g. `brew style --fix Library/Homebrew/cmd/reinstall.rb`` -- Run `brew tests --online` to ensure that RSpec unit tests are passing (although some online tests may be flaky so can be ignored if they pass on a rerun). +- Run `brew tests --online --changed` to ensure that RSpec unit tests are passing (although some online tests may be flaky so can be ignored if they pass on a rerun). Individual test files can be passed with `--only` e.g. to test `Library/Homebrew/cmd/reinstall.rb` with `Library/Homebrew/test/cmd/reinstall_spec.rb` run `brew tests --only=cmd/reinstall`. +- All of the above can be run with the `brew-mcp-server` ### Development Flow diff --git a/Library/Homebrew/mcp_server.rb b/Library/Homebrew/mcp_server.rb index 5e0e440a56..ec50812fe5 100644 --- a/Library/Homebrew/mcp_server.rb +++ b/Library/Homebrew/mcp_server.rb @@ -110,6 +110,61 @@ module Homebrew command: "brew doctor", inputSchema: { type: "object", properties: {} }, }, + typecheck: { + name: "typecheck", + description: "Check for typechecking errors using Sorbet.", + command: "brew typecheck", + inputSchema: { type: "object", properties: {} }, + }, + style: { + name: "style", + description: "Check formulae or files for conformance to Homebrew style guidelines.", + command: "brew style", + inputSchema: { + type: "object", + properties: { + fix: { + type: "boolean", + description: "Fix style violations automatically using RuboCop's auto-correct feature", + }, + files: { + type: "string", + description: "Specific files to check (space-separated)", + }, + changed: { + type: "boolean", + description: "Only check files that were changed from the `main` branch", + }, + }, + }, + }, + tests: { + name: "tests", + description: "Run Homebrew's unit and integration tests.", + command: "brew tests", + inputSchema: { + type: "object", + properties: { + only: { + type: "string", + description: "Specific tests to run (comma-seperated) e.g. for `_spec.rb` pass ``. " \ + "Appending `:` will start at a specific line", + }, + fail_fast: { + type: "boolean", + description: "Exit early on the first failing test", + }, + changed: { + type: "boolean", + description: "Only runs tests on files that were changed from the `main` branch", + }, + online: { + type: "boolean", + description: "Run online tests", + }, + }, + }, + }, commands: { name: "commands", description: "Show lists of built-in and external commands.", @@ -233,29 +288,102 @@ module Homebrew when "tools/list" respond_result(id, { tools: TOOLS.values }) when "tools/call" - if (tool = TOOLS.fetch(request["params"]["name"].to_sym, nil)) - require "shellwords" - - arguments = request["params"]["arguments"] - argument = arguments.fetch("formula_or_cask", "") - argument = arguments.fetch("text_or_regex", "") if argument.strip.empty? - argument = arguments.fetch("command", "") if argument.strip.empty? - argument = nil if argument.strip.empty? - brew_command = T.cast(tool.fetch(:command), String) - .delete_prefix("brew ") - full_command = [HOMEBREW_BREW_FILE, brew_command, argument].compact - .map { |arg| Shellwords.escape(arg) } - .join(" ") - output = `#{full_command} 2>&1`.strip - respond_result(id, { content: [{ type: "text", text: output }] }) - else - respond_error(id, "Unknown tool") - end + respond_to_tools_call(id, request) else respond_error(id, "Method not found") end end + sig { params(id: Integer, request: T::Hash[String, T.untyped]).returns(T.nilable(T::Hash[Symbol, T.anything])) } + def respond_to_tools_call(id, request) + tool_name = request["params"]["name"].to_sym + tool = TOOLS.fetch tool_name do + return respond_error(id, "Unknown tool") + end + + require "open3" + + command_args = tool_command_arguments(tool_name, request["params"]["arguments"]) + progress_token = request["params"]["_meta"]&.fetch("progressToken", nil) + brew_command = T.cast(tool.fetch(:command), String) + .delete_prefix("brew ") + buffer_size = 4096 # 4KB + progress = T.let(0, Integer) + done = T.let(false, T::Boolean) + new_output = T.let(false, T::Boolean) + output = +"" + + text = Open3.popen2e(HOMEBREW_BREW_FILE, brew_command, *command_args) do |stdin, io, _wait| + stdin.close + + reader = Thread.new do + loop do + output << io.readpartial(buffer_size) + progress += 1 + new_output = true + end + rescue EOFError + nil + ensure + done = true + end + + until done + break unless progress_token + + sleep 1 + next unless new_output + + response = { + jsonrpc: JSON_RPC_VERSION, + method: "notifications/progress", + params: { progressToken: progress_token, progress: }, + } + progress_output = JSON.dump(response).strip + @stdout.puts(progress_output) + @stdout.flush + + new_output = false + end + + reader.join + + output + end + + respond_result(id, { content: [{ type: "text", text: }] }) + end + + sig { params(tool_name: Symbol, arguments: T::Hash[String, T.untyped]).returns(T::Array[String]) } + def tool_command_arguments(tool_name, arguments) + require "shellwords" + + case tool_name + when :style + style_args = [] + style_args << "--fix" if arguments["fix"] + file_arguments = arguments.fetch("files", "").strip.split + style_args.concat(file_arguments) unless file_arguments.empty? + style_args + when :tests + tests_args = [] + only_arguments = arguments.fetch("only", "").strip + tests_args << "--only=#{only_arguments}" unless only_arguments.empty? + tests_args << "--fail-fast" if arguments["fail_fast"] + tests_args << "--changed" if arguments["changed"] + tests_args << "--online" if arguments["online"] + tests_args + when :search + [arguments["text_or_regex"]] + when :help + [arguments["command"]] + else + [arguments["formula_or_cask"]] + end.compact + .reject(&:empty?) + .map { |arg| Shellwords.escape(arg) } + end + sig { params(id: T.nilable(Integer), result: T::Hash[Symbol, T.anything]).returns(T.nilable(T::Hash[Symbol, T.anything])) diff --git a/Library/Homebrew/test/mcp_server_spec.rb b/Library/Homebrew/test/mcp_server_spec.rb index 96c303fe5e..5e15987b3e 100644 --- a/Library/Homebrew/test/mcp_server_spec.rb +++ b/Library/Homebrew/test/mcp_server_spec.rb @@ -130,7 +130,7 @@ RSpec.describe Homebrew::McpServer do Homebrew::McpServer::TOOLS.each do |tool_name, tool_definition| it "responds to tools/call for #{tool_name}" do - allow(server).to receive(:`).and_return("output for #{tool_name}") + allow(Open3).to receive(:popen2e).and_return("output for #{tool_name}") arguments = {} Array(tool_definition[:required]).each do |required_key| arguments[required_key] = "dummy"