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.
This commit is contained in:
		
							parent
							
								
									53494592e3
								
							
						
					
					
						commit
						addff82ff4
					
				
							
								
								
									
										5
									
								
								.github/copilot-instructions.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/copilot-instructions.md
									
									
									
									
										vendored
									
									
								
							@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 `<file>_spec.rb` pass `<file>`. " \
 | 
			
		||||
                           "Appending `:<line_number>` 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]))
 | 
			
		||||
 | 
			
		||||
@ -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"
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user