From 188dc8d914480eae91ec55e361eef9547e1eeeb2 Mon Sep 17 00:00:00 2001 From: ehl-jf Date: Wed, 3 Jun 2026 15:41:54 +0200 Subject: [PATCH] JGC-468 - Add jf mcp command to configure the remote JFrog MCP server Introduce a new `jf mcp` command namespace that surfaces and configures the remote, platform-hosted MCP (Model Context Protocol) server: - `jf mcp show` prints the MCP endpoint (/mcp) as text or JSON; overridable via --mcp-url or JFROG_CLI_MCP_URL. - `jf mcp install` configures an AI agent (cursor or claude) to connect to the server. It first verifies the endpoint is reachable, writes the agent-specific entry (cursor omits "type"; claude uses "type": "http"), and prompts the user to complete OAuth in the agent. No credentials are stored. - `jf mcp uninstall` removes the JFrog entry, leaving other servers untouched. Supports --global/--project-dir scoping, --name, --dry-run and --skip-check. --- docs/mcp/install/help.go | 28 +++ docs/mcp/show/help.go | 23 +++ docs/mcp/uninstall/help.go | 20 ++ general/api/cli.go | 46 +---- general/api/cli_test.go | 52 +----- main.go | 9 +- mcp/cli.go | 164 +++++++++++++++++ mcp/mcp.go | 313 ++++++++++++++++++++++++++++++++ mcp/mcp_test.go | 242 ++++++++++++++++++++++++ utils/cliutils/cli_consts.go | 4 + utils/cliutils/commandsflags.go | 98 ++++++++-- utils/usage/usage.go | 52 ++++++ utils/usage/usage_test.go | 77 ++++++++ 13 files changed, 1021 insertions(+), 107 deletions(-) create mode 100644 docs/mcp/install/help.go create mode 100644 docs/mcp/show/help.go create mode 100644 docs/mcp/uninstall/help.go create mode 100644 mcp/cli.go create mode 100644 mcp/mcp.go create mode 100644 mcp/mcp_test.go create mode 100644 utils/usage/usage.go create mode 100644 utils/usage/usage_test.go diff --git a/docs/mcp/install/help.go b/docs/mcp/install/help.go new file mode 100644 index 000000000..0254bd3d5 --- /dev/null +++ b/docs/mcp/install/help.go @@ -0,0 +1,28 @@ +package install + +var Usage = []string{"mcp install --agent "} + +func GetDescription() string { + return "Configure an AI agent (cursor or claude) to connect to the remote JFrog MCP server." +} + +func GetArguments() string { + return ` EXAMPLES + # Configure Cursor for the current project + $ jf mcp install --agent cursor + + # Configure Claude Code globally (user-level) + $ jf mcp install --agent claude --global + + # Preview the configuration without writing it + $ jf mcp install --agent cursor --dry-run + +NOTES + Before writing any configuration, the command verifies that the MCP server is + reachable. Use --skip-check to bypass this verification. + + Authentication is completed via OAuth in the agent itself; no credentials are + written to the agent configuration. After installation, complete the OAuth + authorization as required by the agent (for example, run /mcp in Claude Code, + or approve the server in Cursor) to activate the connection.` +} diff --git a/docs/mcp/show/help.go b/docs/mcp/show/help.go new file mode 100644 index 000000000..db1df5389 --- /dev/null +++ b/docs/mcp/show/help.go @@ -0,0 +1,23 @@ +package show + +var Usage = []string{"mcp show"} + +func GetDescription() string { + return "Print the remote MCP (Model Context Protocol) server endpoint for the configured JFrog Platform." +} + +func GetArguments() string { + return ` EXAMPLES + # Print the MCP endpoint for the default server + $ jf mcp show + + # Print the MCP endpoint for a specific server, as JSON + $ jf mcp show --server-id my-platform --format json + +NOTES + The endpoint is derived as /mcp. It can be overridden with the + --mcp-url flag or the JFROG_CLI_MCP_URL environment variable. + + This command surfaces the remote, platform-hosted MCP server. It is unrelated + to 'jf source-mcp', which runs a local MCP server for source-code analysis.` +} diff --git a/docs/mcp/uninstall/help.go b/docs/mcp/uninstall/help.go new file mode 100644 index 000000000..783830a41 --- /dev/null +++ b/docs/mcp/uninstall/help.go @@ -0,0 +1,20 @@ +package uninstall + +var Usage = []string{"mcp uninstall --agent "} + +func GetDescription() string { + return "Remove the JFrog MCP server entry previously added to an AI agent's configuration." +} + +func GetArguments() string { + return ` EXAMPLES + # Remove the JFrog MCP server from Cursor (current project) + $ jf mcp uninstall --agent cursor + + # Remove it from Claude Code's global configuration + $ jf mcp uninstall --agent claude --global + +NOTES + Only the JFrog MCP server entry is removed; other configured MCP servers are + left untouched. Use --name to target an entry written under a non-default name.` +} diff --git a/general/api/cli.go b/general/api/cli.go index a4f6c5735..698051300 100644 --- a/general/api/cli.go +++ b/general/api/cli.go @@ -9,12 +9,12 @@ import ( commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" coreconfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" - "github.com/jfrog/jfrog-cli-core/v2/common/commands" "github.com/jfrog/jfrog-cli/utils/cliutils" + "github.com/jfrog/jfrog-cli/utils/usage" "github.com/jfrog/jfrog-client-go/http/httpclient" "github.com/jfrog/jfrog-client-go/utils/errorutils" - "github.com/jfrog/jfrog-client-go/utils/log" "github.com/jfrog/jfrog-client-go/utils/io/httputils" + "github.com/jfrog/jfrog-client-go/utils/log" "github.com/urfave/cli" ) @@ -63,11 +63,9 @@ func runApiCmd(c commandContext, serverDetails *coreconfig.ServerDetails, stdOut return errorutils.CheckErrorf("no JFrog Platform URL specified, either via the --url flag or as part of the server configuration") } - commands.CollectMetrics("jf api", flagsUsed) - timeout := time.Duration(c.Int(flagTimeout)) * time.Second - waitUsageReport := startUsageReport(serverDetails) - defer waitForUsageReport(waitUsageReport, timeout) + waitUsageReport := usage.StartReport("jf api", flagsUsed, serverDetails) + defer usage.WaitForReport("jf api", waitUsageReport, timeout) pathArg := c.Args().First() fullURL, err := joinPlatformAPIURL(serverDetails.GetUrl(), pathArg) @@ -94,42 +92,6 @@ func runApiCmd(c commandContext, serverDetails *coreconfig.ServerDetails, stdOut return exchangeAndPrint(client, c, method, fullURL, body, details, stdOut) } -// reportUsageFn is the usage reporter invoked by startUsageReport. Overridable -// in tests to inject fakes (panic, slow, fast) without touching HTTP. -var reportUsageFn = commands.ReportUsage - -// startUsageReport launches the usage report in a goroutine, recovering from -// any panic so that a misbehaving reporter cannot crash the process. The -// returned channel is signaled by ReportUsage on success, or closed by the -// recover path on panic — either way the caller's receive unblocks. -func startUsageReport(serverDetails *coreconfig.ServerDetails) chan bool { - waitUsageReport := make(chan bool) - go func() { - defer func() { - if r := recover(); r != nil { - log.Debug("jf api: usage report panicked:", r) - close(waitUsageReport) - } - }() - reportUsageFn("jf api", serverDetails, waitUsageReport) - }() - return waitUsageReport -} - -// waitForUsageReport blocks until the usage report completes or the timeout -// elapses. A non-positive timeout waits indefinitely. -func waitForUsageReport(wait <-chan bool, timeout time.Duration) { - if timeout <= 0 { - <-wait - return - } - select { - case <-wait: - case <-time.After(timeout): - log.Debug("jf api: usage report did not complete within", timeout) - } -} - func httpMethodOrDefault(c commandContext) string { method := strings.ToUpper(strings.TrimSpace(c.String(flagMethod))) if method == "" { diff --git a/general/api/cli_test.go b/general/api/cli_test.go index 83df3e5de..02a4c59d9 100644 --- a/general/api/cli_test.go +++ b/general/api/cli_test.go @@ -13,6 +13,7 @@ import ( "github.com/jfrog/jfrog-cli-core/v2/common/commands" coreConfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" testhelpers "github.com/jfrog/jfrog-cli/utils/tests" + "github.com/jfrog/jfrog-cli/utils/usage" "github.com/jfrog/jfrog-client-go/utils/errorutils" clientlog "github.com/jfrog/jfrog-client-go/utils/log" "github.com/stretchr/testify/assert" @@ -651,13 +652,13 @@ func (mc *mockContext) setInt(name string, value int) { mc.setMap[name] = true } -// swapReportUsageFn replaces reportUsageFn for the duration of the test and -// restores it on cleanup. Tests using this must not run in parallel. +// swapReportUsageFn replaces the shared usage.ReportUsageFn for the duration of +// the test and restores it on cleanup. Tests using this must not run in parallel. func swapReportUsageFn(t *testing.T, fn func(string, *coreConfig.ServerDetails, chan<- bool)) { t.Helper() - prev := reportUsageFn - reportUsageFn = fn - t.Cleanup(func() { reportUsageFn = prev }) + prev := usage.ReportUsageFn + usage.ReportUsageFn = fn + t.Cleanup(func() { usage.ReportUsageFn = prev }) } // newTestServer starts an httptest server that returns 200/OK and serverDetails @@ -692,33 +693,6 @@ func TestRunApiCmd_CollectsMetrics(t *testing.T) { assert.Equal(t, flags, got.Flags) } -func TestWaitForUsageReport(t *testing.T) { - t.Run("non-positive timeout waits for signal", func(t *testing.T) { - ch := make(chan bool, 1) - ch <- true - start := time.Now() - waitForUsageReport(ch, 0) - assert.Less(t, time.Since(start), 50*time.Millisecond) - }) - - t.Run("returns immediately when signaled before timeout", func(t *testing.T) { - ch := make(chan bool, 1) - ch <- true - start := time.Now() - waitForUsageReport(ch, time.Second) - assert.Less(t, time.Since(start), 50*time.Millisecond) - }) - - t.Run("returns at timeout when never signaled", func(t *testing.T) { - ch := make(chan bool) - start := time.Now() - waitForUsageReport(ch, 100*time.Millisecond) - elapsed := time.Since(start) - assert.GreaterOrEqual(t, elapsed, 100*time.Millisecond) - assert.Less(t, elapsed, 500*time.Millisecond) - }) -} - func TestRunApiCmd_UsageReportDisabled(t *testing.T) { t.Setenv("JFROG_CLI_REPORT_USAGE", "false") @@ -732,20 +706,6 @@ func TestRunApiCmd_UsageReportDisabled(t *testing.T) { assert.Less(t, time.Since(start), 2*time.Second) } -func TestStartUsageReport_PanicIsRecovered(t *testing.T) { - swapReportUsageFn(t, func(_ string, _ *coreConfig.ServerDetails, _ chan<- bool) { - panic("boom") - }) - - ch := startUsageReport(&coreConfig.ServerDetails{}) - select { - case <-ch: - // Channel was closed by the recover path; receive returns the zero value. - case <-time.After(time.Second): - t.Fatal("startUsageReport did not unblock after panic") - } -} - func TestRunApiCmd_UsageReportTimeout(t *testing.T) { swapReportUsageFn(t, func(_ string, _ *coreConfig.ServerDetails, ch chan<- bool) { time.Sleep(3 * time.Second) diff --git a/main.go b/main.go index 198bdf4e9..1c821eb27 100644 --- a/main.go +++ b/main.go @@ -15,8 +15,8 @@ import ( "github.com/agnivade/levenshtein" appTrustCLI "github.com/jfrog/jfrog-cli-application/cli" artifactoryCLI "github.com/jfrog/jfrog-cli-artifactory/cli" - corecommon "github.com/jfrog/jfrog-cli-core/v2/docs/common" corecommands "github.com/jfrog/jfrog-cli-core/v2/common/commands" + corecommon "github.com/jfrog/jfrog-cli-core/v2/docs/common" "github.com/jfrog/jfrog-cli-core/v2/plugins/components" coreconfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" @@ -38,6 +38,7 @@ import ( "github.com/jfrog/jfrog-cli/general/login" "github.com/jfrog/jfrog-cli/general/summary" "github.com/jfrog/jfrog-cli/general/token" + "github.com/jfrog/jfrog-cli/mcp" "github.com/jfrog/jfrog-cli/missioncontrol" "github.com/jfrog/jfrog-cli/packagealias" "github.com/jfrog/jfrog-cli/pipelines" @@ -293,6 +294,12 @@ func getCommands() ([]cli.Command, error) { Subcommands: config.GetCommands(), Category: commandNamespacesCategory, }, + { + Name: cliutils.CmdMcp, + Usage: "MCP (Model Context Protocol) server commands", + Subcommands: mcp.GetCommands(), + Category: commandNamespacesCategory, + }, { Name: "package-alias", Usage: "Transparent package manager interception (Ghost Frog).", diff --git a/mcp/cli.go b/mcp/cli.go new file mode 100644 index 000000000..bcdb174d8 --- /dev/null +++ b/mcp/cli.go @@ -0,0 +1,164 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + commonCliUtils "github.com/jfrog/jfrog-cli-core/v2/common/cliutils" + corecommon "github.com/jfrog/jfrog-cli-core/v2/docs/common" + coreconfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli/docs/common" + installDocs "github.com/jfrog/jfrog-cli/docs/mcp/install" + showDocs "github.com/jfrog/jfrog-cli/docs/mcp/show" + uninstallDocs "github.com/jfrog/jfrog-cli/docs/mcp/uninstall" + "github.com/jfrog/jfrog-cli/utils/cliutils" + "github.com/jfrog/jfrog-cli/utils/usage" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/urfave/cli" +) + +func GetCommands() []cli.Command { + return cliutils.GetSortedCommands(cli.CommandsByName{ + { + Name: "show", + Flags: cliutils.GetCommandFlags(cliutils.McpShow), + Usage: showDocs.GetDescription(), + HelpName: corecommon.CreateUsage("mcp show", showDocs.GetDescription(), showDocs.Usage), + UsageText: showDocs.GetArguments(), + ArgsUsage: common.CreateEnvVars(), + BashComplete: corecommon.CreateBashCompletionFunc(), + Action: showCmd, + }, + { + Name: "install", + Flags: cliutils.GetCommandFlags(cliutils.McpInstall), + Usage: installDocs.GetDescription(), + HelpName: corecommon.CreateUsage("mcp install", installDocs.GetDescription(), installDocs.Usage), + UsageText: installDocs.GetArguments(), + ArgsUsage: common.CreateEnvVars(), + BashComplete: corecommon.CreateBashCompletionFunc("cursor", "claude"), + Action: installCmd, + }, + { + Name: "uninstall", + Flags: cliutils.GetCommandFlags(cliutils.McpUninstall), + Usage: uninstallDocs.GetDescription(), + HelpName: corecommon.CreateUsage("mcp uninstall", uninstallDocs.GetDescription(), uninstallDocs.Usage), + UsageText: uninstallDocs.GetArguments(), + ArgsUsage: common.CreateEnvVars(), + BashComplete: corecommon.CreateBashCompletionFunc("cursor", "claude"), + Action: uninstallCmd, + }, + }) +} + +func collectFlags(c *cli.Context) []string { + var flagsUsed []string + for _, f := range c.Command.Flags { + name := f.GetName() + if c.IsSet(name) { + flagsUsed = append(flagsUsed, name) + } + } + return flagsUsed +} + +func showCmd(c *cli.Context) error { + if c.NArg() != 0 { + return cliutils.WrongNumberOfArgumentsHandler(c) + } + serverDetails, err := cliutils.CreateServerDetailsWithConfigOffer(c, true, commonCliUtils.Platform) + if err != nil { + return err + } + const cmdName = "jf mcp show" + wait := usage.StartReport(cmdName, collectFlags(c), serverDetails) + defer usage.WaitForReport(cmdName, wait, usage.DefaultReportTimeout) + + return runShow(c, serverDetails, os.Stdout) +} + +func runShow(c *cli.Context, serverDetails *coreconfig.ServerDetails, out io.Writer) error { + mcpURL, err := ResolveMcpURL(c.String("mcp-url"), serverDetails) + if err != nil { + return err + } + info := EndpointInfo{ + ServerId: serverDetails.ServerId, + PlatformUrl: serverDetails.GetUrl(), + McpUrl: mcpURL, + Transport: "http", + } + if strings.EqualFold(c.String("format"), "json") { + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return errorutils.CheckError(err) + } + return fprintf(out, "%s\n", string(data)) + } + msg := fmt.Sprintf("MCP endpoint: %s\n", info.McpUrl) + if info.ServerId != "" { + msg += fmt.Sprintf("Server ID: %s\n", info.ServerId) + } + msg += "Auth: OAuth (completed in the agent / client platform)\n" + return fprintf(out, "%s", msg) +} + +func installCmd(c *cli.Context) error { + if c.NArg() != 0 { + return cliutils.WrongNumberOfArgumentsHandler(c) + } + serverDetails, err := cliutils.CreateServerDetailsWithConfigOffer(c, true, commonCliUtils.Platform) + if err != nil { + return err + } + const cmdName = "jf mcp install" + wait := usage.StartReport(cmdName, collectFlags(c), serverDetails) + defer usage.WaitForReport(cmdName, wait, usage.DefaultReportTimeout) + + mcpURL, err := ResolveMcpURL(c.String("mcp-url"), serverDetails) + if err != nil { + return err + } + return Install(InstallParams{ + Agent: c.String("agent"), + ServerName: serverNameOrDefault(c), + McpURL: mcpURL, + ProjectDir: c.String("project-dir"), + Global: c.Bool("global"), + DryRun: c.Bool("dry-run"), + ServerDetails: serverDetails, + SkipCheck: c.Bool("skip-check"), + }, os.Stdout) +} + +func uninstallCmd(c *cli.Context) error { + if c.NArg() != 0 { + return cliutils.WrongNumberOfArgumentsHandler(c) + } + serverDetails, err := cliutils.CreateServerDetailsWithConfigOffer(c, true, commonCliUtils.Platform) + if err != nil { + return err + } + const cmdName = "jf mcp uninstall" + wait := usage.StartReport(cmdName, collectFlags(c), serverDetails) + defer usage.WaitForReport(cmdName, wait, usage.DefaultReportTimeout) + + return Uninstall(UninstallParams{ + Agent: c.String("agent"), + ServerName: serverNameOrDefault(c), + ProjectDir: c.String("project-dir"), + Global: c.Bool("global"), + DryRun: c.Bool("dry-run"), + }, os.Stdout) +} + +func serverNameOrDefault(c *cli.Context) string { + if name := strings.TrimSpace(c.String("name")); name != "" { + return name + } + return DefaultServerName +} diff --git a/mcp/mcp.go b/mcp/mcp.go new file mode 100644 index 000000000..20f673fdd --- /dev/null +++ b/mcp/mcp.go @@ -0,0 +1,313 @@ +package mcp + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + coreconfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli/utils/cliutils" + "github.com/jfrog/jfrog-client-go/http/httpclient" + "github.com/jfrog/jfrog-client-go/utils/errorutils" + "github.com/jfrog/jfrog-client-go/utils/io/httputils" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// mcpServersKey is the top-level key used by both Cursor and Claude to hold MCP +// server definitions. +const mcpServersKey = "mcpServers" + +// DefaultServerName is the entry name written to the agent configuration. +const DefaultServerName = "jfrog" + +// agentSpec describes where and how to write the MCP server entry for a given +// AI agent. The config schema is agent-specific: Cursor omits the "type" field +// for remote servers, while Claude requires "type": "http". +type agentSpec struct { + // projectFile is the config file path relative to the project directory. + projectFile string + // globalFile is the user-level config file path (may start with "~"). + globalFile string + // includeType controls whether the entry includes "type": "http" (Claude). + includeType bool +} + +// supportedAgents maps the user-facing agent name to its configuration spec. +// Scope is intentionally limited to cursor and claude. +var supportedAgents = map[string]agentSpec{ + "cursor": {projectFile: filepath.Join(".cursor", "mcp.json"), globalFile: filepath.Join("~", ".cursor", "mcp.json"), includeType: false}, + "claude": {projectFile: ".mcp.json", globalFile: filepath.Join("~", ".claude.json"), includeType: true}, +} + +// SupportedAgentNames returns the supported agent names, sorted, for help and +// error messages. +func SupportedAgentNames() string { + names := make([]string, 0, len(supportedAgents)) + for name := range supportedAgents { + names = append(names, name) + } + sort.Strings(names) + return strings.Join(names, ", ") +} + +func resolveAgent(name string) (agentSpec, string, error) { + normalized := strings.ToLower(strings.TrimSpace(name)) + if normalized == "" { + return agentSpec{}, "", errorutils.CheckErrorf("the --agent flag is required. Supported agents: %s", SupportedAgentNames()) + } + spec, ok := supportedAgents[normalized] + if !ok { + return agentSpec{}, "", errorutils.CheckErrorf("unsupported agent %q. Supported agents: %s", name, SupportedAgentNames()) + } + return spec, normalized, nil +} + +// configFilePath resolves the absolute config file path for the agent at the +// requested scope. +func (a agentSpec) configFilePath(projectDir string, global bool) (string, error) { + if global { + return expandHome(a.globalFile) + } + if projectDir == "" { + projectDir = "." + } + return filepath.Abs(filepath.Join(projectDir, a.projectFile)) +} + +func expandHome(path string) (string, error) { + if path == "~" || strings.HasPrefix(path, "~"+string(os.PathSeparator)) { + home, err := os.UserHomeDir() + if err != nil { + return "", errorutils.CheckError(err) + } + path = filepath.Join(home, strings.TrimPrefix(path, "~")) + } + return filepath.Abs(path) +} + +// ResolveMcpURL determines the remote MCP endpoint. Precedence: explicit override +// (flag value, passed in as urlOverride) > JFROG_CLI_MCP_URL env var > derived +// from the configured platform URL as /mcp. +func ResolveMcpURL(urlOverride string, serverDetails *coreconfig.ServerDetails) (string, error) { + if trimmed := strings.TrimSpace(urlOverride); trimmed != "" { + return strings.TrimRight(trimmed, "/"), nil + } + if env := strings.TrimSpace(os.Getenv(cliutils.JfrogCliMcpUrl)); env != "" { + return strings.TrimRight(env, "/"), nil + } + base := strings.TrimSpace(serverDetails.GetUrl()) + if base == "" { + return "", errorutils.CheckErrorf("no JFrog Platform URL is configured. Set one with 'jf config' or pass --url / --mcp-url / the %s environment variable", cliutils.JfrogCliMcpUrl) + } + return strings.TrimRight(base, "/") + "/mcp", nil +} + +// EndpointInfo is the stable JSON schema printed by 'jf mcp show --format json'. +type EndpointInfo struct { + ServerId string `json:"serverId,omitempty"` + PlatformUrl string `json:"platformUrl,omitempty"` + McpUrl string `json:"mcpUrl"` + Transport string `json:"transport"` +} + +// CheckAvailability verifies the MCP endpoint is reachable. Any HTTP response — +// including an auth-required 401/403 or a 405 — proves the endpoint exists and +// counts as available, mirroring how MCP clients detect OAuth-protected servers. +// A network error or a 404 is treated as not available. +func CheckAvailability(serverDetails *coreconfig.ServerDetails, mcpURL string) error { + client, err := httpclient.ClientBuilder(). + SetInsecureTls(serverDetails.InsecureTls). + SetClientCertPath(serverDetails.ClientCertPath). + SetClientCertKeyPath(serverDetails.ClientCertKeyPath). + Build() + if err != nil { + return err + } + resp, _, _, err := client.SendGet(mcpURL, true, httputils.HttpClientDetails{}, "") + if err != nil { + return errorutils.CheckErrorf("the MCP server at %s is not reachable: %s", mcpURL, err.Error()) + } + defer func() { + if resp.Body != nil { + _ = resp.Body.Close() + } + }() + if resp.StatusCode == http.StatusNotFound { + return errorutils.CheckErrorf("the MCP server at %s is not available (HTTP 404). Verify the platform URL and that the MCP server is enabled", mcpURL) + } + log.Debug(fmt.Sprintf("MCP readiness check for %s returned HTTP %d", mcpURL, resp.StatusCode)) + return nil +} + +// readConfig loads the agent config file into a generic JSON object. A missing +// file yields an empty object. +func readConfig(path string) (map[string]interface{}, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]interface{}{}, nil + } + return nil, errorutils.CheckError(err) + } + if len(strings.TrimSpace(string(data))) == 0 { + return map[string]interface{}{}, nil + } + root := map[string]interface{}{} + if err := json.Unmarshal(data, &root); err != nil { + return nil, errorutils.CheckErrorf("failed to parse existing config %s: %s", path, err.Error()) + } + return root, nil +} + +func mcpServersMap(root map[string]interface{}) map[string]interface{} { + if existing, ok := root[mcpServersKey].(map[string]interface{}); ok { + return existing + } + servers := map[string]interface{}{} + root[mcpServersKey] = servers + return servers +} + +func marshalConfig(root map[string]interface{}) ([]byte, error) { + data, err := json.MarshalIndent(root, "", " ") + if err != nil { + return nil, errorutils.CheckError(err) + } + return append(data, '\n'), nil +} + +func writeConfigFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return errorutils.CheckError(err) + } + return errorutils.CheckError(os.WriteFile(path, data, 0600)) +} + +// fprintf writes a formatted message to out and returns a checked error, so +// callers report write failures instead of silently ignoring them. +func fprintf(out io.Writer, format string, a ...interface{}) error { + _, err := fmt.Fprintf(out, format, a...) + return errorutils.CheckError(err) +} + +// InstallParams holds the resolved inputs for an install operation. +type InstallParams struct { + Agent string + ServerName string + McpURL string + ProjectDir string + Global bool + DryRun bool + ServerDetails *coreconfig.ServerDetails + SkipCheck bool +} + +// Install writes (or previews) the JFrog MCP server entry into the agent config. +func Install(params InstallParams, out io.Writer) error { + spec, agentName, err := resolveAgent(params.Agent) + if err != nil { + return err + } + if !params.SkipCheck { + if err := CheckAvailability(params.ServerDetails, params.McpURL); err != nil { + return err + } + } + path, err := spec.configFilePath(params.ProjectDir, params.Global) + if err != nil { + return err + } + + root, err := readConfig(path) + if err != nil { + return err + } + servers := mcpServersMap(root) + entry := map[string]interface{}{"url": params.McpURL} + if spec.includeType { + entry["type"] = "http" + } + servers[params.ServerName] = entry + + data, err := marshalConfig(root) + if err != nil { + return err + } + + if params.DryRun { + return fprintf(out, "[Dry run] %s would be updated to:\n%s", path, data) + } + if err := writeConfigFile(path, data); err != nil { + return err + } + + scope := "project" + if params.Global { + scope = "global" + } + msg := fmt.Sprintf("Configured the '%s' MCP server for %s (%s scope): %s\n\n", params.ServerName, agentName, scope, path) + msg += "Next step: the connection is not active until you complete OAuth authorization.\n" + switch agentName { + case "claude": + msg += " Run /mcp inside Claude Code and complete the browser login.\n" + case "cursor": + msg += " Reload Cursor and approve the JFrog MCP server when prompted.\n" + } + return fprintf(out, "%s", msg) +} + +// UninstallParams holds the resolved inputs for an uninstall operation. +type UninstallParams struct { + Agent string + ServerName string + ProjectDir string + Global bool + DryRun bool +} + +// Uninstall removes the JFrog MCP server entry from the agent config, leaving +// other entries untouched. +func Uninstall(params UninstallParams, out io.Writer) error { + spec, agentName, err := resolveAgent(params.Agent) + if err != nil { + return err + } + path, err := spec.configFilePath(params.ProjectDir, params.Global) + if err != nil { + return err + } + + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { + return fprintf(out, "No %s configuration found at %s; nothing to remove.\n", agentName, path) + } + + root, err := readConfig(path) + if err != nil { + return err + } + servers, ok := root[mcpServersKey].(map[string]interface{}) + if !ok { + return fprintf(out, "No MCP servers configured in %s; nothing to remove.\n", path) + } + if _, present := servers[params.ServerName]; !present { + return fprintf(out, "No '%s' MCP server entry in %s; nothing to remove.\n", params.ServerName, path) + } + delete(servers, params.ServerName) + + data, err := marshalConfig(root) + if err != nil { + return err + } + if params.DryRun { + return fprintf(out, "[Dry run] %s would be updated to:\n%s", path, data) + } + if err := writeConfigFile(path, data); err != nil { + return err + } + return fprintf(out, "Removed the '%s' MCP server entry from %s (%s).\n", params.ServerName, path, agentName) +} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go new file mode 100644 index 000000000..cd9787877 --- /dev/null +++ b/mcp/mcp_test.go @@ -0,0 +1,242 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + coreconfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-cli/utils/cliutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveMcpURL(t *testing.T) { + t.Run("flag override wins", func(t *testing.T) { + got, err := ResolveMcpURL("https://override.example/mcp/", &coreconfig.ServerDetails{Url: "https://platform.example/"}) + require.NoError(t, err) + assert.Equal(t, "https://override.example/mcp", got) + }) + + t.Run("env override when no flag", func(t *testing.T) { + t.Setenv(cliutils.JfrogCliMcpUrl, "https://env.example/mcp/") + got, err := ResolveMcpURL("", &coreconfig.ServerDetails{Url: "https://platform.example/"}) + require.NoError(t, err) + assert.Equal(t, "https://env.example/mcp", got) + }) + + t.Run("derived from platform url", func(t *testing.T) { + got, err := ResolveMcpURL("", &coreconfig.ServerDetails{Url: "https://platform.example/"}) + require.NoError(t, err) + assert.Equal(t, "https://platform.example/mcp", got) + }) + + t.Run("error when no url available", func(t *testing.T) { + _, err := ResolveMcpURL("", &coreconfig.ServerDetails{}) + assert.Error(t, err) + }) +} + +func TestCheckAvailability(t *testing.T) { + cases := []struct { + status int + available bool + }{ + {http.StatusOK, true}, + {http.StatusUnauthorized, true}, + {http.StatusForbidden, true}, + {http.StatusMethodNotAllowed, true}, + {http.StatusNotFound, false}, + } + for _, tc := range cases { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + })) + err := CheckAvailability(&coreconfig.ServerDetails{}, srv.URL) + if tc.available { + assert.NoError(t, err, "status %d should be available", tc.status) + } else { + assert.Error(t, err, "status %d should not be available", tc.status) + } + srv.Close() + } + + t.Run("network error is not available", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := srv.URL + srv.Close() // ensure the address is no longer listening + assert.Error(t, CheckAvailability(&coreconfig.ServerDetails{}, url)) + }) +} + +func readServers(t *testing.T, path string) map[string]interface{} { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + root := map[string]interface{}{} + require.NoError(t, json.Unmarshal(data, &root)) + servers, ok := root[mcpServersKey].(map[string]interface{}) + require.True(t, ok, "expected mcpServers object") + return servers +} + +func TestInstall_Cursor_WritesEntryWithoutType(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + err := Install(InstallParams{ + Agent: "cursor", + ServerName: DefaultServerName, + McpURL: "https://platform.example/mcp", + ProjectDir: dir, + SkipCheck: true, + }, &out) + require.NoError(t, err) + + servers := readServers(t, filepath.Join(dir, ".cursor", "mcp.json")) + entry, ok := servers["jfrog"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "https://platform.example/mcp", entry["url"]) + _, hasType := entry["type"] + assert.False(t, hasType, "cursor entries must not include a type field") +} + +func TestInstall_Claude_WritesEntryWithType(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + err := Install(InstallParams{ + Agent: "claude", + ServerName: DefaultServerName, + McpURL: "https://platform.example/mcp", + ProjectDir: dir, + SkipCheck: true, + }, &out) + require.NoError(t, err) + + servers := readServers(t, filepath.Join(dir, ".mcp.json")) + entry, ok := servers["jfrog"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "http", entry["type"]) + assert.Equal(t, "https://platform.example/mcp", entry["url"]) +} + +func TestInstall_PreservesExistingEntries(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".cursor", "mcp.json") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + existing := `{"mcpServers":{"other":{"url":"https://other.example/mcp"}},"someOtherKey":true}` + require.NoError(t, os.WriteFile(path, []byte(existing), 0600)) + + var out bytes.Buffer + require.NoError(t, Install(InstallParams{ + Agent: "cursor", + ServerName: DefaultServerName, + McpURL: "https://platform.example/mcp", + ProjectDir: dir, + SkipCheck: true, + }, &out)) + + servers := readServers(t, path) + assert.Contains(t, servers, "other", "existing entry must be preserved") + assert.Contains(t, servers, "jfrog", "new entry must be added") + + // Unrelated top-level keys must be preserved. + data, err := os.ReadFile(path) + require.NoError(t, err) + root := map[string]interface{}{} + require.NoError(t, json.Unmarshal(data, &root)) + assert.Equal(t, true, root["someOtherKey"]) +} + +func TestInstall_Idempotent(t *testing.T) { + dir := t.TempDir() + params := InstallParams{ + Agent: "cursor", + ServerName: DefaultServerName, + McpURL: "https://platform.example/mcp", + ProjectDir: dir, + SkipCheck: true, + } + var out bytes.Buffer + require.NoError(t, Install(params, &out)) + require.NoError(t, Install(params, &out)) + + servers := readServers(t, filepath.Join(dir, ".cursor", "mcp.json")) + assert.Len(t, servers, 1) +} + +func TestInstall_DryRunWritesNothing(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + require.NoError(t, Install(InstallParams{ + Agent: "cursor", + ServerName: DefaultServerName, + McpURL: "https://platform.example/mcp", + ProjectDir: dir, + DryRun: true, + SkipCheck: true, + }, &out)) + + _, statErr := os.Stat(filepath.Join(dir, ".cursor", "mcp.json")) + assert.True(t, os.IsNotExist(statErr), "dry-run must not write a file") + assert.Contains(t, out.String(), "Dry run") +} + +func TestInstall_ReadinessFailureAbortsWithoutWriting(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + dir := t.TempDir() + var out bytes.Buffer + err := Install(InstallParams{ + Agent: "cursor", + ServerName: DefaultServerName, + McpURL: srv.URL, + ProjectDir: dir, + ServerDetails: &coreconfig.ServerDetails{}, + }, &out) + require.Error(t, err) + _, statErr := os.Stat(filepath.Join(dir, ".cursor", "mcp.json")) + assert.True(t, os.IsNotExist(statErr), "no config should be written when readiness fails") +} + +func TestInstall_UnsupportedAgent(t *testing.T) { + var out bytes.Buffer + err := Install(InstallParams{Agent: "windsurf", ServerName: DefaultServerName, SkipCheck: true}, &out) + require.Error(t, err) +} + +func TestUninstall_RemovesOnlyJfrogEntry(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".cursor", "mcp.json") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + existing := `{"mcpServers":{"jfrog":{"url":"https://platform.example/mcp"},"other":{"url":"https://other.example/mcp"}}}` + require.NoError(t, os.WriteFile(path, []byte(existing), 0600)) + + var out bytes.Buffer + require.NoError(t, Uninstall(UninstallParams{ + Agent: "cursor", + ServerName: DefaultServerName, + ProjectDir: dir, + }, &out)) + + servers := readServers(t, path) + assert.NotContains(t, servers, "jfrog") + assert.Contains(t, servers, "other") +} + +func TestUninstall_MissingFileIsNoOp(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + require.NoError(t, Uninstall(UninstallParams{ + Agent: "cursor", + ServerName: DefaultServerName, + ProjectDir: dir, + }, &out)) + assert.Contains(t, out.String(), "nothing to remove") +} diff --git a/utils/cliutils/cli_consts.go b/utils/cliutils/cli_consts.go index e1d6855a8..aa4f80c70 100644 --- a/utils/cliutils/cli_consts.go +++ b/utils/cliutils/cli_consts.go @@ -15,6 +15,7 @@ const ( CmdConfig = "config" CmdOptions = "options" CmdPipelines = "pl" + CmdMcp = "mcp" // Common Retries = 3 @@ -34,6 +35,9 @@ const ( // the legacy behavior. Applies uniformly to all commands, including OIDC // token-exchange failures. JfrogCliErrorOutputFormat = "JFROG_CLI_ERROR_OUTPUT_FORMAT" + // JfrogCliMcpUrl overrides the remote MCP server endpoint used by the 'jf mcp' + // commands. When unset, the endpoint is derived as /mcp. + JfrogCliMcpUrl = "JFROG_CLI_MCP_URL" ) // ErrorFormatJSON is the env-var value that switches HTTP error reporting to JSON-on-stderr. diff --git a/utils/cliutils/commandsflags.go b/utils/cliutils/commandsflags.go index 95ded3d1c..b5ad59a8a 100644 --- a/utils/cliutils/commandsflags.go +++ b/utils/cliutils/commandsflags.go @@ -8,8 +8,8 @@ import ( "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/plugins/components" "github.com/jfrog/jfrog-cli-core/v2/common/format" + "github.com/jfrog/jfrog-cli-core/v2/plugins/components" "github.com/jfrog/jfrog-cli-core/v2/utils/coreutils" "github.com/jfrog/jfrog-client-go/utils/log" @@ -137,6 +137,11 @@ const ( ExchangeOidcToken = "exchange-oidc-token" Api = "api" + // MCP commands keys + McpShow = "mcp-show" + McpInstall = "mcp-install" + McpUninstall = "mcp-uninstall" + // Plugin commands keys PluginInstall = "plugin-install" PluginPublish = "plugin-publish" @@ -510,17 +515,17 @@ const ( // Per-command format flag map keys. All share Name: "format" but restrict // the description to the formats that command actually supports. - configShowFormat = "config-show-format" - accessTokenCreateFormat = "access-token-create-format" - exchangeOidcTokenFormat = "exchange-oidc-token-format" - licenseAcquireFormat = "license-acquire-format" - licenseDeployFormat = "license-deploy-format" - jpdAddFormat = "jpd-add-format" - pluginInstallFormat = "plugin-install-format" - pluginPublishFormat = "plugin-publish-format" - plStatusFormat = "pl-status-format" - plTriggerFormat = "pl-trigger-format" - plSyncFormat = "pl-sync-format" + configShowFormat = "config-show-format" + accessTokenCreateFormat = "access-token-create-format" + exchangeOidcTokenFormat = "exchange-oidc-token-format" + licenseAcquireFormat = "license-acquire-format" + licenseDeployFormat = "license-deploy-format" + jpdAddFormat = "jpd-add-format" + pluginInstallFormat = "plugin-install-format" + pluginPublishFormat = "plugin-publish-format" + plStatusFormat = "pl-status-format" + plTriggerFormat = "pl-trigger-format" + plSyncFormat = "pl-sync-format" plSyncStatusFormat = "pl-sync-status-format" permissionTargetCreateFormat = "permission-target-create-format" permissionTargetUpdateFormat = "permission-target-update-format" @@ -643,12 +648,22 @@ const ( RepoKey = "repo-key" // API command flags - apiHeader = "api-header" - apiInput = "api-input" - apiData = "api-data" - apiMethod = "api-method" - apiVerbose = "api-verbose" - apiTimeout = "api-timeout" + apiHeader = "api-header" + apiInput = "api-input" + apiData = "api-data" + apiMethod = "api-method" + apiVerbose = "api-verbose" + apiTimeout = "api-timeout" + + // MCP command flags + mcpUrl = "mcp-url" + mcpAgent = "mcp-agent" + mcpGlobal = "mcp-global" + mcpProjectDir = "mcp-project-dir" + mcpName = "mcp-name" + mcpDryRun = "mcp-dry-run" + mcpSkipCheck = "mcp-skip-check" + mcpShowFormat = "mcp-show-format" ) var flagsMap = map[string]cli.Flag{ @@ -786,6 +801,38 @@ var flagsMap = map[string]cli.Flag{ Name: "timeout", Usage: "[Default: 0] Overall HTTP request timeout in seconds. 0 means no timeout.` `", }, + mcpShowFormat: cli.StringFlag{ + Name: Format, + Usage: "[Optional] " + components.GetFormatFlagDescription([]format.OutputFormat{format.Table, format.Json}) + "` `", + }, + mcpUrl: cli.StringFlag{ + Name: "mcp-url", + Usage: "[Optional] Remote MCP server endpoint. Overrides the value derived from the platform URL (/mcp) and the " + JfrogCliMcpUrl + " environment variable.` `", + }, + mcpAgent: cli.StringFlag{ + Name: "agent", + Usage: "[Optional] Target AI agent to configure: 'cursor' or 'claude'.` `", + }, + mcpGlobal: cli.BoolFlag{ + Name: "global", + Usage: "[Default: false] Configure the MCP server in the agent's global (user-level) configuration instead of the current project.` `", + }, + mcpProjectDir: cli.StringFlag{ + Name: "project-dir", + Usage: "[Default: current directory] Project directory whose agent configuration should be updated. Ignored when --global is set.` `", + }, + mcpName: cli.StringFlag{ + Name: "name", + Usage: "[Default: jfrog] Name of the MCP server entry written to the agent configuration.` `", + }, + mcpDryRun: cli.BoolFlag{ + Name: "dry-run", + Usage: "[Default: false] Print the resulting agent configuration without writing it to disk.` `", + }, + mcpSkipCheck: cli.BoolFlag{ + Name: "skip-check", + Usage: "[Default: false] Skip the readiness check that verifies the MCP server is reachable before configuring the agent.` `", + }, // Artifactory's commands flags url: cli.StringFlag{ Name: url, @@ -2196,6 +2243,21 @@ var commandFlags = map[string][]string{ ClientCertKeyPath, InsecureTls, configDisableRefreshAccessToken, apiHeader, apiInput, apiData, apiMethod, apiVerbose, apiTimeout, }, + McpShow: { + platformUrl, user, password, accessToken, sshPassphrase, sshKeyPath, serverId, ClientCertPath, + ClientCertKeyPath, InsecureTls, configDisableRefreshAccessToken, + mcpUrl, mcpShowFormat, + }, + McpInstall: { + platformUrl, user, password, accessToken, sshPassphrase, sshKeyPath, serverId, ClientCertPath, + ClientCertKeyPath, InsecureTls, configDisableRefreshAccessToken, + mcpUrl, mcpAgent, mcpGlobal, mcpProjectDir, mcpName, mcpDryRun, mcpSkipCheck, + }, + McpUninstall: { + platformUrl, user, password, accessToken, sshPassphrase, sshKeyPath, serverId, ClientCertPath, + ClientCertKeyPath, InsecureTls, configDisableRefreshAccessToken, + mcpAgent, mcpGlobal, mcpProjectDir, mcpName, mcpDryRun, + }, TemplateConsumer: { url, user, password, accessToken, sshPassphrase, sshKeyPath, serverId, ClientCertPath, ClientCertKeyPath, vars, diff --git a/utils/usage/usage.go b/utils/usage/usage.go new file mode 100644 index 000000000..dba92544b --- /dev/null +++ b/utils/usage/usage.go @@ -0,0 +1,52 @@ +package usage + +import ( + "time" + + "github.com/jfrog/jfrog-cli-core/v2/common/commands" + coreconfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// DefaultReportTimeout bounds how long a command waits for the usage report to +// complete before giving up. Commands without a user-facing timeout flag (for +// example "jf mcp ...") should pass this so a slow report never blocks the CLI. +const DefaultReportTimeout = 2 * time.Second + +// ReportUsageFn is the usage reporter invoked by StartReport. It is a package +// variable so tests can inject fakes (panic, slow, fast) without touching HTTP. +var ReportUsageFn = commands.ReportUsage + +// StartReport collects command metrics and launches the usage report in a +// goroutine, recovering from any panic so that a misbehaving reporter cannot +// crash the process. The returned channel is signaled by ReportUsage on +// success, or closed by the recover path on panic — either way the caller's +// receive unblocks. Pair it with a deferred WaitForReport. +func StartReport(commandName string, flagsUsed []string, serverDetails *coreconfig.ServerDetails) chan bool { + commands.CollectMetrics(commandName, flagsUsed) + waitUsageReport := make(chan bool) + go func() { + defer func() { + if r := recover(); r != nil { + log.Debug(commandName+": usage report panicked:", r) + close(waitUsageReport) + } + }() + ReportUsageFn(commandName, serverDetails, waitUsageReport) + }() + return waitUsageReport +} + +// WaitForReport blocks until the usage report completes or the timeout elapses. +// A non-positive timeout waits indefinitely. +func WaitForReport(commandName string, wait <-chan bool, timeout time.Duration) { + if timeout <= 0 { + <-wait + return + } + select { + case <-wait: + case <-time.After(timeout): + log.Debug(commandName+": usage report did not complete within", timeout) + } +} diff --git a/utils/usage/usage_test.go b/utils/usage/usage_test.go new file mode 100644 index 000000000..63cd1f6a3 --- /dev/null +++ b/utils/usage/usage_test.go @@ -0,0 +1,77 @@ +package usage + +import ( + "testing" + "time" + + "github.com/jfrog/jfrog-cli-core/v2/common/commands" + coreconfig "github.com/jfrog/jfrog-cli-core/v2/utils/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// swapReportUsageFn replaces ReportUsageFn for the duration of the test and +// restores it on cleanup. Tests using this must not run in parallel. +func swapReportUsageFn(t *testing.T, fn func(string, *coreconfig.ServerDetails, chan<- bool)) { + t.Helper() + prev := ReportUsageFn + ReportUsageFn = fn + t.Cleanup(func() { ReportUsageFn = prev }) +} + +func TestStartReport_CollectsMetrics(t *testing.T) { + swapReportUsageFn(t, func(_ string, _ *coreconfig.ServerDetails, ch chan<- bool) { + ch <- true + }) + + flags := []string{"verbose", "header"} + wait := StartReport("jf usage-test", flags, &coreconfig.ServerDetails{}) + WaitForReport("jf usage-test", wait, time.Second) + + got := commands.GetCollectedMetrics("jf usage-test") + require.NotNil(t, got, "expected metrics to be collected for command \"jf usage-test\"") + assert.Equal(t, flags, got.Flags) +} + +func TestStartReport_PanicIsRecovered(t *testing.T) { + swapReportUsageFn(t, func(_ string, _ *coreconfig.ServerDetails, _ chan<- bool) { + panic("boom") + }) + + wait := StartReport("jf usage-test", nil, &coreconfig.ServerDetails{}) + select { + case <-wait: + // Channel was closed by the recover path; receive returns the zero value. + case <-time.After(time.Second): + t.Fatal("StartReport did not unblock after panic") + } +} + +func TestWaitForReport(t *testing.T) { + const cmd = "jf usage-test" + + t.Run("non-positive timeout waits for signal", func(t *testing.T) { + ch := make(chan bool, 1) + ch <- true + start := time.Now() + WaitForReport(cmd, ch, 0) + assert.Less(t, time.Since(start), 50*time.Millisecond) + }) + + t.Run("returns immediately when signaled before timeout", func(t *testing.T) { + ch := make(chan bool, 1) + ch <- true + start := time.Now() + WaitForReport(cmd, ch, time.Second) + assert.Less(t, time.Since(start), 50*time.Millisecond) + }) + + t.Run("returns at timeout when never signaled", func(t *testing.T) { + ch := make(chan bool) + start := time.Now() + WaitForReport(cmd, ch, 100*time.Millisecond) + elapsed := time.Since(start) + assert.GreaterOrEqual(t, elapsed, 100*time.Millisecond) + assert.Less(t, elapsed, 500*time.Millisecond) + }) +}