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..c485fb40 --- /dev/null +++ b/local/mcp/app.go @@ -0,0 +1,86 @@ +/* + * 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" + "strings" + + "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, args []string) (*Application, error) { + args = append(args, "list", "--format=json") + var buf bytes.Buffer + e := &php.Executor{ + BinName: "php", + Dir: projectDir, + Args: args, + Stdout: &buf, + Stderr: &buf, + } + if ret := e.Execute(false); ret != 0 { + return nil, errors.Errorf("unable to list commands (%s):\n%s", strings.Join(args, " "), 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/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 new file mode 100644 index 00000000..b1c444a2 --- /dev/null +++ b/local/mcp/server.go @@ -0,0 +1,197 @@ +/* + * 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 + apps map[string]*Application + appArgs map[string][]string + 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, + apps: map[string]*Application{}, + } + + mcp.server = server.NewMCPServer( + "Symfony CLI Server", + "1.0.0", + server.WithLogging(), + server.WithResourceCapabilities(true, true), + ) + + mcp.appArgs = map[string][]string{ + "symfony": {"php", "bin/console"}, + // "cloud": {"run", "upsun"}, + } + + 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 +} + +func (p *MCP) Start() error { + return server.ServeStdio(p.server) +} + +func (p *MCP) addTool(appName string, cmd command) error { + toolOptions := []mcp.ToolOption{} + // 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), + } + 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 := 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) + } + + 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("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("option value for \"%s\" must be a boolean", 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") + var buf bytes.Buffer + 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, redactHighEntropy(buf.String()))), nil + } + return mcp.NewToolResultText(redactHighEntropy(buf.String())), nil + } + + p.server.AddTool(tool, handler) + + return 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) 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)) 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