Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/mcp/install/help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package install

var Usage = []string{"mcp install --agent <cursor|claude>"}

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.`
}
23 changes: 23 additions & 0 deletions docs/mcp/show/help.go
Original file line number Diff line number Diff line change
@@ -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 <platform-url>/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.`
}
20 changes: 20 additions & 0 deletions docs/mcp/uninstall/help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package uninstall

var Usage = []string{"mcp uninstall --agent <cursor|claude>"}

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.`
}
46 changes: 4 additions & 42 deletions general/api/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
Expand All @@ -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 == "" {
Expand Down
52 changes: 6 additions & 46 deletions general/api/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand All @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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).",
Expand Down
Loading
Loading