From 75f1c16ff6cc66cbf4c0b73ae85f36ed0523b31b Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Sat, 15 Feb 2025 08:32:38 +0100 Subject: [PATCH 1/3] Add initial implementation of an MCP server --- commands/completion_posix.go | 6 +- commands/local_mcp_server_start.go | 43 +++++++ commands/root.go | 1 + go.mod | 4 +- go.sum | 5 +- local/mcp/app.go | 94 +++++++++++++++ local/mcp/server.go | 179 +++++++++++++++++++++++++++++ local/php/symfony.go | 15 +-- main.go | 6 +- 9 files changed, 338 insertions(+), 15 deletions(-) create mode 100644 commands/local_mcp_server_start.go create mode 100644 local/mcp/app.go create mode 100644 local/mcp/server.go diff --git a/commands/completion_posix.go b/commands/completion_posix.go index 5144a782..163fdc81 100644 --- a/commands/completion_posix.go +++ b/commands/completion_posix.go @@ -31,7 +31,11 @@ func autocompleteSymfonyConsoleWrapper(context *console.Context, words complete. // Composer does not support those options yet, so we only use them for Symfony Console args = append(args, "-a1", fmt.Sprintf("-s%s", console.GuessShell())) - if executor, err := php.SymfonyConsoleExecutor(args); err == nil { + dir, err := os.Getwd() + if err != nil { + return []string{} + } + if executor, err := php.SymfonyConsoleExecutor(dir, args); err == nil { os.Exit(executor.Execute(false)) } diff --git a/commands/local_mcp_server_start.go b/commands/local_mcp_server_start.go new file mode 100644 index 00000000..4cf18f5e --- /dev/null +++ b/commands/local_mcp_server_start.go @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025-present Fabien Potencier + * + * This file is part of Symfony CLI project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package commands + +import ( + "github.com/symfony-cli/console" + "github.com/symfony-cli/symfony-cli/local/mcp" +) + +var localMcpServerStartCmd = &console.Command{ + Category: "local", + Name: "mcp:start", + Aliases: []*console.Alias{{Name: "mcp:start"}}, + Usage: "Run a local MCP server", + Description: localWebServerProdWarningMsg, + Args: console.ArgDefinition{ + {Name: "bin", Optional: true, Description: "The path to the Symfony CLI binary"}, + }, + Action: func(c *console.Context) error { + server, err := mcp.NewServer(c.Args().Get("bin")) + if err != nil { + return err + } + return server.Start() + }, +} diff --git a/commands/root.go b/commands/root.go index 01cbaa44..62ad65ed 100644 --- a/commands/root.go +++ b/commands/root.go @@ -57,6 +57,7 @@ func CommonCommands() []*console.Command { bookCheckoutCmd, cloudEnvDebugCmd, doctrineCheckServerVersionSettingCmd, + localMcpServerStartCmd, localNewCmd, localPhpListCmd, localPhpRefreshCmd, diff --git a/go.mod b/go.mod index ea93e946..35adb90a 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/golang-lru/arc/v2 v2.0.7 github.com/joho/godotenv v1.5.1 + github.com/mark3labs/mcp-go v0.8.4 github.com/mitchellh/go-homedir v1.1.0 github.com/nxadm/tail v1.4.11 github.com/olekukonko/tablewriter v0.0.5 @@ -50,13 +51,14 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/btree v1.1.3 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect diff --git a/go.sum b/go.sum index 56b930f6..742b5907 100644 --- a/go.sum +++ b/go.sum @@ -82,9 +82,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v0.8.4 h1:/VxjJ0+4oN2eYLuAgVzixrYNfrmwJnV38EfPIX3VbPE= +github.com/mark3labs/mcp-go v0.8.4/go.mod h1:cjMlBU0cv/cj9kjlgmRhoJ5JREdS7YX83xeIG9Ko/jE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/local/mcp/app.go b/local/mcp/app.go new file mode 100644 index 00000000..0faa1886 --- /dev/null +++ b/local/mcp/app.go @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025-present Fabien Potencier + * + * This file is part of Symfony CLI project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package mcp + +import ( + "bytes" + "encoding/json" + + "github.com/pkg/errors" + "github.com/symfony-cli/symfony-cli/local/php" +) + +type Application struct { + Commands []command +} + +type command struct { + Name string + Description string + Help string + Definition definition + Hidden bool +} + +type definition struct { + Arguments map[string]argument + Options map[string]option +} + +type argument struct { + Required bool `json:"is_required"` + IsArray bool `json:"is_array"` + Description string `json:"description"` + Default interface{} `json:"default"` +} + +type option struct { + Description string `json:"description"` + AcceptValue bool `json:"accept_value"` + IsValueRequired bool `json:"is_value_required"` + IsMultiple bool `json:"is_multiple"` + Default interface{} `json:"default"` +} + +func NewApp(projectDir string) (*Application, error) { + app, err := parseApplication(projectDir) + if err != nil { + return nil, err + } + + return app, nil +} + +func parseApplication(projectDir string) (*Application, error) { + var buf bytes.Buffer + var bufErr bytes.Buffer + e := &php.Executor{ + BinName: "php", + Dir: projectDir, + Args: []string{"php", "bin/console", "list", "--format=json"}, + Stdout: &buf, + Stderr: &bufErr, + } + if ret := e.Execute(false); ret != 0 { + return nil, errors.Errorf("unable to list commands: %s\n%s", bufErr.String(), buf.String()) + } + + // Fix PHP types + cleanOutput := bytes.ReplaceAll(buf.Bytes(), []byte(`"arguments":[]`), []byte(`"arguments":{}`)) + + var app *Application + if err := json.Unmarshal(cleanOutput, &app); err != nil { + return nil, err + } + + return app, nil +} diff --git a/local/mcp/server.go b/local/mcp/server.go new file mode 100644 index 00000000..7013b249 --- /dev/null +++ b/local/mcp/server.go @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025-present Fabien Potencier + * + * This file is part of Symfony CLI project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package mcp + +import ( + "bytes" + "context" + "fmt" + "regexp" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/symfony-cli/symfony-cli/local/php" +) + +type MCP struct { + server *server.MCPServer + app *Application + projectDir string +} + +var excludedCommands = map[string]bool{ + "list": true, + "_complete": true, + "completion": true, +} + +var excludedOptions = map[string]bool{ + "help": true, + "silent": true, + "quiet": true, + "verbose": true, + "version": true, + "ansi": true, + "no-ansi": true, + "env": true, + "format": true, + "no-interaction": true, + "no-debug": true, + "profile": true, +} + +func NewServer(projectDir string) (*MCP, error) { + mcp := &MCP{ + projectDir: projectDir, + } + + mcp.server = server.NewMCPServer( + "Symfony CLI Server", + "1.0.0", + server.WithLogging(), + server.WithResourceCapabilities(true, true), + ) + + var err error + mcp.app, err = NewApp(projectDir) + if err != nil { + return nil, err + } + for _, command := range mcp.app.Commands { + if _, ok := excludedCommands[command.Name]; ok { + continue + } + if command.Hidden { + continue + } + if err := mcp.addTool(command); err != nil { + return nil, err + } + } + + return mcp, nil +} + +func (p *MCP) Start() error { + return server.ServeStdio(p.server) +} + +func (p *MCP) addTool(cmd command) error { + toolOptions := []mcp.ToolOption{} + toolOptions = append(toolOptions, mcp.WithDescription(cmd.Description+"\n\n"+cmd.Help)) + for name, arg := range cmd.Definition.Arguments { + argOptions := []mcp.PropertyOption{ + mcp.Description(arg.Description), + } + if arg.Required { + argOptions = append(argOptions, mcp.Required()) + } + toolOptions = append(toolOptions, mcp.WithString("arg_"+name, argOptions...)) + } + for name, option := range cmd.Definition.Options { + if _, ok := excludedOptions[name]; ok { + continue + } + optOptions := []mcp.PropertyOption{ + mcp.Description(option.Description), + } + if option.AcceptValue { + toolOptions = append(toolOptions, mcp.WithString("opt_"+name, optOptions...)) + } else { + toolOptions = append(toolOptions, mcp.WithBoolean("opt_"+name, optOptions...)) + } + } + + toolName := strings.ReplaceAll(cmd.Name, ":", "-") + regexp := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) + if !regexp.MatchString(toolName) { + return fmt.Errorf("invalid command name: %s", cmd.Name) + } + + tool := mcp.NewTool(toolName, toolOptions...) + + handler := func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + executorArgs := []string{cmd.Name} + for name, value := range request.Params.Arguments { + if strings.HasPrefix(name, "arg_") { + arg, ok := value.(string) + if !ok { + return mcp.NewToolResultError(fmt.Sprintf("argument value for \"%s\" must be a string", name)), nil + } + executorArgs = append(executorArgs, arg) + } else if strings.HasPrefix(name, "opt_") { + if cmd.Definition.Options[strings.TrimPrefix(name, "opt_")].AcceptValue { + arg, ok := value.(string) + if !ok { + return mcp.NewToolResultError(fmt.Sprintf("argument value for \"%s\" must be a string", name)), nil + } + executorArgs = append(executorArgs, fmt.Sprintf("--%s=%s", strings.TrimPrefix(name, "opt_"), arg)) + } else { + arg, ok := value.(bool) + if !ok { + return mcp.NewToolResultError(fmt.Sprintf("argument value for \"%s\" must be a string", name)), nil + } + if arg { + executorArgs = append(executorArgs, fmt.Sprintf("--%s", strings.TrimPrefix(name, "opt_"))) + } + } + } else { + return mcp.NewToolResultText(fmt.Sprintf("Unknown argument: %s", name)), nil + } + } + executorArgs = append(executorArgs, "--no-ansi") + executorArgs = append(executorArgs, "--no-interaction") + e, err := php.SymfonyConsoleExecutor(p.projectDir, executorArgs) + if err != nil { + return nil, err + } + e.Dir = p.projectDir + var buf bytes.Buffer + e.Stdout = &buf + e.Stderr = &buf + if ret := e.Execute(false); ret != 0 { + return mcp.NewToolResultError(fmt.Sprintf("Error running %s (exit code: %d)\n%s", strings.Join(executorArgs, " "), ret, buf.String())), nil + } + return mcp.NewToolResultText(buf.String()), nil + } + + p.server.AddTool(tool, handler) + + return nil +} diff --git a/local/php/symfony.go b/local/php/symfony.go index c0ee7981..9d1a562c 100644 --- a/local/php/symfony.go +++ b/local/php/symfony.go @@ -10,15 +10,10 @@ import ( // SymfonyConsoleExecutor returns an Executor prepared to run Symfony Console. // It returns an error if no console binary is found. -func SymfonyConsoleExecutor(args []string) (*Executor, error) { - dir, err := os.Getwd() - if err != nil { - return nil, errors.WithStack(err) - } - +func SymfonyConsoleExecutor(projectDir string, args []string) (*Executor, error) { for { for _, consolePath := range []string{"bin/console", "app/console"} { - consolePath = filepath.Join(dir, consolePath) + consolePath = filepath.Join(projectDir, consolePath) if _, err := os.Stat(consolePath); err == nil { return &Executor{ BinName: "php", @@ -27,11 +22,11 @@ func SymfonyConsoleExecutor(args []string) (*Executor, error) { } } - upDir := filepath.Dir(dir) - if upDir == dir || upDir == "." { + upDir := filepath.Dir(projectDir) + if upDir == projectDir || upDir == "." { break } - dir = upDir + projectDir = upDir } return nil, errors.New("No console binary found") diff --git a/main.go b/main.go index bdd7af30..63613de9 100644 --- a/main.go +++ b/main.go @@ -74,7 +74,11 @@ func main() { } // called via "symfony console"? if len(args) >= 2 && args[1] == "console" { - if executor, err := php.SymfonyConsoleExecutor(args[2:]); err == nil { + dir, err := os.Getwd() + if err != nil { + os.Exit(1) + } + if executor, err := php.SymfonyConsoleExecutor(dir, args[2:]); err == nil { executor.Logger = terminal.Logger executor.ExtraEnv = getCliExtraEnv() os.Exit(executor.Execute(false)) From a0bd3cd570b59512838df57a9ff309a86dd469d9 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Mon, 17 Feb 2025 17:09:40 +0100 Subject: [PATCH 2/3] Add support for Composer --- local/mcp/app.go | 20 +++++--------- local/mcp/server.go | 61 +++++++++++++++++++++++++++---------------- local/php/composer.go | 2 +- local/php/executor.go | 2 +- 4 files changed, 47 insertions(+), 38 deletions(-) diff --git a/local/mcp/app.go b/local/mcp/app.go index 0faa1886..c485fb40 100644 --- a/local/mcp/app.go +++ b/local/mcp/app.go @@ -22,6 +22,7 @@ package mcp import ( "bytes" "encoding/json" + "strings" "github.com/pkg/errors" "github.com/symfony-cli/symfony-cli/local/php" @@ -59,27 +60,18 @@ type option struct { Default interface{} `json:"default"` } -func NewApp(projectDir string) (*Application, error) { - app, err := parseApplication(projectDir) - if err != nil { - return nil, err - } - - return app, nil -} - -func parseApplication(projectDir string) (*Application, error) { +func NewApp(projectDir string, args []string) (*Application, error) { + args = append(args, "list", "--format=json") var buf bytes.Buffer - var bufErr bytes.Buffer e := &php.Executor{ BinName: "php", Dir: projectDir, - Args: []string{"php", "bin/console", "list", "--format=json"}, + Args: args, Stdout: &buf, - Stderr: &bufErr, + Stderr: &buf, } if ret := e.Execute(false); ret != 0 { - return nil, errors.Errorf("unable to list commands: %s\n%s", bufErr.String(), buf.String()) + return nil, errors.Errorf("unable to list commands (%s):\n%s", strings.Join(args, " "), buf.String()) } // Fix PHP types diff --git a/local/mcp/server.go b/local/mcp/server.go index 7013b249..c567925e 100644 --- a/local/mcp/server.go +++ b/local/mcp/server.go @@ -33,7 +33,8 @@ import ( type MCP struct { server *server.MCPServer - app *Application + apps map[string]*Application + appArgs map[string][]string projectDir string } @@ -61,6 +62,7 @@ var excludedOptions = map[string]bool{ func NewServer(projectDir string) (*MCP, error) { mcp := &MCP{ projectDir: projectDir, + apps: map[string]*Application{}, } mcp.server = server.NewMCPServer( @@ -70,21 +72,36 @@ func NewServer(projectDir string) (*MCP, error) { server.WithResourceCapabilities(true, true), ) - var err error - mcp.app, err = NewApp(projectDir) - if err != nil { - return nil, err + mcp.appArgs = map[string][]string{ + "symfony": {"php", "bin/console"}, + // "cloud": {"run", "upsun"}, } - for _, command := range mcp.app.Commands { - if _, ok := excludedCommands[command.Name]; ok { - continue - } - if command.Hidden { - continue - } - if err := mcp.addTool(command); err != nil { + + e := &php.Executor{ + Dir: projectDir, + BinName: "php", + } + if composerPath, err := e.FindComposer(""); err == nil { + mcp.appArgs["composer"] = []string{"php", composerPath} + } + + for name, args := range mcp.appArgs { + var err error + mcp.apps[name], err = NewApp(projectDir, args) + if err != nil { return nil, err } + for _, command := range mcp.apps[name].Commands { + if _, ok := excludedCommands[command.Name]; ok { + continue + } + if command.Hidden { + continue + } + if err := mcp.addTool(name, command); err != nil { + return nil, err + } + } } return mcp, nil @@ -94,7 +111,7 @@ func (p *MCP) Start() error { return server.ServeStdio(p.server) } -func (p *MCP) addTool(cmd command) error { +func (p *MCP) addTool(appName string, cmd command) error { toolOptions := []mcp.ToolOption{} toolOptions = append(toolOptions, mcp.WithDescription(cmd.Description+"\n\n"+cmd.Help)) for name, arg := range cmd.Definition.Arguments { @@ -120,7 +137,7 @@ func (p *MCP) addTool(cmd command) error { } } - toolName := strings.ReplaceAll(cmd.Name, ":", "-") + toolName := appName + "--" + strings.ReplaceAll(cmd.Name, ":", "-") regexp := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) if !regexp.MatchString(toolName) { return fmt.Errorf("invalid command name: %s", cmd.Name) @@ -159,14 +176,14 @@ func (p *MCP) addTool(cmd command) error { } executorArgs = append(executorArgs, "--no-ansi") executorArgs = append(executorArgs, "--no-interaction") - e, err := php.SymfonyConsoleExecutor(p.projectDir, executorArgs) - if err != nil { - return nil, err - } - e.Dir = p.projectDir var buf bytes.Buffer - e.Stdout = &buf - e.Stderr = &buf + e := &php.Executor{ + BinName: "php", + Dir: p.projectDir, + Args: append(p.appArgs[appName], executorArgs...), + Stdout: &buf, + Stderr: &buf, + } if ret := e.Execute(false); ret != 0 { return mcp.NewToolResultError(fmt.Sprintf("Error running %s (exit code: %d)\n%s", strings.Join(executorArgs, " "), ret, buf.String())), nil } diff --git a/local/php/composer.go b/local/php/composer.go index c6f8b5b5..d6a03f04 100644 --- a/local/php/composer.go +++ b/local/php/composer.go @@ -73,7 +73,7 @@ func Composer(dir string, args, env []string, stdout, stderr, logger io.Writer, if composerVersion() == 2 { composerBin = "composer2" } - path, err := e.findComposer(composerBin) + path, err := e.FindComposer(composerBin) if err != nil || !isPHPScript(path) { fmt.Fprintln(logger, " WARNING: Unable to find Composer, downloading one. It is recommended to install Composer yourself at https://getcomposer.org/download/") // we don't store it under bin/ to avoid it being found by findComposer as we want to only use it as a fallback diff --git a/local/php/executor.go b/local/php/executor.go index b3123788..98077580 100644 --- a/local/php/executor.go +++ b/local/php/executor.go @@ -390,7 +390,7 @@ func cleanupStaleTemporaryDirectories(mainLogger zerolog.Logger, doneCh chan<- b } // Find composer depending on the configuration -func (e *Executor) findComposer(extraBin string) (string, error) { +func (e *Executor) FindComposer(extraBin string) (string, error) { if scriptDir, err := e.DetectScriptDir(); err == nil { for _, file := range []string{extraBin, "composer.phar", "composer"} { path := filepath.Join(scriptDir, file) From 3b1b9b542cbda25782a8beb428d3b0761fe58d49 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Mon, 17 Feb 2025 17:46:01 +0100 Subject: [PATCH 3/3] Redact high entropy strings in the command output --- local/mcp/entropy.go | 69 +++++++++++++++++++++++++++++++++++++++ local/mcp/entropy_test.go | 29 ++++++++++++++++ local/mcp/server.go | 11 ++++--- 3 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 local/mcp/entropy.go create mode 100644 local/mcp/entropy_test.go diff --git a/local/mcp/entropy.go b/local/mcp/entropy.go new file mode 100644 index 00000000..4cd2fbce --- /dev/null +++ b/local/mcp/entropy.go @@ -0,0 +1,69 @@ +package mcp + +import ( + "math" + "regexp" + "strings" + "unicode" +) + +func redactHighEntropy(str string) string { + var result strings.Builder + var word strings.Builder + for i := 0; i < len(str); i++ { + char := rune(str[i]) + if unicode.IsSpace(char) || char == '=' || char == ':' || char == ',' || char == ';' || char == '|' { + if word.Len() > 0 { + result.WriteString(doRedactHighEntropy(word.String())) + word.Reset() + } + result.WriteRune(char) + } else { + word.WriteRune(char) + } + } + if word.Len() > 0 { + result.WriteString(doRedactHighEntropy(word.String())) + } + return result.String() +} + +func doRedactHighEntropy(str string) string { + if len(str) < 8 { + return str + } + secretPatterns := []*regexp.Regexp{ + regexp.MustCompile(`^[A-Fa-f0-9]{32,}$`), // Hex + regexp.MustCompile(`^[A-Za-z0-9+/]{32,}={0,2}$`), // Base64 + regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`), // UUID (case insensitive) + } + for _, pattern := range secretPatterns { + if pattern.MatchString(str) { + return "[REDACTED]" + } + } + freqMap := make(map[rune]float64) + totalChars := float64(len(str)) + for _, char := range str { + freqMap[char]++ + } + entropy := 0.0 + for _, count := range freqMap { + prob := count / totalChars + entropy -= prob * math.Log2(prob) + } + threshold := 3.5 + if len(str) > 32 { + threshold = 3.75 + } else if len(str) > 16 { + threshold = 3.25 + } + charSetScore := float64(len(freqMap)) / float64(len(str)) + if charSetScore > 0.5 { + threshold -= 0.25 + } + if entropy > threshold { + return "[REDACTED]" + } + return str +} diff --git a/local/mcp/entropy_test.go b/local/mcp/entropy_test.go new file mode 100644 index 00000000..c1d36675 --- /dev/null +++ b/local/mcp/entropy_test.go @@ -0,0 +1,29 @@ +package mcp + +import "testing" + +func TestAnalyzeString(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"Base64 Token", "Some token eyJhbGciOiJIUzI1NiIs detected", "Some token [REDACTED] detected"}, + {"Base64 Token =", "Some_token=eyJhbGciOiJIUzI1NiIs detected", "Some_token=[REDACTED] detected"}, + {"UUID", "A UUID 550e8400-e29b-41d4-a716-446655440000", "A UUID [REDACTED]"}, + {"Random", "Random aB1$x9#mK2&pL5@vN8*qR3", "Random [REDACTED]"}, + {"AWS Secret", "aws_secret_key=AKIA4YFAKESECRETKEY123EXAMPLE", "aws_secret_key=[REDACTED]"}, + {"AWS Secret in text", "The key AKIA4YFAKESECRETKEY123EXAMPLE was exposed", "The key [REDACTED] was exposed"}, + {"Stripe Secret Key", "stripe_key=sk_live_51HCOXXAaYYbbiuYYuu990011", "stripe_key=[REDACTED]"}, + {"Stripe Key", "sk_live_h9xj4h44j3h43jh43", "[REDACTED]"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := redactHighEntropy(tt.input) + if result != tt.expected { + t.Errorf("got %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/local/mcp/server.go b/local/mcp/server.go index c567925e..b1c444a2 100644 --- a/local/mcp/server.go +++ b/local/mcp/server.go @@ -113,7 +113,8 @@ func (p *MCP) Start() error { func (p *MCP) addTool(appName string, cmd command) error { toolOptions := []mcp.ToolOption{} - toolOptions = append(toolOptions, mcp.WithDescription(cmd.Description+"\n\n"+cmd.Help)) + // We don't add cmd.Help because the LLM can get confused about the name to use for args/options (forgetting to prefix with "arg_" or "opt_") + toolOptions = append(toolOptions, mcp.WithDescription(cmd.Description)) for name, arg := range cmd.Definition.Arguments { argOptions := []mcp.PropertyOption{ mcp.Description(arg.Description), @@ -158,13 +159,13 @@ func (p *MCP) addTool(appName string, cmd command) error { if cmd.Definition.Options[strings.TrimPrefix(name, "opt_")].AcceptValue { arg, ok := value.(string) if !ok { - return mcp.NewToolResultError(fmt.Sprintf("argument value for \"%s\" must be a string", name)), nil + return mcp.NewToolResultError(fmt.Sprintf("option value for \"%s\" must be a string", name)), nil } executorArgs = append(executorArgs, fmt.Sprintf("--%s=%s", strings.TrimPrefix(name, "opt_"), arg)) } else { arg, ok := value.(bool) if !ok { - return mcp.NewToolResultError(fmt.Sprintf("argument value for \"%s\" must be a string", name)), nil + return mcp.NewToolResultError(fmt.Sprintf("option value for \"%s\" must be a boolean", name)), nil } if arg { executorArgs = append(executorArgs, fmt.Sprintf("--%s", strings.TrimPrefix(name, "opt_"))) @@ -185,9 +186,9 @@ func (p *MCP) addTool(appName string, cmd command) error { Stderr: &buf, } if ret := e.Execute(false); ret != 0 { - return mcp.NewToolResultError(fmt.Sprintf("Error running %s (exit code: %d)\n%s", strings.Join(executorArgs, " "), ret, buf.String())), nil + return mcp.NewToolResultError(fmt.Sprintf("Error running %s (exit code: %d)\n%s", strings.Join(executorArgs, " "), ret, redactHighEntropy(buf.String()))), nil } - return mcp.NewToolResultText(buf.String()), nil + return mcp.NewToolResultText(redactHighEntropy(buf.String())), nil } p.server.AddTool(tool, handler)