diff --git a/docs/general/ai/help.go b/docs/general/how/help.go similarity index 93% rename from docs/general/ai/help.go rename to docs/general/how/help.go index 3ae224eb0..1585057ae 100644 --- a/docs/general/ai/help.go +++ b/docs/general/how/help.go @@ -1,4 +1,4 @@ -package ai +package how var Usage = []string{"how"} diff --git a/docs/general/mcp/help.go b/docs/general/mcp/help.go new file mode 100644 index 000000000..a68bdc7a5 --- /dev/null +++ b/docs/general/mcp/help.go @@ -0,0 +1,7 @@ +package mcp + +var Usage = []string{"mcp start"} + +func GetDescription() string { + return "Start the JFrog MCP server and begin using it with your MCP client of your choice." +} diff --git a/general/ai/cli.go b/general/how/cli.go similarity index 99% rename from general/ai/cli.go rename to general/how/cli.go index 8ef6c0e71..35970e6e3 100644 --- a/general/ai/cli.go +++ b/general/how/cli.go @@ -1,4 +1,4 @@ -package ai +package how import ( "bufio" diff --git a/general/mcp/cli.go b/general/mcp/cli.go new file mode 100644 index 000000000..ba14c5a27 --- /dev/null +++ b/general/mcp/cli.go @@ -0,0 +1,196 @@ +package mcp + +import ( + "fmt" + "os" + "os/exec" + "path" + "runtime" + "strings" + + "github.com/jfrog/build-info-go/utils" + + "github.com/jfrog/jfrog-cli-core/v2/common/commands" + + "github.com/jfrog/jfrog-cli-core/v2/utils/config" + + "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" + "github.com/jfrog/jfrog-cli/utils/cliutils" + "github.com/jfrog/jfrog-client-go/utils/log" + "github.com/urfave/cli" +) + +const ( + mcpToolSetsEnvVar = "JFROG_MCP_TOOLSETS" + mcpToolAccessEnvVar = "JFROG_MCP_TOOL_ACCESS" + mcpServerBinaryName = "cli-mcp-server" + defaultServerVersion = "[RELEASE]" + cliMcpDirName = "cli-mcp" + // Empty tool sets enabled all available tools + defaultToolsets = "" + defaultToolAccess = "read" + mcpDownloadBaseURL = "https://releases.jfrog.io/artifactory/cli-mcp-server/v0" +) + +type Command struct { + serverDetails *config.ServerDetails + toolSets string + toolAccess string + serverVersion string +} + +// NewMcpCommand returns a new MCP command instance +func NewMcpCommand() *Command { + return &Command{} +} + +// SetServerDetails sets the Artifactory server details for the command +func (mcp *Command) SetServerDetails(serverDetails *config.ServerDetails) { + mcp.serverDetails = serverDetails +} + +// ServerDetails returns the Artifactory server details associated with the command +func (mcp *Command) ServerDetails() (*config.ServerDetails, error) { + return mcp.serverDetails, nil +} + +// CommandName returns the name of the command for usage reporting +func (mcp *Command) CommandName() string { + return "jf_mcp_start" +} + +// resolveMCPServerArgs resolves the MCP server arguments (toolSets, toolAccess, serverVersion) +// in the following order for each value: +// 1. CLI flag +// 2. Environment variable +// 3. Default constant +func (mcp *Command) resolveMCPServerArgs(c *cli.Context) { + // Resolve toolSets: CLI flag -> Env var -> Default + mcp.toolSets = c.String(cliutils.McpToolsets) + if mcp.toolSets == "" { + mcp.toolSets = os.Getenv(mcpToolSetsEnvVar) + } + if mcp.toolSets == "" { + mcp.toolSets = defaultToolsets + } + + // Resolve toolAccess: CLI flag -> Env var -> Default + mcp.toolAccess = c.String(cliutils.McpToolAccess) + if mcp.toolAccess == "" { + mcp.toolAccess = os.Getenv(mcpToolAccessEnvVar) + } + if mcp.toolAccess == "" { + mcp.toolAccess = defaultToolAccess + } + + // Resolve serverVersion: CLI flag -> Default + mcp.serverVersion = c.String(cliutils.McpServerVersion) + if mcp.serverVersion == "" { + mcp.serverVersion = defaultServerVersion + } +} + +// Run executes the MCP command, downloading the server binary if needed and starting it +func (mcp *Command) Run() error { + executablePath, err := downloadServerExecutable(mcp.serverVersion) + if err != nil { + return err + } + // Create command to execute the MCP server + cmd := createMcpServerCommand(executablePath, mcp.toolSets, mcp.toolAccess) + + log.Info(fmt.Sprintf("Starting MCP server | toolset: %s | tools access: %s", mcp.toolSets, mcp.toolAccess)) + // Execute the command + return cmd.Run() +} + +// createMcpServerCommand creates the exec.Command for the MCP server +func createMcpServerCommand(executablePath, toolSets, toolAccess string) *exec.Cmd { + cmd := exec.Command( + executablePath, + fmt.Sprintf("--%s=%s", cliutils.McpToolsets, toolSets), + fmt.Sprintf("--%s=%s", cliutils.McpToolAccess, toolAccess), + ) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + return cmd +} + +// Cmd handles the CLI command execution and argument parsing +func Cmd(c *cli.Context) error { + // Show help if needed + if show, err := cliutils.ShowCmdHelpIfNeeded(c, c.Args()); show || err != nil { + return err + } + cmd := createAndConfigureCommand(c) + return commands.Exec(cmd) + +} + +// getMcpServerVersion runs the MCP server binary with --version flag to get its version +func getMcpServerVersion(binaryPath string) (string, error) { + cmd := exec.Command(binaryPath, "--version") + output, err := cmd.Output() + if err != nil { + return "", err + } + // Trim whitespace and return the output + return strings.TrimSpace(string(output)), nil +} + +// createAndConfigureCommand creates and configures the MCP command +func createAndConfigureCommand(c *cli.Context) *Command { + serverDetails, err := cliutils.CreateArtifactoryDetailsByFlags(c) + if err != nil { + log.Error("Failed to create Artifactory details:", err) + return nil + } + + cmd := NewMcpCommand() + cmd.SetServerDetails(serverDetails) + cmd.resolveMCPServerArgs(c) + + return cmd +} + +// downloadServerExecutable downloads the MCP server binary if it doesn't exist locally +func downloadServerExecutable(version string) (string, error) { + osName, arch, binaryName := getOsArchBinaryInfo() + targetPath, err := getLocalBinaryPath(binaryName) + if err != nil { + return "", err + } + urlStr := fmt.Sprintf("%s/%s/%s-%s/%s", mcpDownloadBaseURL, version, osName, arch, mcpServerBinaryName) + log.Info("Downloading MCP server from:", urlStr) + return targetPath, utils.DownloadFile(targetPath, urlStr) + +} + +// getLocalBinaryPath determines the path to the binary and checks if it exists +func getLocalBinaryPath(binaryName string) (fullPath string, err error) { + jfrogHomeDir, err := coreutils.GetJfrogHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get JFrog home directory: %w", err) + } + + targetDir := path.Join(jfrogHomeDir, cliMcpDirName) + if err = os.MkdirAll(targetDir, 0777); err != nil { + return "", fmt.Errorf("failed to create directory '%s': %w", targetDir, err) + } + + fullPath = path.Join(targetDir, binaryName) + return fullPath, nil +} + +// getOsArchBinaryInfo returns the current OS, architecture, and appropriate binary name +func getOsArchBinaryInfo() (osName, arch, binaryName string) { + osName = runtime.GOOS + arch = runtime.GOARCH + binaryName = mcpServerBinaryName + if osName == "windows" { + binaryName += ".exe" + } + return +} diff --git a/general/mcp/cli_test.go b/general/mcp/cli_test.go new file mode 100644 index 000000000..d59e42fb5 --- /dev/null +++ b/general/mcp/cli_test.go @@ -0,0 +1,209 @@ +package mcp + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/jfrog/jfrog-cli/utils/tests" + "github.com/stretchr/testify/assert" +) + +func TestGetMCPServerArgs(t *testing.T) { + testRuns := []struct { + name string + flags []string + envVars map[string]string + expectedToolSets string + expectedToolAccess string + expectedVersion string + }{ + { + name: "FlagsOnly", + flags: []string{"toolsets=test", "tools-access=read", "mcp-server-version=1.0.0"}, + envVars: map[string]string{}, + expectedToolSets: "test", + expectedToolAccess: "read", + expectedVersion: "1.0.0", + }, + { + name: "EnvVarsOnly", + flags: []string{}, + envVars: map[string]string{mcpToolSetsEnvVar: "test-env", mcpToolAccessEnvVar: "read-env"}, + expectedToolSets: "test-env", + expectedToolAccess: "read-env", + expectedVersion: defaultServerVersion, + }, + { + name: "FlagsOverrideEnvVars", + flags: []string{"toolsets=test-flag", "tools-access=read-flag"}, + envVars: map[string]string{mcpToolSetsEnvVar: "test-env", mcpToolAccessEnvVar: "read-env"}, + expectedToolSets: "test-flag", + expectedToolAccess: "read-flag", + expectedVersion: defaultServerVersion, + }, + { + name: "NoFlagsOrEnvVars", + flags: []string{}, + envVars: map[string]string{}, + expectedToolSets: "", + expectedToolAccess: "", + expectedVersion: defaultServerVersion, + }, + } + + for _, test := range testRuns { + t.Run(test.name, func(t *testing.T) { + // Save current environment and restore it after the test + originalEnv := make(map[string]string) + for key := range test.envVars { + originalEnv[key] = os.Getenv(key) + } + defer func() { + for key, value := range originalEnv { + if err := os.Setenv(key, value); err != nil { + t.Logf("Failed to restore environment variable %s: %v", key, err) + } + } + }() + + // Set environment variables for the test + for key, value := range test.envVars { + if err := os.Setenv(key, value); err != nil { + t.Fatalf("Failed to set environment variable %s: %v", key, err) + } + } + + // Create CLI context + context, _ := tests.CreateContext(t, test.flags, []string{}) + + // Test getMCPServerArgs + cmd := NewMcpCommand() + cmd.resolveMCPServerArgs(context) + + // Assert results + assert.Equal(t, test.expectedToolSets, cmd.toolSets) + assert.Equal(t, test.expectedToolAccess, cmd.toolAccess) + assert.Equal(t, test.expectedVersion, cmd.serverVersion) + }) + } +} + +func TestGetOsArchBinaryInfo(t *testing.T) { + osName, arch, binaryName := getOsArchBinaryInfo() + + // Verify OS and architecture are not empty + assert.NotEmpty(t, osName) + assert.NotEmpty(t, arch) + + // Verify binary name has correct format + if osName == "windows" { + assert.Equal(t, mcpServerBinaryName+".exe", binaryName) + } else { + assert.Equal(t, mcpServerBinaryName, binaryName) + } +} + +func TestCmd(t *testing.T) { + testCases := []struct { + name string + args []string + expectError bool + errorMsg string + }{ + { + name: "NoArgs", + args: []string{}, + expectError: true, + errorMsg: "Unknown subcommand: ", + }, + { + name: "InvalidSubcommand", + args: []string{"invalid"}, + expectError: true, + errorMsg: "Unknown subcommand: invalid", + }, + // Update and start subcommands require more complex mocking to test properly + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + context, _ := tests.CreateContext(t, []string{}, tc.args) + err := Cmd(context) + + if tc.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.errorMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestGetLocalBinaryPath(t *testing.T) { + // Skip test since we can't mock coreutils.GetJfrogHomeDir directly + t.Skip("Skipping test as it requires mocking package-level functions") +} + +func TestGetMcpServerVersion(t *testing.T) { + // Create a temporary directory for testing + tempDir, err := os.MkdirTemp("", "mcp-version-test") + assert.NoError(t, err) + defer os.RemoveAll(tempDir) + + // For Windows, we need a different approach as batch scripts don't work the same way + if runtime.GOOS == "windows" { + // On Windows, we'll create a small Go program and compile it + tempSrcDir := filepath.Join(tempDir, "src") + err = os.Mkdir(tempSrcDir, 0755) + assert.NoError(t, err) + + // Create a simple Go program that prints the version + srcFile := filepath.Join(tempSrcDir, "main.go") + srcContent := `package main + +import "fmt" + +func main() { + fmt.Println("1.2.3") +} +` + err = os.WriteFile(srcFile, []byte(srcContent), 0644) + assert.NoError(t, err) + + // Compile the program + binaryPath := filepath.Join(tempDir, "fake-binary.exe") + cmd := exec.Command("go", "build", "-o", binaryPath, srcFile) + err = cmd.Run() + + // If compilation fails, skip the test + if err != nil { + t.Skip("Skipping test, unable to compile test binary:", err) + return + } + + // Test with the compiled binary + version, err := getMcpServerVersion(binaryPath) + assert.NoError(t, err) + assert.Equal(t, "1.2.3", version) + } else { + // On Unix systems, we can use a shell script + binaryPath := filepath.Join(tempDir, "fake-binary") + scriptContent := "#!/bin/sh\necho \"1.2.3\"\n" + + err = os.WriteFile(binaryPath, []byte(scriptContent), 0755) + assert.NoError(t, err) + + // Test the getMcpServerVersion function + version, err := getMcpServerVersion(binaryPath) + assert.NoError(t, err) + assert.Equal(t, "1.2.3", version) + } + + // Test with non-existent binary + _, err = getMcpServerVersion(filepath.Join(tempDir, "non-existent")) + assert.Error(t, err) +} diff --git a/main.go b/main.go index 3aec5037a..cf974a244 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,11 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "os" + "runtime" + "sort" + "strings" + "github.com/agnivade/levenshtein" artifactoryCLI "github.com/jfrog/jfrog-cli-artifactory/cli" corecommon "github.com/jfrog/jfrog-cli-core/v2/docs/common" @@ -18,13 +23,15 @@ import ( "github.com/jfrog/jfrog-cli/completion" "github.com/jfrog/jfrog-cli/config" "github.com/jfrog/jfrog-cli/docs/common" - aiDocs "github.com/jfrog/jfrog-cli/docs/general/ai" + aiHowDocs "github.com/jfrog/jfrog-cli/docs/general/how" loginDocs "github.com/jfrog/jfrog-cli/docs/general/login" + mcpDocs "github.com/jfrog/jfrog-cli/docs/general/mcp" oidcDocs "github.com/jfrog/jfrog-cli/docs/general/oidc" summaryDocs "github.com/jfrog/jfrog-cli/docs/general/summary" tokenDocs "github.com/jfrog/jfrog-cli/docs/general/token" - "github.com/jfrog/jfrog-cli/general/ai" + "github.com/jfrog/jfrog-cli/general/how" "github.com/jfrog/jfrog-cli/general/login" + "github.com/jfrog/jfrog-cli/general/mcp" "github.com/jfrog/jfrog-cli/general/summary" "github.com/jfrog/jfrog-cli/general/token" "github.com/jfrog/jfrog-cli/missioncontrol" @@ -39,10 +46,6 @@ import ( clientlog "github.com/jfrog/jfrog-client-go/utils/log" "github.com/urfave/cli" "golang.org/x/exp/slices" - "os" - "runtime" - "sort" - "strings" ) const commandHelpTemplate string = `{{.HelpName}}{{if .UsageText}} @@ -268,10 +271,19 @@ func getCommands() ([]cli.Command, error) { }, { Name: "how", - Usage: aiDocs.GetDescription(), - HelpName: corecommon.CreateUsage("how", aiDocs.GetDescription(), aiDocs.Usage), + Usage: aiHowDocs.GetDescription(), + HelpName: corecommon.CreateUsage("how", aiHowDocs.GetDescription(), aiHowDocs.Usage), + BashComplete: corecommon.CreateBashCompletionFunc(), + Action: how.HowCmd, + }, + { + Name: "mcp", + Usage: mcpDocs.GetDescription(), + HelpName: corecommon.CreateUsage("mcp", mcpDocs.GetDescription(), mcpDocs.Usage), BashComplete: corecommon.CreateBashCompletionFunc(), - Action: ai.HowCmd, + ArgsUsage: common.CreateEnvVars(), + Flags: cliutils.GetCommandFlags(cliutils.Mcp), + Action: mcp.Cmd, }, { Name: "access-token-create", diff --git a/utils/cliutils/commandsflags.go b/utils/cliutils/commandsflags.go index 15b786258..433fcc7e2 100644 --- a/utils/cliutils/commandsflags.go +++ b/utils/cliutils/commandsflags.go @@ -2,10 +2,11 @@ package cliutils import ( "fmt" - "github.com/jfrog/jfrog-cli-artifactory/cliutils/flagkit" "sort" "strconv" + "github.com/jfrog/jfrog-cli-artifactory/cliutils/flagkit" + commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" @@ -118,6 +119,14 @@ const ( AccessTokenCreate = "access-token-create" ExchangeOidcToken = "exchange-oidc-token" + // MCP command key + Mcp = "mcp" + + // MCP flags + McpToolsets = "toolsets" + McpToolAccess = "tools-access" + McpServerVersion = "mcp-server-version" + // *** Artifactory Commands' flags *** // Base flags url = "url" @@ -1246,6 +1255,18 @@ var flagsMap = map[string]cli.Flag{ Name: TargetWorkingDir, Usage: "[Default: '/storage'] Local working directory on the target Artifactory server.` `", }, + McpToolsets: cli.StringFlag{ + Name: McpToolsets, + Usage: "Comma-separated list of toolsets (can also be set via JFROG_MCP_TOOLSETS env var)", + }, + McpToolAccess: cli.StringFlag{ + Name: McpToolAccess, + Usage: "Semicolon-separated list of tool access rights (can also be set via JFROG_MCP_TOOLS_ACCESS env var)", + }, + McpServerVersion: cli.StringFlag{ + Name: McpServerVersion, + Usage: "Specify the MCP server version to download. Leave empty to use the latest version.", + }, // Distribution's commands Flags distUrl: cli.StringFlag{ @@ -1712,6 +1733,7 @@ var flagsMap = map[string]cli.Flag{ } var commandFlags = map[string][]string{ + // Common commands flags AddConfig: { interactive, EncPassword, configPlatformUrl, configRtUrl, configDistUrl, configXrUrl, configMcUrl, configPlUrl, configUser, configPassword, configAccessToken, sshKeyPath, sshPassphrase, ClientCertPath, ClientCertKeyPath, BasicAuthOnly, configInsecureTls, Overwrite, passwordStdin, accessTokenStdin, OidcTokenID, OidcProviderName, OidcAudience, OidcProviderType, ApplicationKey, @@ -2038,6 +2060,9 @@ var commandFlags = map[string][]string{ Setup: { serverId, url, user, password, accessToken, sshPassphrase, sshKeyPath, ClientCertPath, ClientCertKeyPath, Project, setupRepo, }, + Mcp: { + McpToolsets, McpToolAccess, McpServerVersion, + }, } func GetCommandFlags(cmd string) []cli.Flag {