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) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy