From dbb8c407da393c029baaf9e91899336c3ec330bc Mon Sep 17 00:00:00 2001 From: Rajpreet Singh Date: Tue, 29 Sep 2026 10:05:57 +0530 Subject: [PATCH 1/3] feat: add MCP setup instructions and implement token fetching - Updated README.md with instructions for automatic and manual MCP (Model Context Protocol) setup, including the new `setup`, `status`, and `uninstall` commands. - Implemented `NewCachedTokenFetcher` in the `auth` package to provide a mechanism for retrieving valid bearer JWTs from cached login sessions. - Introduced `IEnvironment` and `mcpclient` logic to handle client-specific configuration paths and command discovery for VS Code, Claude, and Cursor. - Added comprehensive unit tests for token fetching and MCP client environment handling. --- README.md | 34 +++- internal/auth/auth.go | 21 +++ internal/auth/auth_test.go | 54 ++++++ internal/cmd/mcp/mcp.go | 13 +- internal/cmd/mcp/mcp_test.go | 14 +- .../cmd/mcp/setup/fake_authenticator_test.go | 17 ++ internal/cmd/mcp/setup/setup.go | 139 ++++++++++++++ internal/cmd/mcp/setup/setup_test.go | 149 +++++++++++++++ .../cmd/mcp/start/fake_authenticator_test.go | 17 ++ internal/cmd/mcp/{ => start}/start.go | 50 +++-- internal/cmd/mcp/{ => start}/start_test.go | 62 +------ internal/cmd/mcp/status/status.go | 120 ++++++++++++ internal/cmd/mcp/status/status_test.go | 79 ++++++++ internal/cmd/mcp/uninstall/uninstall.go | 69 +++++++ internal/cmd/mcp/uninstall/uninstall_test.go | 55 ++++++ internal/io/file_system.go | 5 + internal/io/testdata/file_system_mock.go | 5 + internal/mcpclient/client.go | 175 ++++++++++++++++++ internal/mcpclient/client_test.go | 154 +++++++++++++++ internal/mcpclient/config_file.go | 170 +++++++++++++++++ internal/mcpclient/config_file_test.go | 147 +++++++++++++++ internal/mcpclient/environment.go | 44 +++++ internal/mcpclient/handshake.go | 134 ++++++++++++++ internal/mcpclient/handshake_test.go | 82 ++++++++ .../mcpclient/testdata/fake_environment.go | 39 ++++ .../mcpclient/testdata/fake_filesystem.go | 63 +++++++ 26 files changed, 1819 insertions(+), 92 deletions(-) create mode 100644 internal/cmd/mcp/setup/fake_authenticator_test.go create mode 100644 internal/cmd/mcp/setup/setup.go create mode 100644 internal/cmd/mcp/setup/setup_test.go create mode 100644 internal/cmd/mcp/start/fake_authenticator_test.go rename internal/cmd/mcp/{ => start}/start.go (56%) rename internal/cmd/mcp/{ => start}/start_test.go (79%) create mode 100644 internal/cmd/mcp/status/status.go create mode 100644 internal/cmd/mcp/status/status_test.go create mode 100644 internal/cmd/mcp/uninstall/uninstall.go create mode 100644 internal/cmd/mcp/uninstall/uninstall_test.go create mode 100644 internal/mcpclient/client.go create mode 100644 internal/mcpclient/client_test.go create mode 100644 internal/mcpclient/config_file.go create mode 100644 internal/mcpclient/config_file_test.go create mode 100644 internal/mcpclient/environment.go create mode 100644 internal/mcpclient/handshake.go create mode 100644 internal/mcpclient/handshake_test.go create mode 100644 internal/mcpclient/testdata/fake_environment.go create mode 100644 internal/mcpclient/testdata/fake_filesystem.go diff --git a/README.md b/README.md index 4d1dd756..4db934f4 100644 --- a/README.md +++ b/README.md @@ -112,8 +112,38 @@ AI coding assistants (Claude, Copilot, Cursor, etc.) check whether a dependency policies before it's installed. 1. Authenticate once with `debricked auth login`, or have a [Debricked access token](https://docs.debricked.com/product/administration/generate-access-token) ready to pass via `--access-token`/`DEBRICKED_TOKEN`. -2. Point your MCP client at the `debricked` binary with the `mcp start` arguments. Examples below assume `debricked` - is on your `PATH`; otherwise use the full path to the binary. +2. Configure your MCP client. No access token is ever written to a config file by `debricked`; the server always + authenticates using your cached `debricked auth login` session (or `--access-token`/`DEBRICKED_TOKEN` if set). + +### Automatic setup (recommended) + +```sh +debricked mcp setup --client vscode # or claude / cursor +``` + +This adds a `debricked` entry to the client's config file (`.vscode/mcp.json`, `.cursor/mcp.json` at the project +level by default, or Claude Desktop's global config). Existing entries/config are preserved and a `.bak` copy is +written before any existing file is modified. Pass `--global` to configure the user-level VS Code/Cursor config +instead of the project one. + +Check that everything is wired up and the server actually starts and authenticates: + +```sh +debricked mcp status # checks vscode, claude and cursor +debricked mcp status --client vscode +``` + +Remove the entry again (the config file itself is never deleted): + +```sh +debricked mcp uninstall --client vscode +``` + +### Manual setup + +If you'd rather edit client config yourself, or want to see what `mcp setup` writes, point your MCP client at the +`debricked` binary with the `mcp start` arguments. Examples below assume `debricked` is on your `PATH`; otherwise +use the full path to the binary. ### VS Code (`.vscode/mcp.json`) ```jsonc diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 59d212ac..49bb7816 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "strings" "github.com/golang-jwt/jwt" @@ -139,6 +140,26 @@ func (a Authenticator) refresh(refreshToken string) (*oauth2.Token, error) { } } +// NewCachedTokenFetcher returns a function that fetches a currently-valid bearer JWT +// from the cached `debricked auth login` session on every call, reusing this +// authenticator's own keyring + OAuth refresh logic. Intended for callers (e.g. the +// Fortify SCA MCP server's Options.TokenFetcher) that need a fresh token on demand +// rather than a long-lived credential to exchange/manage themselves - this avoids +// ever handing out the OAuth refresh token, which /api/login_refresh doesn't accept. +func NewCachedTokenFetcher(authenticator IAuthenticator) func(context.Context) (string, error) { + return func(context.Context) (string, error) { + token, err := authenticator.Token() + if err != nil { + return "", err + } + if token == nil || strings.TrimSpace(token.AccessToken) == "" { + return "", errors.New("cached login has no access token") + } + + return strings.TrimSpace(token.AccessToken), nil + } +} + func (a Authenticator) Authenticate() error { state := oauth2.GenerateVerifier() codeVerifier := oauth2.GenerateVerifier() diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index f588b72f..d0a067fa 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1,6 +1,7 @@ package auth import ( + "context" "runtime" "testing" @@ -259,3 +260,56 @@ func TestMockedAuthenticateOpenURLError(t *testing.T) { assert.Error(t, err) } + +// fakeTokenAuthenticator is a minimal IAuthenticator test double with a +// configurable Token() result. +type fakeTokenAuthenticator struct { + token *oauth2.Token + err error +} + +func (f fakeTokenAuthenticator) Authenticate() error { return nil } +func (f fakeTokenAuthenticator) Logout() error { return nil } +func (f fakeTokenAuthenticator) Token() (*oauth2.Token, error) { + return f.token, f.err +} + +func TestNewCachedTokenFetcher(t *testing.T) { + tests := []struct { + name string + authenticator fakeTokenAuthenticator + wantToken string + wantErr bool + }{ + { + name: "returns cached access token", + authenticator: fakeTokenAuthenticator{token: &oauth2.Token{AccessToken: "jwt", RefreshToken: "cached-refresh-token"}}, //nolint:gosec // test fixture, not a real credential + wantToken: "jwt", + }, + { + name: "authenticator error surfaces as error", + authenticator: fakeTokenAuthenticator{err: assert.AnError}, + wantToken: "", + wantErr: true, + }, + { + name: "cached login with empty access token errors", + authenticator: fakeTokenAuthenticator{token: &oauth2.Token{}}, + wantToken: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotToken, err := NewCachedTokenFetcher(tt.authenticator)(context.Background()) + + assert.Equal(t, tt.wantToken, gotToken) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/cmd/mcp/mcp.go b/internal/cmd/mcp/mcp.go index b336dc3b..325110dd 100644 --- a/internal/cmd/mcp/mcp.go +++ b/internal/cmd/mcp/mcp.go @@ -2,6 +2,12 @@ package mcp import ( "github.com/debricked/cli/internal/auth" + "github.com/debricked/cli/internal/cmd/mcp/setup" + "github.com/debricked/cli/internal/cmd/mcp/start" + "github.com/debricked/cli/internal/cmd/mcp/status" + "github.com/debricked/cli/internal/cmd/mcp/uninstall" + "github.com/debricked/cli/internal/io" + "github.com/debricked/cli/internal/mcpclient" "github.com/spf13/cobra" ) @@ -15,7 +21,12 @@ func NewMCPCmd(accessToken *string, authenticator auth.IAuthenticator, baseURL s Short: "Debricked MCP server.", Long: `Run a Model Context Protocol (MCP) server exposing Debricked functionality.`, } - cmd.AddCommand(NewStartCmd(accessToken, authenticator, baseURL)) + fs := io.FileSystem{} + env := mcpclient.NewEnvironment() + cmd.AddCommand(start.NewStartCmd(accessToken, authenticator, baseURL)) + cmd.AddCommand(setup.NewSetupCmd(accessToken, authenticator, baseURL, fs, env)) + cmd.AddCommand(status.NewStatusCmd(fs, env)) + cmd.AddCommand(uninstall.NewUninstallCmd(fs, env)) return cmd } diff --git a/internal/cmd/mcp/mcp_test.go b/internal/cmd/mcp/mcp_test.go index ac235444..9f9a06fd 100644 --- a/internal/cmd/mcp/mcp_test.go +++ b/internal/cmd/mcp/mcp_test.go @@ -10,16 +10,16 @@ func TestNewMCPCmd(t *testing.T) { token := "" cmd := NewMCPCmd(&token, fakeAuthenticator{}, "https://debricked.com") commands := cmd.Commands() - nbrOfCommands := 1 + nbrOfCommands := 4 assert.Lenf(t, commands, nbrOfCommands, "failed to assert that there were %d sub commands connected", nbrOfCommands) - match := false + expected := map[string]bool{"start": false, "setup": false, "status": false, "uninstall": false} for _, subCmd := range commands { - if subCmd.Name() == "start" { - match = true - - break + if _, ok := expected[subCmd.Name()]; ok { + expected[subCmd.Name()] = true } } - assert.True(t, match, "failed to assert that start command was registered") + for name, found := range expected { + assert.Truef(t, found, "failed to assert that %s command was registered", name) + } } diff --git a/internal/cmd/mcp/setup/fake_authenticator_test.go b/internal/cmd/mcp/setup/fake_authenticator_test.go new file mode 100644 index 00000000..9b37893a --- /dev/null +++ b/internal/cmd/mcp/setup/fake_authenticator_test.go @@ -0,0 +1,17 @@ +package setup + +import ( + "golang.org/x/oauth2" +) + +// fakeAuthenticator is a test double for auth.IAuthenticator. +type fakeAuthenticator struct { + token *oauth2.Token + err error +} + +func (f fakeAuthenticator) Authenticate() error { return nil } +func (f fakeAuthenticator) Logout() error { return nil } +func (f fakeAuthenticator) Token() (*oauth2.Token, error) { + return f.token, f.err +} diff --git a/internal/cmd/mcp/setup/setup.go b/internal/cmd/mcp/setup/setup.go new file mode 100644 index 00000000..b2a3ea9d --- /dev/null +++ b/internal/cmd/mcp/setup/setup.go @@ -0,0 +1,139 @@ +package setup + +import ( + "fmt" + "os/exec" + "strings" + + "github.com/debricked/cli/internal/auth" + "github.com/debricked/cli/internal/cmd/cmderror" + "github.com/debricked/cli/internal/io" + "github.com/debricked/cli/internal/mcpclient" + "github.com/debricked/fortify-sca-mcp/v26/pkg/server" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +// apiVersion matches the Debricked API version the rest of the CLI targets (e.g. /api/1.0/...). +const apiVersion = "1.0" + +// verifyFn is a seam over server.VerifyAccessToken to allow tests to substitute a fake implementation. +var verifyFn = server.VerifyAccessToken + +// lookPathFn is a seam over exec.LookPath to allow tests to substitute a fake implementation. +var lookPathFn = exec.LookPath + +// NewSetupCmd creates the `mcp setup` command, which adds a debricked MCP server +// entry to a supported client's config file. +func NewSetupCmd(accessToken *string, authenticator auth.IAuthenticator, baseURL string, fs io.IFileSystem, env mcpclient.IEnvironment) *cobra.Command { + var clientFlag string + var global bool + + cmd := &cobra.Command{ + Use: "setup", + Short: "Add the Debricked MCP server to a client's configuration.", + Long: `Add a "debricked" MCP server entry to a supported client's configuration file +(VS Code, Claude Desktop, or Cursor), so it can be picked up without hand-editing JSON. + +By default, VS Code and Cursor are configured at the project level (./.vscode/mcp.json, +./.cursor/mcp.json, relative to the current directory). Pass --global to configure the +user-level config instead. Claude Desktop only has a global configuration; --global has +no effect for it. + +No access token is ever written to the config file. Run 'debricked auth login' beforehand +so the server can authenticate when your MCP client starts it.`, + RunE: func(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + + client, err := mcpclient.ParseClient(clientFlag) + if err != nil { + return cmderror.CommandError{Code: 1, Err: err} + } + + if global && client == mcpclient.ClientClaude { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: Claude Desktop only has a global configuration; --global has no effect.") + } + + path, err := mcpclient.ConfigPath(client, global, env) + if err != nil { + return cmderror.CommandError{Code: 1, Err: err} + } + + config, existed, err := mcpclient.ReadConfig(fs, path) + if err != nil { + return cmderror.CommandError{Code: 1, Err: err} + } + + command, portable := resolveCommand(env) + fields := map[string]any{ + "command": command, + "args": []string{"mcp", "start"}, + } + + changed := mcpclient.UpsertServerEntry(config, mcpclient.ServerMapKey(client), mcpclient.ServerEntryName, fields) + if !changed { + fmt.Fprintf(cmd.OutOrStdout(), "%s Already configured at %s, no changes made.\n", color.GreenString("✔"), path) + + return nil + } + + if err := mcpclient.WriteConfig(fs, path, config, existed); err != nil { + return cmderror.CommandError{Code: 1, Err: err} + } + + if existed { + fmt.Fprintf(cmd.OutOrStdout(), "Backed up previous config to %s%s\n", path, mcpclient.BackupSuffix) + } + if !portable { + fmt.Fprintf(cmd.ErrOrStderr(), "note: %q was not found on PATH, using the current binary's absolute path instead. This is machine-specific and won't survive a reinstall or move.\n", "debricked") + } + fmt.Fprintf(cmd.OutOrStdout(), "%s Configured %s at %s\n", color.GreenString("✔"), client, path) + + warnIfNoCredentials(cmd, accessToken, authenticator, baseURL) + + return nil + }, + } + + cmd.Flags().StringVar(&clientFlag, "client", "", "MCP client to configure: vscode, claude, or cursor.") + cmd.Flags().BoolVar(&global, "global", false, "Configure the user-level (global) config instead of the project-level one.") + _ = cmd.MarkFlagRequired("client") + + return cmd +} + +// resolveCommand prefers the bare "debricked" command, which resolves via the MCP +// client's own PATH and is safe to commit to a shared project config. It falls back +// to the absolute path of the currently running binary if "debricked" isn't on PATH. +// The second return value reports whether the portable form was used. +func resolveCommand(env mcpclient.IEnvironment) (string, bool) { + if _, err := lookPathFn("debricked"); err == nil { + return "debricked", true + } + + if path, err := env.Executable(); err == nil { + return path, false + } + + return "debricked", true +} + +// warnIfNoCredentials prints a best-effort warning (never an error) if no access +// token can currently be resolved, or if the resolved token is rejected by the API - +// the config was still written successfully either way. +func warnIfNoCredentials(cmd *cobra.Command, accessToken *string, authenticator auth.IAuthenticator, baseURL string) { + var options server.Options + if token := strings.TrimSpace(*accessToken); token != "" { + options = server.Options{AccessToken: token, BaseURL: baseURL, APIVersion: apiVersion} + } else if _, err := authenticator.Token(); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: no access token found; run `debricked auth login` before starting the server. (%v)\n", err) + + return + } else { + options = server.Options{TokenFetcher: auth.NewCachedTokenFetcher(authenticator), BaseURL: baseURL, APIVersion: apiVersion} + } + + if err := verifyFn(cmd.Context(), options); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: access token rejected: %v\n", err) + } +} diff --git a/internal/cmd/mcp/setup/setup_test.go b/internal/cmd/mcp/setup/setup_test.go new file mode 100644 index 00000000..2c82964b --- /dev/null +++ b/internal/cmd/mcp/setup/setup_test.go @@ -0,0 +1,149 @@ +package setup + +import ( + "context" + "errors" + "os/exec" + "testing" + + "github.com/debricked/cli/internal/mcpclient/testdata" + "github.com/debricked/fortify-sca-mcp/v26/pkg/server" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func withStubbedVerify(t *testing.T, err error) { + t.Helper() + original := verifyFn + verifyFn = func(context.Context, server.Options) error { return err } + t.Cleanup(func() { verifyFn = original }) +} + +func withStubbedLookPath(t *testing.T, found bool) { + t.Helper() + original := lookPathFn + if found { + lookPathFn = func(string) (string, error) { return "/usr/bin/debricked", nil } + } else { + lookPathFn = func(string) (string, error) { return "", exec.ErrNotFound } + } + t.Cleanup(func() { lookPathFn = original }) +} + +func TestSetupCmdRequiresClientFlag(t *testing.T) { + withStubbedVerify(t, nil) + withStubbedLookPath(t, true) + token := "tok" + cmd := NewSetupCmd(&token, fakeAuthenticator{}, "https://debricked.com", testdata.NewFileSystem(), testdata.Environment{Wd: "/proj"}) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + + assert.Error(t, err) +} + +func TestSetupCmdInvalidClient(t *testing.T) { + withStubbedVerify(t, nil) + withStubbedLookPath(t, true) + token := "tok" + cmd := NewSetupCmd(&token, fakeAuthenticator{}, "https://debricked.com", testdata.NewFileSystem(), testdata.Environment{Wd: "/proj"}) + cmd.SetArgs([]string{"--client", "bogus"}) + + err := cmd.Execute() + + assert.Error(t, err) +} + +func TestSetupCmdCreatesProjectConfig(t *testing.T) { + withStubbedVerify(t, nil) + withStubbedLookPath(t, true) + fs := testdata.NewFileSystem() + token := "tok" + cmd := NewSetupCmd(&token, fakeAuthenticator{}, "https://debricked.com", fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) + cmd.SetArgs([]string{"--client", "vscode"}) + + err := cmd.Execute() + + require.NoError(t, err) + content, ok := fs.Files["/proj/.vscode/mcp.json"] + require.True(t, ok) + assert.Contains(t, string(content), `"debricked"`) + assert.NotContains(t, fs.Files, "/proj/.vscode/mcp.json.bak") +} + +func TestSetupCmdFallsBackToAbsolutePathWhenNotOnPATH(t *testing.T) { + withStubbedVerify(t, nil) + withStubbedLookPath(t, false) + fs := testdata.NewFileSystem() + token := "tok" + env := testdata.Environment{Wd: "/proj", GOOSValue: "linux", ExecutablePath: "/opt/debricked/debricked"} + cmd := NewSetupCmd(&token, fakeAuthenticator{}, "https://debricked.com", fs, env) + cmd.SetArgs([]string{"--client", "vscode"}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Contains(t, string(fs.Files["/proj/.vscode/mcp.json"]), "/opt/debricked/debricked") +} + +func TestSetupCmdIdempotent(t *testing.T) { + withStubbedVerify(t, nil) + withStubbedLookPath(t, true) + fs := testdata.NewFileSystem() + token := "tok" + env := testdata.Environment{Wd: "/proj", GOOSValue: "linux"} + cmd := NewSetupCmd(&token, fakeAuthenticator{}, "https://debricked.com", fs, env) + cmd.SetArgs([]string{"--client", "vscode"}) + require.NoError(t, cmd.Execute()) + + cmd2 := NewSetupCmd(&token, fakeAuthenticator{}, "https://debricked.com", fs, env) + cmd2.SetArgs([]string{"--client", "vscode"}) + require.NoError(t, cmd2.Execute()) + + assert.NotContains(t, fs.Files, "/proj/.vscode/mcp.json.bak") +} + +func TestSetupCmdBacksUpExistingFile(t *testing.T) { + withStubbedVerify(t, nil) + withStubbedLookPath(t, true) + fs := testdata.NewFileSystem() + fs.Files["/proj/.vscode/mcp.json"] = []byte(`{"servers":{"other":{"command":"foo"}}}`) + token := "tok" + cmd := NewSetupCmd(&token, fakeAuthenticator{}, "https://debricked.com", fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) + cmd.SetArgs([]string{"--client", "vscode"}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Contains(t, fs.Files, "/proj/.vscode/mcp.json.bak") + assert.Contains(t, string(fs.Files["/proj/.vscode/mcp.json"]), "other") +} + +func TestSetupCmdMalformedExistingFileAborts(t *testing.T) { + withStubbedVerify(t, nil) + withStubbedLookPath(t, true) + fs := testdata.NewFileSystem() + fs.Files["/proj/.vscode/mcp.json"] = []byte(`{ not json`) + token := "tok" + cmd := NewSetupCmd(&token, fakeAuthenticator{}, "https://debricked.com", fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) + cmd.SetArgs([]string{"--client", "vscode"}) + + err := cmd.Execute() + + assert.Error(t, err) +} + +func TestSetupCmdWarnsWhenNoCredentials(t *testing.T) { + withStubbedVerify(t, nil) + withStubbedLookPath(t, true) + fs := testdata.NewFileSystem() + token := "" + cmd := NewSetupCmd(&token, fakeAuthenticator{err: errors.New("not logged in")}, "https://debricked.com", fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) + cmd.SetArgs([]string{"--client", "vscode"}) + + err := cmd.Execute() + + // config is still written successfully even without credentials + require.NoError(t, err) + assert.Contains(t, fs.Files, "/proj/.vscode/mcp.json") +} diff --git a/internal/cmd/mcp/start/fake_authenticator_test.go b/internal/cmd/mcp/start/fake_authenticator_test.go new file mode 100644 index 00000000..005005b2 --- /dev/null +++ b/internal/cmd/mcp/start/fake_authenticator_test.go @@ -0,0 +1,17 @@ +package start + +import ( + "golang.org/x/oauth2" +) + +// fakeAuthenticator is a test double for auth.IAuthenticator. +type fakeAuthenticator struct { + token *oauth2.Token + err error +} + +func (f fakeAuthenticator) Authenticate() error { return nil } +func (f fakeAuthenticator) Logout() error { return nil } +func (f fakeAuthenticator) Token() (*oauth2.Token, error) { + return f.token, f.err +} diff --git a/internal/cmd/mcp/start.go b/internal/cmd/mcp/start/start.go similarity index 56% rename from internal/cmd/mcp/start.go rename to internal/cmd/mcp/start/start.go index 4eb69cb2..fbbcff45 100644 --- a/internal/cmd/mcp/start.go +++ b/internal/cmd/mcp/start/start.go @@ -1,4 +1,4 @@ -package mcp +package start import ( "context" @@ -19,8 +19,8 @@ var serveFn = server.Serve // verifyFn is a seam over server.VerifyAccessToken to allow tests to substitute a fake implementation. var verifyFn = server.VerifyAccessToken -// apiVersion matches the Debricked API version the rest of the CLI targets (e.g. /api/1.0/...). -const apiVersion = "1.0" +// APIVersion matches the Debricked API version the rest of the CLI targets (e.g. /api/1.0/...). +const APIVersion = "1.0" // NewStartCmd creates the `mcp start` command, which runs an MCP server over stdio. func NewStartCmd(accessToken *string, authenticator auth.IAuthenticator, baseURL string) *cobra.Command { @@ -31,18 +31,11 @@ func NewStartCmd(accessToken *string, authenticator auth.IAuthenticator, baseURL RunE: func(cmd *cobra.Command, _ []string) error { cmd.SilenceUsage = true - token, resolveErr := resolveToken(*accessToken, authenticator) - if token == "" { - err := errors.New("no access token found. Pass --access-token/DEBRICKED_TOKEN, or run `debricked auth login` first") - if resolveErr != nil { - err = fmt.Errorf("%w (%v)", err, resolveErr) - } - + options, err := buildServerOptions(*accessToken, authenticator, baseURL) + if err != nil { return cmderror.CommandError{Code: 1, Err: err} } - options := server.Options{AccessToken: token, BaseURL: baseURL, APIVersion: apiVersion} - // Fail fast on bad credentials instead of only discovering it on the first tool call. if err := verifyFn(cmd.Context(), options); err != nil { return cmderror.CommandError{Code: 1, Err: fmt.Errorf("access token rejected: %w", err)} @@ -53,7 +46,7 @@ func NewStartCmd(accessToken *string, authenticator auth.IAuthenticator, baseURL return cmderror.CommandError{Code: 1, Err: err} } - err := serveFn(cmd.Context(), options, os.Stdin, os.Stdout) + err = serveFn(cmd.Context(), options, os.Stdin, os.Stdout) if err == nil || errors.Is(err, context.Canceled) { return nil } @@ -65,23 +58,24 @@ func NewStartCmd(accessToken *string, authenticator auth.IAuthenticator, baseURL return cmd } -// resolveToken prefers an explicit access token, falling back to the CLI's cached login. -// The Fortify SCA MCP server treats this value as a refresh token, so the cached -// session's RefreshToken is used rather than its short-lived JWT AccessToken. -// The returned error (only set when the token is empty) carries the underlying -// cause, e.g. a keyring failure vs. simply never having logged in. -func resolveToken(accessToken string, authenticator auth.IAuthenticator) (string, error) { - if token := strings.TrimSpace(accessToken); token != "" { - return token, nil +// buildServerOptions decides how the MCP server authenticates. An explicit +// --access-token/DEBRICKED_TOKEN (a PAT) is passed through as-is: the server exchanges +// it itself via /api/login_refresh, exactly as before. Otherwise this falls back to the +// cached `debricked auth login` session via a TokenFetcher callback, so the server +// always gets a currently-valid bearer JWT on demand - the OAuth refresh token itself +// is never handed over, since /api/login_refresh doesn't accept it. +func buildServerOptions(explicitToken string, authenticator auth.IAuthenticator, baseURL string) (server.Options, error) { + if token := strings.TrimSpace(explicitToken); token != "" { + return server.Options{AccessToken: token, BaseURL: baseURL, APIVersion: APIVersion}, nil } - cachedToken, err := authenticator.Token() - if err != nil { - return "", err - } - if cachedToken == nil || strings.TrimSpace(cachedToken.RefreshToken) == "" { - return "", errors.New("cached login has no refresh token") + if _, err := authenticator.Token(); err != nil { + return server.Options{}, fmt.Errorf("no access token found. Pass --access-token/DEBRICKED_TOKEN, or run `debricked auth login` first (%w)", err) } - return strings.TrimSpace(cachedToken.RefreshToken), nil + return server.Options{ + TokenFetcher: auth.NewCachedTokenFetcher(authenticator), + BaseURL: baseURL, + APIVersion: APIVersion, + }, nil } diff --git a/internal/cmd/mcp/start_test.go b/internal/cmd/mcp/start/start_test.go similarity index 79% rename from internal/cmd/mcp/start_test.go rename to internal/cmd/mcp/start/start_test.go index 28a5b37e..6ed176a6 100644 --- a/internal/cmd/mcp/start_test.go +++ b/internal/cmd/mcp/start/start_test.go @@ -1,4 +1,4 @@ -package mcp +package start import ( "context" @@ -57,9 +57,9 @@ func TestNewStartCmdFallsBackToCachedLogin(t *testing.T) { defer func() { verifyFn = originalVerify }() verifyFn = func(context.Context, server.Options) error { return nil } - var gotToken string + var gotOptions server.Options serveFn = func(_ context.Context, options server.Options, _ io.Reader, _ io.Writer) error { - gotToken = options.AccessToken + gotOptions = options return nil } @@ -74,7 +74,11 @@ func TestNewStartCmdFallsBackToCachedLogin(t *testing.T) { err := cmd.Execute() assert.NoError(t, err) - assert.Equal(t, "cached-refresh-token", gotToken) + assert.Empty(t, gotOptions.AccessToken, "cached login should never pass the OAuth refresh token as a static access token") + assert.NotNil(t, gotOptions.TokenFetcher) + fetched, err := gotOptions.TokenFetcher(context.Background()) + assert.NoError(t, err) + assert.Equal(t, "cached-jwt", fetched, "TokenFetcher should hand out the cached session's JWT, not the refresh token") } func TestNewStartCmdPropagatesBaseURL(t *testing.T) { @@ -229,53 +233,3 @@ func TestNewStartCmdNoStdout(t *testing.T) { assert.NoError(t, execErr) assert.Empty(t, out) } - -func TestResolveToken(t *testing.T) { - tests := []struct { - name string - explicit string - authenticator fakeAuthenticator - wantToken string - wantErr bool - }{ - { - name: "explicit token wins over cached login", - explicit: "explicit-token", - authenticator: fakeAuthenticator{token: &oauth2.Token{RefreshToken: "cached-refresh-token"}}, //nolint:gosec // test fixture, not a real credential - wantToken: "explicit-token", - }, - { //nolint:gosec // test fixture, not a real credential - name: "falls back to cached refresh token", - explicit: "", - authenticator: fakeAuthenticator{token: &oauth2.Token{AccessToken: "jwt", RefreshToken: "cached-refresh-token"}}, //nolint:gosec // test fixture, not a real credential - wantToken: "cached-refresh-token", - }, - { - name: "authenticator error surfaces as error", - explicit: "", - authenticator: fakeAuthenticator{err: errors.New("keyring unavailable")}, - wantToken: "", - wantErr: true, - }, - { - name: "cached login with empty refresh token errors", - explicit: "", - authenticator: fakeAuthenticator{token: &oauth2.Token{}}, - wantToken: "", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotToken, err := resolveToken(tt.explicit, tt.authenticator) - - assert.Equal(t, tt.wantToken, gotToken) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/internal/cmd/mcp/status/status.go b/internal/cmd/mcp/status/status.go new file mode 100644 index 00000000..91a15922 --- /dev/null +++ b/internal/cmd/mcp/status/status.go @@ -0,0 +1,120 @@ +package status + +import ( + "errors" + "fmt" + + "github.com/debricked/cli/internal/cmd/cmderror" + "github.com/debricked/cli/internal/io" + "github.com/debricked/cli/internal/mcpclient" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +// NewStatusCmd creates the `mcp status` command, which reports whether the debricked +// MCP server is configured for one or all supported clients, and whether it actually +// starts and authenticates. +func NewStatusCmd(fs io.IFileSystem, env mcpclient.IEnvironment) *cobra.Command { + var clientFlag string + + cmd := &cobra.Command{ + Use: "status", + Short: "Show whether the Debricked MCP server is configured and working.", + Long: `Check whether the "debricked" MCP server entry is present in supported clients' +configuration files, and perform a live MCP handshake against the exact command found +there to confirm it starts and authenticates successfully. Checks vscode, claude, and +cursor if --client is omitted.`, + RunE: func(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + + clients := mcpclient.AllClients + if clientFlag != "" { + client, err := mcpclient.ParseClient(clientFlag) + if err != nil { + return cmderror.CommandError{Code: 1, Err: err} + } + clients = []mcpclient.Client{client} + } + + ok := true + for _, client := range clients { + for _, global := range scopesFor(client) { + if !checkClientScope(cmd, fs, env, client, global) { + ok = false + } + } + } + + if !ok { + return cmderror.CommandError{Code: 1, Err: errors.New("one or more checks failed, see above")} + } + + return nil + }, + } + + cmd.Flags().StringVar(&clientFlag, "client", "", "Only check one client: vscode, claude, or cursor.") + + return cmd +} + +func scopesFor(client mcpclient.Client) []bool { + if !mcpclient.SupportsLocalScope(client) { + return []bool{true} + } + + return []bool{false, true} +} + +func scopeLabel(global bool) string { + if global { + return "global" + } + + return "project" +} + +// checkClientScope reports the config + live-handshake status for one client/scope +// combination and returns false if anything is missing or failing. +func checkClientScope(cmd *cobra.Command, fs io.IFileSystem, env mcpclient.IEnvironment, client mcpclient.Client, global bool) bool { + out := cmd.OutOrStdout() + label := fmt.Sprintf("%s (%s)", client, scopeLabel(global)) + + path, err := mcpclient.ConfigPath(client, global, env) + if err != nil { + fmt.Fprintf(out, "%s %s: %v\n", color.RedString("✘"), label, err) + + return false + } + + config, existed, err := mcpclient.ReadConfig(fs, path) + if err != nil { + fmt.Fprintf(out, "%s %s: %v\n", color.RedString("✘"), label, err) + + return false + } + + entry, ok := mcpclient.HasServerEntry(config, mcpclient.ServerMapKey(client), mcpclient.ServerEntryName) + if !existed || !ok { + fmt.Fprintf(out, "%s %s: not configured (%s)\n", color.RedString("✘"), label, path) + + return false + } + + command, args, err := mcpclient.EntryCommand(entry) + if err != nil { + fmt.Fprintf(out, "%s %s: configured at %s but entry is invalid: %v\n", color.RedString("✘"), label, path, err) + + return false + } + fmt.Fprintf(out, "%s %s: configured (%s)\n", color.GreenString("✔"), label, path) + + if err := mcpclient.CheckServer(cmd.Context(), command, args, mcpclient.DefaultHandshakeTimeout); err != nil { + fmt.Fprintf(out, " %s server check failed: %v\n", color.RedString("✘"), err) + + return false + } + fmt.Fprintf(out, " %s server responded to a live MCP handshake\n", color.GreenString("✔")) + + return true +} diff --git a/internal/cmd/mcp/status/status_test.go b/internal/cmd/mcp/status/status_test.go new file mode 100644 index 00000000..e7677285 --- /dev/null +++ b/internal/cmd/mcp/status/status_test.go @@ -0,0 +1,79 @@ +package status + +import ( + "bytes" + "os/exec" + "runtime" + "testing" + + "github.com/debricked/cli/internal/mcpclient/testdata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatusCmdInvalidClient(t *testing.T) { + cmd := NewStatusCmd(testdata.NewFileSystem(), testdata.Environment{Wd: "/proj", GOOSValue: "linux", HomeDir: "/home/test"}) + cmd.SetArgs([]string{"--client", "bogus"}) + + err := cmd.Execute() + + assert.Error(t, err) +} + +func TestStatusCmdNotConfiguredAnywhere(t *testing.T) { + var out bytes.Buffer + cmd := NewStatusCmd(testdata.NewFileSystem(), testdata.Environment{Wd: "/proj", GOOSValue: "linux", HomeDir: "/home/test"}) + cmd.SetOut(&out) + cmd.SetArgs([]string{"--client", "claude"}) + + err := cmd.Execute() + + assert.Error(t, err) + assert.Contains(t, out.String(), "not configured") +} + +func TestStatusCmdConfiguredAndWorking(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-based handshake fixtures are not supported on windows") + } + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not available") + } + + fs := testdata.NewFileSystem() + fs.Files["/home/test/.config/Claude/claude_desktop_config.json"] = []byte( + `{"mcpServers":{"debricked":{"command":"sh","args":["-c","read line; echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}'"]}}}`, + ) + var out bytes.Buffer + cmd := NewStatusCmd(fs, testdata.Environment{GOOSValue: "linux", HomeDir: "/home/test"}) + cmd.SetOut(&out) + cmd.SetArgs([]string{"--client", "claude"}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Contains(t, out.String(), "responded to a live MCP handshake") +} + +func TestStatusCmdConfiguredButFailingHandshake(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-based handshake fixtures are not supported on windows") + } + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not available") + } + + fs := testdata.NewFileSystem() + fs.Files["/home/test/.config/Claude/claude_desktop_config.json"] = []byte( + `{"mcpServers":{"debricked":{"command":"sh","args":["-c","echo \"no access token found\" 1>&2; exit 1"]}}}`, + ) + var out bytes.Buffer + cmd := NewStatusCmd(fs, testdata.Environment{GOOSValue: "linux", HomeDir: "/home/test"}) + cmd.SetOut(&out) + cmd.SetArgs([]string{"--client", "claude"}) + + err := cmd.Execute() + + assert.Error(t, err) + assert.Contains(t, out.String(), "server check failed") +} diff --git a/internal/cmd/mcp/uninstall/uninstall.go b/internal/cmd/mcp/uninstall/uninstall.go new file mode 100644 index 00000000..dc70febf --- /dev/null +++ b/internal/cmd/mcp/uninstall/uninstall.go @@ -0,0 +1,69 @@ +package uninstall + +import ( + "fmt" + + "github.com/debricked/cli/internal/cmd/cmderror" + "github.com/debricked/cli/internal/io" + "github.com/debricked/cli/internal/mcpclient" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +// NewUninstallCmd creates the `mcp uninstall` command, which removes the debricked +// MCP server entry from a supported client's config file. +func NewUninstallCmd(fs io.IFileSystem, env mcpclient.IEnvironment) *cobra.Command { + var clientFlag string + var global bool + + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Remove the Debricked MCP server from a client's configuration.", + Long: `Remove the "debricked" MCP server entry from a supported client's configuration +file (VS Code, Claude Desktop, or Cursor). The config file itself, and any other entries +in it, are left in place; a backup of the previous file is written to .bak.`, + RunE: func(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + + client, err := mcpclient.ParseClient(clientFlag) + if err != nil { + return cmderror.CommandError{Code: 1, Err: err} + } + + if global && client == mcpclient.ClientClaude { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "note: Claude Desktop only has a global configuration; --global has no effect.") + } + + path, err := mcpclient.ConfigPath(client, global, env) + if err != nil { + return cmderror.CommandError{Code: 1, Err: err} + } + + config, existed, err := mcpclient.ReadConfig(fs, path) + if err != nil { + return cmderror.CommandError{Code: 1, Err: err} + } + + if !existed || !mcpclient.RemoveServerEntry(config, mcpclient.ServerMapKey(client), mcpclient.ServerEntryName) { + fmt.Fprintf(cmd.OutOrStdout(), "%s Already not configured at %s.\n", color.GreenString("✔"), path) + + return nil + } + + if err := mcpclient.WriteConfig(fs, path, config, existed); err != nil { + return cmderror.CommandError{Code: 1, Err: err} + } + + fmt.Fprintf(cmd.OutOrStdout(), "Backed up previous config to %s%s\n", path, mcpclient.BackupSuffix) + fmt.Fprintf(cmd.OutOrStdout(), "%s Removed %s configuration from %s\n", color.GreenString("✔"), client, path) + + return nil + }, + } + + cmd.Flags().StringVar(&clientFlag, "client", "", "MCP client to remove: vscode, claude, or cursor.") + cmd.Flags().BoolVar(&global, "global", false, "Target the user-level (global) config instead of the project-level one.") + _ = cmd.MarkFlagRequired("client") + + return cmd +} diff --git a/internal/cmd/mcp/uninstall/uninstall_test.go b/internal/cmd/mcp/uninstall/uninstall_test.go new file mode 100644 index 00000000..e3e5213e --- /dev/null +++ b/internal/cmd/mcp/uninstall/uninstall_test.go @@ -0,0 +1,55 @@ +package uninstall + +import ( + "testing" + + "github.com/debricked/cli/internal/mcpclient/testdata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUninstallCmdRequiresClientFlag(t *testing.T) { + cmd := NewUninstallCmd(testdata.NewFileSystem(), testdata.Environment{Wd: "/proj"}) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + + assert.Error(t, err) +} + +func TestUninstallCmdAlreadyNotConfigured(t *testing.T) { + fs := testdata.NewFileSystem() + cmd := NewUninstallCmd(fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) + cmd.SetArgs([]string{"--client", "vscode"}) + + err := cmd.Execute() + + require.NoError(t, err) + assert.NotContains(t, fs.Files, "/proj/.vscode/mcp.json") +} + +func TestUninstallCmdRemovesEntryKeepsFile(t *testing.T) { + fs := testdata.NewFileSystem() + fs.Files["/proj/.vscode/mcp.json"] = []byte(`{"servers":{"debricked":{"command":"debricked","args":["mcp","start"]},"other":{"command":"foo"}}}`) + cmd := NewUninstallCmd(fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) + cmd.SetArgs([]string{"--client", "vscode"}) + + err := cmd.Execute() + + require.NoError(t, err) + content := string(fs.Files["/proj/.vscode/mcp.json"]) + assert.NotContains(t, content, `"debricked":{`) + assert.Contains(t, content, "other") + assert.Contains(t, fs.Files, "/proj/.vscode/mcp.json.bak") +} + +func TestUninstallCmdMalformedExistingFileAborts(t *testing.T) { + fs := testdata.NewFileSystem() + fs.Files["/proj/.vscode/mcp.json"] = []byte(`{ not json`) + cmd := NewUninstallCmd(fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) + cmd.SetArgs([]string{"--client", "vscode"}) + + err := cmd.Execute() + + assert.Error(t, err) +} diff --git a/internal/io/file_system.go b/internal/io/file_system.go index ca4584cd..229416de 100644 --- a/internal/io/file_system.go +++ b/internal/io/file_system.go @@ -24,6 +24,7 @@ type IFileSystem interface { FsReadAll(file fs.File) ([]byte, error) FsWriteFile(path string, bytes []byte, perm fs.FileMode) error Mkdir(path string, perm fs.FileMode) error + MkdirAll(path string, perm fs.FileMode) error Copy(destination io.Writer, source io.Reader) (int64, error) } @@ -73,6 +74,10 @@ func (_ FileSystem) Mkdir(name string, perm fs.FileMode) error { return os.Mkdir(name, perm) } +func (_ FileSystem) MkdirAll(path string, perm fs.FileMode) error { + return os.MkdirAll(path, perm) +} + func (_ FileSystem) RemoveAll(path string) { _ = os.RemoveAll(path) } diff --git a/internal/io/testdata/file_system_mock.go b/internal/io/testdata/file_system_mock.go index 76fe4ff5..2068e38e 100644 --- a/internal/io/testdata/file_system_mock.go +++ b/internal/io/testdata/file_system_mock.go @@ -24,6 +24,7 @@ type FileSystemMock struct { FsReadAllError error FsWriteFileError error MkdirError error + MkdirAllError error CopyError error CopySize int64 } @@ -71,6 +72,10 @@ func (fsm FileSystemMock) Mkdir(name string, perm fs.FileMode) error { return fsm.MkdirError } +func (fsm FileSystemMock) MkdirAll(path string, perm fs.FileMode) error { + return fsm.MkdirAllError +} + func (fsm FileSystemMock) RemoveAll(path string) { } diff --git a/internal/mcpclient/client.go b/internal/mcpclient/client.go new file mode 100644 index 00000000..ee6e35d2 --- /dev/null +++ b/internal/mcpclient/client.go @@ -0,0 +1,175 @@ +package mcpclient + +import ( + "fmt" + "path/filepath" + "strings" + + internalOs "github.com/debricked/cli/internal/runtime/os" +) + +// Client identifies a supported MCP host application. +type Client string + +const ( + ClientVSCode Client = "vscode" + ClientClaude Client = "claude" + ClientCursor Client = "cursor" + + // ServerEntryName is the fixed key used for the debricked entry in a client's + // server map, e.g. servers.debricked / mcpServers.debricked. + ServerEntryName = "debricked" +) + +// AllClients lists every supported client, in a stable order used by `mcp status` +// when no --client flag is given. +var AllClients = []Client{ClientVSCode, ClientClaude, ClientCursor} + +const ( + macOS = "darwin" + winOS = "windows" +) + +// ParseClient validates a --client flag value, case-insensitively. +func ParseClient(value string) (Client, error) { + switch Client(strings.ToLower(strings.TrimSpace(value))) { + case ClientVSCode: + return ClientVSCode, nil + case ClientClaude: + return ClientClaude, nil + case ClientCursor: + return ClientCursor, nil + default: + return "", fmt.Errorf("unsupported client %q, must be one of: vscode, claude, cursor", value) + } +} + +// ServerMapKey returns the top-level JSON key holding the server map for a given client. +func ServerMapKey(client Client) string { + if client == ClientVSCode { + return "servers" + } + + return "mcpServers" // claude, cursor +} + +// SupportsLocalScope reports whether a client has a project-level config location. +// Claude Desktop only has a global config. +func SupportsLocalScope(client Client) bool { + return client != ClientClaude +} + +// ConfigPath resolves the config file path for a client/scope combination. +func ConfigPath(client Client, global bool, env IEnvironment) (string, error) { + if client == ClientClaude || global { + return globalConfigPath(client, env) + } + + return localConfigPath(client, env) +} + +func localConfigPath(client Client, env IEnvironment) (string, error) { + wd, err := env.Getwd() + if err != nil { + return "", fmt.Errorf("failed to resolve current directory: %w", err) + } + + var dir string + switch client { + case ClientVSCode: + dir = ".vscode" + case ClientCursor: + dir = ".cursor" + default: + return "", fmt.Errorf("client %q has no project-level config", client) + } + + return filepath.Join(wd, dir, "mcp.json"), nil +} + +func globalConfigPath(client Client, env IEnvironment) (string, error) { + switch client { + case ClientClaude: + return claudeGlobalPath(env) + case ClientVSCode: + return vscodeGlobalPath(env) + case ClientCursor: + home, err := env.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to resolve home directory: %w", err) + } + + return JoinPath(env.GOOS(), home, ".cursor", "mcp.json"), nil + default: + return "", fmt.Errorf("unsupported client %q", client) + } +} + +func claudeGlobalPath(env IEnvironment) (string, error) { + goos := env.GOOS() + switch goos { + case macOS: + home, err := env.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to resolve home directory: %w", err) + } + + return JoinPath(goos, home, "Library", "Application Support", "Claude", "claude_desktop_config.json"), nil + case winOS: + appData := env.Getenv("APPDATA") + if appData == "" { + return "", fmt.Errorf("%%APPDATA%% is not set") + } + + return JoinPath(goos, appData, "Claude", "claude_desktop_config.json"), nil + default: + // Claude Desktop doesn't officially ship on Linux; this mirrors the community-maintained location. + home, err := env.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to resolve home directory: %w", err) + } + + return JoinPath(goos, home, ".config", "Claude", "claude_desktop_config.json"), nil + } +} + +func vscodeGlobalPath(env IEnvironment) (string, error) { + goos := env.GOOS() + switch goos { + case macOS: + home, err := env.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to resolve home directory: %w", err) + } + + return JoinPath(goos, home, "Library", "Application Support", "Code", "User", "mcp.json"), nil + case winOS: + appData := env.Getenv("APPDATA") + if appData == "" { + return "", fmt.Errorf("%%APPDATA%% is not set") + } + + return JoinPath(goos, appData, "Code", "User", "mcp.json"), nil + default: + if xdg := env.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return JoinPath(goos, xdg, "Code", "User", "mcp.json"), nil + } + home, err := env.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to resolve home directory: %w", err) + } + + return JoinPath(goos, home, ".config", "Code", "User", "mcp.json"), nil + } +} + +// JoinPath joins path segments using the separator appropriate for goos, so path +// construction can be tested for a given target OS regardless of the host test OS. +func JoinPath(goos string, parts ...string) string { + sep := "/" + if goos == internalOs.Windows { + sep = "\\" + } + + return strings.Join(parts, sep) +} diff --git a/internal/mcpclient/client_test.go b/internal/mcpclient/client_test.go new file mode 100644 index 00000000..8d5467ea --- /dev/null +++ b/internal/mcpclient/client_test.go @@ -0,0 +1,154 @@ +package mcpclient_test + +import ( + "testing" + + "github.com/debricked/cli/internal/mcpclient" + "github.com/debricked/cli/internal/mcpclient/testdata" + "github.com/stretchr/testify/assert" +) + +func TestParseClient(t *testing.T) { + tests := []struct { + input string + want mcpclient.Client + wantErr bool + }{ + {"vscode", mcpclient.ClientVSCode, false}, + {"VSCode", mcpclient.ClientVSCode, false}, + {" cursor ", mcpclient.ClientCursor, false}, + {"CLAUDE", mcpclient.ClientClaude, false}, + {"windsurf", "", true}, + {"", "", true}, + } + + for _, tt := range tests { + got, err := mcpclient.ParseClient(tt.input) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + } + } +} + +func TestServerMapKey(t *testing.T) { + assert.Equal(t, "servers", mcpclient.ServerMapKey(mcpclient.ClientVSCode)) + assert.Equal(t, "mcpServers", mcpclient.ServerMapKey(mcpclient.ClientClaude)) + assert.Equal(t, "mcpServers", mcpclient.ServerMapKey(mcpclient.ClientCursor)) +} + +func TestSupportsLocalScope(t *testing.T) { + assert.True(t, mcpclient.SupportsLocalScope(mcpclient.ClientVSCode)) + assert.True(t, mcpclient.SupportsLocalScope(mcpclient.ClientCursor)) + assert.False(t, mcpclient.SupportsLocalScope(mcpclient.ClientClaude)) +} + +func TestConfigPathLocal(t *testing.T) { + env := testdata.Environment{Wd: "/home/user/project", GOOSValue: "linux"} + + path, err := mcpclient.ConfigPath(mcpclient.ClientVSCode, false, env) + assert.NoError(t, err) + assert.Equal(t, "/home/user/project/.vscode/mcp.json", path) + + path, err = mcpclient.ConfigPath(mcpclient.ClientCursor, false, env) + assert.NoError(t, err) + assert.Equal(t, "/home/user/project/.cursor/mcp.json", path) +} + +func TestConfigPathClaudeAlwaysGlobal(t *testing.T) { + env := testdata.Environment{Wd: "/home/user/project", GOOSValue: "darwin", HomeDir: "/Users/test"} + + // global=false must still resolve to the global path for claude. + path, err := mcpclient.ConfigPath(mcpclient.ClientClaude, false, env) + assert.NoError(t, err) + assert.Equal(t, "/Users/test/Library/Application Support/Claude/claude_desktop_config.json", path) +} + +func TestConfigPathGlobalPerOS(t *testing.T) { + tests := []struct { + name string + client mcpclient.Client + env testdata.Environment + want string + }{ + { + name: "vscode mac", + client: mcpclient.ClientVSCode, + env: testdata.Environment{GOOSValue: "darwin", HomeDir: "/Users/test"}, + want: "/Users/test/Library/Application Support/Code/User/mcp.json", + }, + { + name: "vscode windows", + client: mcpclient.ClientVSCode, + env: testdata.Environment{GOOSValue: "windows", Env: map[string]string{"APPDATA": `C:\Users\test\AppData\Roaming`}}, + want: `C:\Users\test\AppData\Roaming\Code\User\mcp.json`, + }, + { + name: "vscode linux with XDG_CONFIG_HOME", + client: mcpclient.ClientVSCode, + env: testdata.Environment{GOOSValue: "linux", Env: map[string]string{"XDG_CONFIG_HOME": "/home/test/.config"}}, + want: "/home/test/.config/Code/User/mcp.json", + }, + { + name: "vscode linux fallback to home", + client: mcpclient.ClientVSCode, + env: testdata.Environment{GOOSValue: "linux", HomeDir: "/home/test"}, + want: "/home/test/.config/Code/User/mcp.json", + }, + { + name: "claude mac", + client: mcpclient.ClientClaude, + env: testdata.Environment{GOOSValue: "darwin", HomeDir: "/Users/test"}, + want: "/Users/test/Library/Application Support/Claude/claude_desktop_config.json", + }, + { + name: "claude windows", + client: mcpclient.ClientClaude, + env: testdata.Environment{GOOSValue: "windows", Env: map[string]string{"APPDATA": `C:\Users\test\AppData\Roaming`}}, + want: `C:\Users\test\AppData\Roaming\Claude\claude_desktop_config.json`, + }, + { + name: "claude linux best-effort", + client: mcpclient.ClientClaude, + env: testdata.Environment{GOOSValue: "linux", HomeDir: "/home/test"}, + want: "/home/test/.config/Claude/claude_desktop_config.json", + }, + { + name: "cursor mac", + client: mcpclient.ClientCursor, + env: testdata.Environment{GOOSValue: "darwin", HomeDir: "/Users/test"}, + want: "/Users/test/.cursor/mcp.json", + }, + { + name: "cursor windows", + client: mcpclient.ClientCursor, + env: testdata.Environment{GOOSValue: "windows", HomeDir: `C:\Users\test`}, + want: `C:\Users\test\.cursor\mcp.json`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, err := mcpclient.ConfigPath(tt.client, true, tt.env) + assert.NoError(t, err) + assert.Equal(t, tt.want, path) + }) + } +} + +func TestConfigPathMissingAppData(t *testing.T) { + env := testdata.Environment{GOOSValue: "windows"} + + _, err := mcpclient.ConfigPath(mcpclient.ClientVSCode, true, env) + assert.Error(t, err) + + _, err = mcpclient.ConfigPath(mcpclient.ClientClaude, true, env) + assert.Error(t, err) +} + +func TestJoinPathWindows(t *testing.T) { + assert.Equal(t, `a\b\c`, mcpclient.JoinPath("windows", "a", "b", "c")) + assert.Equal(t, "a/b/c", mcpclient.JoinPath("linux", "a", "b", "c")) +} diff --git a/internal/mcpclient/config_file.go b/internal/mcpclient/config_file.go new file mode 100644 index 00000000..bd190878 --- /dev/null +++ b/internal/mcpclient/config_file.go @@ -0,0 +1,170 @@ +package mcpclient + +import ( + "encoding/json" + "fmt" + "path/filepath" + + "github.com/debricked/cli/internal/io" +) + +// BackupSuffix is appended to a config file's path to name its pre-modification backup. +const BackupSuffix = ".bak" + +// ReadConfig reads and parses an existing client config file as a generic JSON object, +// preserving any keys this package doesn't understand. Returns existed=false (with an +// empty config) if the file doesn't exist yet. Malformed JSON is a hard error: this +// package never attempts to recover/rewrite a file it can't fully understand. +func ReadConfig(fs io.IFileSystem, path string) (config map[string]any, existed bool, err error) { + raw, err := fs.ReadFile(path) + if err != nil { + if fs.IsNotExist(err) { + return map[string]any{}, false, nil + } + + return nil, false, fmt.Errorf("failed to read %s: %w", path, err) + } + + config = map[string]any{} + if len(raw) > 0 { + if err := json.Unmarshal(raw, &config); err != nil { + return nil, false, fmt.Errorf("%s is not valid JSON, please fix or remove it manually: %w", path, err) + } + } + + return config, true, nil +} + +// WriteConfig marshals config as indented JSON and writes it to path, creating parent +// directories as needed. If existed is true, the previous file contents are backed up +// to path+".bak" (overwriting any previous backup) before the new content is written. +func WriteConfig(fs io.IFileSystem, path string, config map[string]any, existed bool) error { + if existed { + if err := backupConfig(fs, path); err != nil { + return err + } + } + + if err := fs.MkdirAll(filepath.Dir(path), 0750); err != nil { + return fmt.Errorf("failed to create directory for %s: %w", path, err) + } + + raw, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to encode %s: %w", path, err) + } + raw = append(raw, '\n') + + if err := fs.FsWriteFile(path, raw, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", path, err) + } + + return nil +} + +func backupConfig(fs io.IFileSystem, path string) error { + previous, err := fs.ReadFile(path) + if err != nil { + if fs.IsNotExist(err) { + return nil + } + + return fmt.Errorf("failed to read %s for backup: %w", path, err) + } + + if err := fs.FsWriteFile(path+BackupSuffix, previous, 0644); err != nil { + return fmt.Errorf("failed to back up %s: %w", path, err) + } + + return nil +} + +// HasServerEntry returns the existing entry map for name under mapKey, if any. +func HasServerEntry(config map[string]any, mapKey, name string) (map[string]any, bool) { + servers, ok := config[mapKey].(map[string]any) + if !ok { + return nil, false + } + entry, ok := servers[name].(map[string]any) + + return entry, ok +} + +// UpsertServerEntry merges fields into the named entry under mapKey, creating the +// entry and/or the servers map as needed. Fields already present on the entry that +// aren't part of fields (e.g. user-added env/cwd) are left untouched. Returns +// changed=false if every field already matched, so callers can skip an unnecessary write. +func UpsertServerEntry(config map[string]any, mapKey, name string, fields map[string]any) bool { + servers, ok := config[mapKey].(map[string]any) + if !ok { + servers = map[string]any{} + } + + entry, ok := servers[name].(map[string]any) + if !ok { + entry = map[string]any{} + } + + changed := false + for key, value := range fields { + if existing, present := entry[key]; !present || !valuesEqual(existing, value) { + entry[key] = value + changed = true + } + } + + servers[name] = entry + config[mapKey] = servers + + return changed +} + +// RemoveServerEntry deletes the named entry under mapKey, if present. Returns +// removed=false if there was nothing to remove. +func RemoveServerEntry(config map[string]any, mapKey, name string) bool { + servers, ok := config[mapKey].(map[string]any) + if !ok { + return false + } + if _, present := servers[name]; !present { + return false + } + + delete(servers, name) + config[mapKey] = servers + + return true +} + +// valuesEqual compares two JSON-compatible values by their marshaled form, since a +// value read back from JSON (e.g. []any{"a","b"}) won't be reflect.DeepEqual to the +// Go-native value we're comparing it against (e.g. []string{"a","b"}). +func valuesEqual(a, b any) bool { + aj, aErr := json.Marshal(a) + bj, bErr := json.Marshal(b) + if aErr != nil || bErr != nil { + return false + } + + return string(aj) == string(bj) +} + +// EntryCommand extracts the command/args a config entry would actually invoke. +func EntryCommand(entry map[string]any) (string, []string, error) { + command, ok := entry["command"].(string) + if !ok || command == "" { + return "", nil, fmt.Errorf("missing or invalid \"command\"") + } + + rawArgs, _ := entry["args"].([]any) + args := make([]string, 0, len(rawArgs)) + for _, rawArg := range rawArgs { + arg, ok := rawArg.(string) + if !ok { + return "", nil, fmt.Errorf("\"args\" must be a list of strings") + } + args = append(args, arg) + } + + return command, args, nil +} diff --git a/internal/mcpclient/config_file_test.go b/internal/mcpclient/config_file_test.go new file mode 100644 index 00000000..e97b62f6 --- /dev/null +++ b/internal/mcpclient/config_file_test.go @@ -0,0 +1,147 @@ +package mcpclient_test + +import ( + "testing" + + "github.com/debricked/cli/internal/mcpclient" + "github.com/debricked/cli/internal/mcpclient/testdata" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadConfigMissingFile(t *testing.T) { + fs := testdata.NewFileSystem() + + config, existed, err := mcpclient.ReadConfig(fs, "/tmp/mcp.json") + + require.NoError(t, err) + assert.False(t, existed) + assert.Empty(t, config) +} + +func TestReadConfigMalformedJSON(t *testing.T) { + fs := testdata.NewFileSystem() + fs.Files["/tmp/mcp.json"] = []byte(`{ not valid json`) + + _, _, err := mcpclient.ReadConfig(fs, "/tmp/mcp.json") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "/tmp/mcp.json") +} + +func TestReadConfigPreservesUnrelatedKeys(t *testing.T) { + fs := testdata.NewFileSystem() + fs.Files["/tmp/mcp.json"] = []byte(`{"servers":{"other":{"command":"foo"}},"inputs":[1,2,3]}`) + + config, existed, err := mcpclient.ReadConfig(fs, "/tmp/mcp.json") + + require.NoError(t, err) + assert.True(t, existed) + assert.Contains(t, config, "inputs") + assert.Contains(t, config, "servers") +} + +func TestWriteConfigCreatesFileNoBackupWhenNew(t *testing.T) { + fs := testdata.NewFileSystem() + path := "/tmp/proj/.vscode/mcp.json" + + err := mcpclient.WriteConfig(fs, path, map[string]any{"servers": map[string]any{}}, false) + + require.NoError(t, err) + assert.Contains(t, fs.Files, path) + assert.NotContains(t, fs.Files, path+mcpclient.BackupSuffix) +} + +func TestWriteConfigBacksUpExistingFile(t *testing.T) { + fs := testdata.NewFileSystem() + path := "/tmp/proj/.vscode/mcp.json" + fs.Files[path] = []byte(`{"servers":{}}`) + + err := mcpclient.WriteConfig(fs, path, map[string]any{"servers": map[string]any{"debricked": map[string]any{}}}, true) + + require.NoError(t, err) + assert.Equal(t, []byte(`{"servers":{}}`), fs.Files[path+mcpclient.BackupSuffix]) + assert.NotEqual(t, []byte(`{"servers":{}}`), fs.Files[path]) +} + +func TestUpsertServerEntryCreatesEntry(t *testing.T) { + config := map[string]any{} + + changed := mcpclient.UpsertServerEntry(config, "servers", "debricked", map[string]any{ + "command": "debricked", + "args": []string{"mcp", "start"}, + }) + + assert.True(t, changed) + entry, ok := mcpclient.HasServerEntry(config, "servers", "debricked") + require.True(t, ok) + assert.Equal(t, "debricked", entry["command"]) +} + +func TestUpsertServerEntryPreservesOtherServersAndFields(t *testing.T) { + config := map[string]any{ + "servers": map[string]any{ + "other": map[string]any{"command": "other-cmd"}, + "debricked": map[string]any{ + "command": "old-path", + "args": []string{"mcp", "start"}, + "env": map[string]any{"FOO": "bar"}, + }, + }, + } + + changed := mcpclient.UpsertServerEntry(config, "servers", "debricked", map[string]any{ + "command": "debricked", + "args": []string{"mcp", "start"}, + }) + + assert.True(t, changed) + servers := config["servers"].(map[string]any) + assert.Contains(t, servers, "other") + entry := servers["debricked"].(map[string]any) + assert.Equal(t, "debricked", entry["command"]) + assert.Equal(t, map[string]any{"FOO": "bar"}, entry["env"]) +} + +func TestUpsertServerEntryIdempotent(t *testing.T) { + config := map[string]any{} + fields := map[string]any{"command": "debricked", "args": []string{"mcp", "start"}} + + changed := mcpclient.UpsertServerEntry(config, "servers", "debricked", fields) + assert.True(t, changed) + + changed = mcpclient.UpsertServerEntry(config, "servers", "debricked", fields) + assert.False(t, changed) +} + +func TestRemoveServerEntry(t *testing.T) { + config := map[string]any{ + "mcpServers": map[string]any{ + "debricked": map[string]any{"command": "debricked"}, + "other": map[string]any{"command": "other"}, + }, + } + + removed := mcpclient.RemoveServerEntry(config, "mcpServers", "debricked") + assert.True(t, removed) + + _, ok := mcpclient.HasServerEntry(config, "mcpServers", "debricked") + assert.False(t, ok) + _, ok = mcpclient.HasServerEntry(config, "mcpServers", "other") + assert.True(t, ok) +} + +func TestRemoveServerEntryMissing(t *testing.T) { + config := map[string]any{} + + removed := mcpclient.RemoveServerEntry(config, "mcpServers", "debricked") + assert.False(t, removed) +} + +func TestEntryCommandInvalid(t *testing.T) { + _, _, err := mcpclient.EntryCommand(map[string]any{}) + assert.Error(t, err) + + _, _, err = mcpclient.EntryCommand(map[string]any{"command": "debricked", "args": []any{1, 2}}) + assert.Error(t, err) +} diff --git a/internal/mcpclient/environment.go b/internal/mcpclient/environment.go new file mode 100644 index 00000000..ccf6a63b --- /dev/null +++ b/internal/mcpclient/environment.go @@ -0,0 +1,44 @@ +package mcpclient + +import ( + "os" + "runtime" +) + +// IEnvironment abstracts OS/environment lookups so client config path resolution is testable +// across platforms without mutating real env vars or touching the real home directory. +type IEnvironment interface { + Getenv(key string) string + UserHomeDir() (string, error) + Getwd() (string, error) + GOOS() string + Executable() (string, error) +} + +// osEnvironment is the real IEnvironment implementation, backed by the os/runtime packages. +type osEnvironment struct{} + +// NewEnvironment creates the real, OS-backed IEnvironment implementation. +func NewEnvironment() IEnvironment { + return osEnvironment{} +} + +func (osEnvironment) Getenv(key string) string { + return os.Getenv(key) +} + +func (osEnvironment) UserHomeDir() (string, error) { + return os.UserHomeDir() +} + +func (osEnvironment) Getwd() (string, error) { + return os.Getwd() +} + +func (osEnvironment) GOOS() string { + return runtime.GOOS +} + +func (osEnvironment) Executable() (string, error) { + return os.Executable() +} diff --git a/internal/mcpclient/handshake.go b/internal/mcpclient/handshake.go new file mode 100644 index 00000000..707cf746 --- /dev/null +++ b/internal/mcpclient/handshake.go @@ -0,0 +1,134 @@ +package mcpclient + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +// DefaultHandshakeTimeout bounds how long `mcp status` waits for a configured MCP +// server to respond to a handshake before giving up. +const DefaultHandshakeTimeout = 5 * time.Second + +type jsonrpcResponse struct { + ID json.RawMessage `json:"id"` + Result json.RawMessage `json:"result"` + Error *jsonrpcError `json:"error"` +} + +type jsonrpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// initializeRequest is a minimal MCP "initialize" JSON-RPC request, sufficient to +// verify that a server starts, authenticates, and speaks MCP over stdio. +func initializeRequest() []byte { + return []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"debricked-cli-status-check","version":"1.0.0"}}}`) +} + +// CheckServer spawns command with args exactly as an MCP client would, sends a +// minimal initialize request over stdin, and waits up to timeout for a valid +// JSON-RPC response on stdout. This proves PATH resolution, the binary, and +// authentication all work end-to-end - not just that credentials look valid +// in the current process. +func CheckServer(ctx context.Context, command string, args []string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, command, args...) // #nosec G204 -- command/args come from the user's own MCP client config, not external input + + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to open stdin: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to open stdout: %w", err) + } + var stderr strings.Builder + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return fmt.Errorf("%q was not found on PATH", command) + } + + return fmt.Errorf("failed to start %q: %w", command, err) + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + response := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var resp jsonrpcResponse + if err := json.Unmarshal([]byte(line), &resp); err != nil { + response <- fmt.Errorf("received a malformed response: %w", err) + + return + } + if resp.Error != nil { + response <- fmt.Errorf("server returned an error: %s", resp.Error.Message) + + return + } + response <- nil + + return + } + response <- errors.New("server closed its output before responding") + }() + + if _, err := stdin.Write(append(initializeRequest(), '\n')); err != nil { + _ = cmd.Process.Kill() + <-done + + return classifyFailure(stderr.String(), fmt.Errorf("failed to send handshake request: %w", err)) + } + + var result error + select { + case <-ctx.Done(): + result = errors.New("timed out waiting for a response") + case result = <-response: + } + + _ = cmd.Process.Kill() + <-done + + if result != nil { + return classifyFailure(stderr.String(), result) + } + + return nil +} + +// classifyFailure turns a raw failure plus captured stderr into an actionable message, +// recognizing the specific errors `debricked mcp start` (see start.go) prints. +func classifyFailure(stderrOutput string, cause error) error { + stderrOutput = strings.TrimSpace(stderrOutput) + switch { + case strings.Contains(stderrOutput, "no access token found"): + return errors.New("no access token found; run `debricked auth login` or pass --access-token") + case strings.Contains(stderrOutput, "access token rejected"): + return fmt.Errorf("access token rejected: %s", stderrOutput) + case stderrOutput != "": + return fmt.Errorf("%w (stderr: %s)", cause, stderrOutput) + default: + return cause + } +} diff --git a/internal/mcpclient/handshake_test.go b/internal/mcpclient/handshake_test.go new file mode 100644 index 00000000..ec9618ad --- /dev/null +++ b/internal/mcpclient/handshake_test.go @@ -0,0 +1,82 @@ +package mcpclient_test + +import ( + "context" + "os/exec" + "runtime" + "testing" + "time" + + "github.com/debricked/cli/internal/mcpclient" + "github.com/stretchr/testify/assert" +) + +func requireShell(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shell-based handshake fixtures are not supported on windows") + } + shell, err := exec.LookPath("sh") + if err != nil { + t.Skip("sh not available") + } + + return shell +} + +func TestCheckServerSuccess(t *testing.T) { + shell := requireShell(t) + + script := `read line; echo '{"jsonrpc":"2.0","id":1,"result":{}}'` + err := mcpclient.CheckServer(context.Background(), shell, []string{"-c", script}, time.Second) + + assert.NoError(t, err) +} + +func TestCheckServerJSONRPCError(t *testing.T) { + shell := requireShell(t) + + script := `read line; echo '{"jsonrpc":"2.0","id":1,"error":{"code":-1,"message":"boom"}}'` + err := mcpclient.CheckServer(context.Background(), shell, []string{"-c", script}, time.Second) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "boom") +} + +func TestCheckServerAuthRejected(t *testing.T) { + shell := requireShell(t) + + script := `echo "access token rejected: bad token" 1>&2; exit 1` + err := mcpclient.CheckServer(context.Background(), shell, []string{"-c", script}, time.Second) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "access token rejected") +} + +func TestCheckServerNoAccessToken(t *testing.T) { + shell := requireShell(t) + + script := `echo "no access token found. Pass --access-token" 1>&2; exit 1` + err := mcpclient.CheckServer(context.Background(), shell, []string{"-c", script}, time.Second) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "no access token found") +} + +func TestCheckServerTimeout(t *testing.T) { + shell := requireShell(t) + + // "exec" replaces the shell process itself, so killing the spawned pid actually + // stops the sleep instead of leaving an orphaned grandchild holding stdout open. + script := `exec sleep 5` + err := mcpclient.CheckServer(context.Background(), shell, []string{"-c", script}, 100*time.Millisecond) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "timed out") +} + +func TestCheckServerCommandNotFound(t *testing.T) { + err := mcpclient.CheckServer(context.Background(), "debricked-does-not-exist-binary", nil, time.Second) + + assert.Error(t, err) +} diff --git a/internal/mcpclient/testdata/fake_environment.go b/internal/mcpclient/testdata/fake_environment.go new file mode 100644 index 00000000..7fbe8643 --- /dev/null +++ b/internal/mcpclient/testdata/fake_environment.go @@ -0,0 +1,39 @@ +package testdata + +import ( + "github.com/debricked/cli/internal/mcpclient" +) + +// Environment is a controllable mcpclient.IEnvironment test double. +type Environment struct { + Env map[string]string + HomeDir string + HomeDirErr error + Wd string + WdErr error + GOOSValue string + ExecutablePath string + ExecErr error +} + +var _ mcpclient.IEnvironment = Environment{} + +func (f Environment) Getenv(key string) string { + return f.Env[key] +} + +func (f Environment) UserHomeDir() (string, error) { + return f.HomeDir, f.HomeDirErr +} + +func (f Environment) Getwd() (string, error) { + return f.Wd, f.WdErr +} + +func (f Environment) GOOS() string { + return f.GOOSValue +} + +func (f Environment) Executable() (string, error) { + return f.ExecutablePath, f.ExecErr +} diff --git a/internal/mcpclient/testdata/fake_filesystem.go b/internal/mcpclient/testdata/fake_filesystem.go new file mode 100644 index 00000000..025580b5 --- /dev/null +++ b/internal/mcpclient/testdata/fake_filesystem.go @@ -0,0 +1,63 @@ +package testdata + +import ( + "embed" + "io" + "io/fs" + "os" + + debio "github.com/debricked/cli/internal/io" +) + +// FileSystem is an in-memory debio.IFileSystem test double, keyed by path, giving +// mcpclient tests realistic read/write/exists behavior for config files. +type FileSystem struct { + Files map[string][]byte +} + +var _ debio.IFileSystem = (*FileSystem)(nil) + +func NewFileSystem() *FileSystem { + return &FileSystem{Files: map[string][]byte{}} +} + +func (m *FileSystem) ReadFile(path string) ([]byte, error) { + content, ok := m.Files[path] + if !ok { + return nil, os.ErrNotExist + } + + return content, nil +} + +func (m *FileSystem) IsNotExist(err error) bool { + return os.IsNotExist(err) +} + +func (m *FileSystem) FsWriteFile(path string, bytes []byte, _ fs.FileMode) error { + m.Files[path] = append([]byte(nil), bytes...) + + return nil +} + +func (m *FileSystem) MkdirAll(_ string, _ fs.FileMode) error { + return nil +} + +func (m *FileSystem) Mkdir(_ string, _ fs.FileMode) error { + return nil +} + +func (m *FileSystem) Open(_ string) (*os.File, error) { return nil, nil } +func (m *FileSystem) Create(_ string) (*os.File, error) { return nil, nil } +func (m *FileSystem) Stat(_ string) (os.FileInfo, error) { return nil, nil } +func (m *FileSystem) Remove(_ string) error { return nil } +func (m *FileSystem) StatFile(_ *os.File) (os.FileInfo, error) { return nil, nil } +func (m *FileSystem) CloseFile(_ *os.File) {} +func (m *FileSystem) WriteToWriter(_ io.Writer, _ []byte) (int, error) { return 0, nil } +func (m *FileSystem) MkdirTemp(_ string) (string, error) { return "", nil } +func (m *FileSystem) RemoveAll(_ string) {} +func (m *FileSystem) FsOpenEmbed(_ embed.FS, _ string) (fs.File, error) { return nil, nil } +func (m *FileSystem) FsCloseFile(_ fs.File) {} +func (m *FileSystem) FsReadAll(_ fs.File) ([]byte, error) { return nil, nil } +func (m *FileSystem) Copy(_ io.Writer, _ io.Reader) (int64, error) { return 0, nil } From b26dda5b81b900916ebba95bd7d451cd86b3c2b8 Mon Sep 17 00:00:00 2001 From: Rajpreet Singh Date: Tue, 29 Sep 2026 15:58:20 +0530 Subject: [PATCH 2/3] =?UTF-8?q?chore(mcp):=20ignore=20unhandled=20fmt.Fpri?= =?UTF-8?q?ntf=20errors=20and=20bump=20fortify-sca-mcp=20=E2=81=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- go.sum | 4 +- internal/cmd/mcp/setup/setup.go | 12 +-- internal/cmd/mcp/status/status.go | 14 +-- internal/cmd/mcp/uninstall/uninstall.go | 6 +- internal/mcpclient/handshake.go | 119 ++++++++++++++++++------ 6 files changed, 111 insertions(+), 46 deletions(-) diff --git a/go.mod b/go.mod index 7cf94551..433ddc4a 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/becheran/wildmatch-go v1.0.0 github.com/bmatcuk/doublestar/v4 v4.6.0 github.com/chelnak/ysmrr v0.2.1 - github.com/debricked/fortify-sca-mcp/v26 v26.3.2 + github.com/debricked/fortify-sca-mcp/v26 v26.3.3 github.com/fatih/color v1.16.0 github.com/go-git/go-billy/v5 v5.5.0 github.com/go-git/go-git/v5 v5.11.0 diff --git a/go.sum b/go.sum index 2175b038..698518ae 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/debricked/fortify-sca-mcp/v26 v26.3.2 h1:33vsbzmTT9Pfs+QQohwOCyCPOcA49X87ryUeK06DpbQ= -github.com/debricked/fortify-sca-mcp/v26 v26.3.2/go.mod h1:CK+FgE9i0E2YS0WOljHDuFVZdE3GQrggoqDuF9C+CYw= +github.com/debricked/fortify-sca-mcp/v26 v26.3.3 h1:j1uWNgwbfJE2/fRHT0rmWq7tnVwXJ41Ac5nmvrD21s0= +github.com/debricked/fortify-sca-mcp/v26 v26.3.3/go.mod h1:CK+FgE9i0E2YS0WOljHDuFVZdE3GQrggoqDuF9C+CYw= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= diff --git a/internal/cmd/mcp/setup/setup.go b/internal/cmd/mcp/setup/setup.go index b2a3ea9d..466049ba 100644 --- a/internal/cmd/mcp/setup/setup.go +++ b/internal/cmd/mcp/setup/setup.go @@ -72,7 +72,7 @@ so the server can authenticate when your MCP client starts it.`, changed := mcpclient.UpsertServerEntry(config, mcpclient.ServerMapKey(client), mcpclient.ServerEntryName, fields) if !changed { - fmt.Fprintf(cmd.OutOrStdout(), "%s Already configured at %s, no changes made.\n", color.GreenString("✔"), path) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Already configured at %s, no changes made.\n", color.GreenString("✔"), path) return nil } @@ -82,12 +82,12 @@ so the server can authenticate when your MCP client starts it.`, } if existed { - fmt.Fprintf(cmd.OutOrStdout(), "Backed up previous config to %s%s\n", path, mcpclient.BackupSuffix) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Backed up previous config to %s%s\n", path, mcpclient.BackupSuffix) } if !portable { - fmt.Fprintf(cmd.ErrOrStderr(), "note: %q was not found on PATH, using the current binary's absolute path instead. This is machine-specific and won't survive a reinstall or move.\n", "debricked") + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "note: %q was not found on PATH, using the current binary's absolute path instead. This is machine-specific and won't survive a reinstall or move.\n", "debricked") } - fmt.Fprintf(cmd.OutOrStdout(), "%s Configured %s at %s\n", color.GreenString("✔"), client, path) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Configured %s at %s\n", color.GreenString("✔"), client, path) warnIfNoCredentials(cmd, accessToken, authenticator, baseURL) @@ -126,7 +126,7 @@ func warnIfNoCredentials(cmd *cobra.Command, accessToken *string, authenticator if token := strings.TrimSpace(*accessToken); token != "" { options = server.Options{AccessToken: token, BaseURL: baseURL, APIVersion: apiVersion} } else if _, err := authenticator.Token(); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "warning: no access token found; run `debricked auth login` before starting the server. (%v)\n", err) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: no access token found; run `debricked auth login` before starting the server. (%v)\n", err) return } else { @@ -134,6 +134,6 @@ func warnIfNoCredentials(cmd *cobra.Command, accessToken *string, authenticator } if err := verifyFn(cmd.Context(), options); err != nil { - fmt.Fprintf(cmd.ErrOrStderr(), "warning: access token rejected: %v\n", err) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: access token rejected: %v\n", err) } } diff --git a/internal/cmd/mcp/status/status.go b/internal/cmd/mcp/status/status.go index 91a15922..acaf1c1c 100644 --- a/internal/cmd/mcp/status/status.go +++ b/internal/cmd/mcp/status/status.go @@ -82,39 +82,39 @@ func checkClientScope(cmd *cobra.Command, fs io.IFileSystem, env mcpclient.IEnvi path, err := mcpclient.ConfigPath(client, global, env) if err != nil { - fmt.Fprintf(out, "%s %s: %v\n", color.RedString("✘"), label, err) + _, _ = fmt.Fprintf(out, "%s %s: %v\n", color.RedString("✘"), label, err) return false } config, existed, err := mcpclient.ReadConfig(fs, path) if err != nil { - fmt.Fprintf(out, "%s %s: %v\n", color.RedString("✘"), label, err) + _, _ = fmt.Fprintf(out, "%s %s: %v\n", color.RedString("✘"), label, err) return false } entry, ok := mcpclient.HasServerEntry(config, mcpclient.ServerMapKey(client), mcpclient.ServerEntryName) if !existed || !ok { - fmt.Fprintf(out, "%s %s: not configured (%s)\n", color.RedString("✘"), label, path) + _, _ = fmt.Fprintf(out, "%s %s: not configured (%s)\n", color.RedString("✘"), label, path) return false } command, args, err := mcpclient.EntryCommand(entry) if err != nil { - fmt.Fprintf(out, "%s %s: configured at %s but entry is invalid: %v\n", color.RedString("✘"), label, path, err) + _, _ = fmt.Fprintf(out, "%s %s: configured at %s but entry is invalid: %v\n", color.RedString("✘"), label, path, err) return false } - fmt.Fprintf(out, "%s %s: configured (%s)\n", color.GreenString("✔"), label, path) + _, _ = fmt.Fprintf(out, "%s %s: configured (%s)\n", color.GreenString("✔"), label, path) if err := mcpclient.CheckServer(cmd.Context(), command, args, mcpclient.DefaultHandshakeTimeout); err != nil { - fmt.Fprintf(out, " %s server check failed: %v\n", color.RedString("✘"), err) + _, _ = fmt.Fprintf(out, " %s server check failed: %v\n", color.RedString("✘"), err) return false } - fmt.Fprintf(out, " %s server responded to a live MCP handshake\n", color.GreenString("✔")) + _, _ = fmt.Fprintf(out, " %s server responded to a live MCP handshake\n", color.GreenString("✔")) return true } diff --git a/internal/cmd/mcp/uninstall/uninstall.go b/internal/cmd/mcp/uninstall/uninstall.go index dc70febf..d77a7cd2 100644 --- a/internal/cmd/mcp/uninstall/uninstall.go +++ b/internal/cmd/mcp/uninstall/uninstall.go @@ -45,7 +45,7 @@ in it, are left in place; a backup of the previous file is written to .bak } if !existed || !mcpclient.RemoveServerEntry(config, mcpclient.ServerMapKey(client), mcpclient.ServerEntryName) { - fmt.Fprintf(cmd.OutOrStdout(), "%s Already not configured at %s.\n", color.GreenString("✔"), path) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Already not configured at %s.\n", color.GreenString("✔"), path) return nil } @@ -54,8 +54,8 @@ in it, are left in place; a backup of the previous file is written to .bak return cmderror.CommandError{Code: 1, Err: err} } - fmt.Fprintf(cmd.OutOrStdout(), "Backed up previous config to %s%s\n", path, mcpclient.BackupSuffix) - fmt.Fprintf(cmd.OutOrStdout(), "%s Removed %s configuration from %s\n", color.GreenString("✔"), client, path) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Backed up previous config to %s%s\n", path, mcpclient.BackupSuffix) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Removed %s configuration from %s\n", color.GreenString("✔"), client, path) return nil }, diff --git a/internal/mcpclient/handshake.go b/internal/mcpclient/handshake.go index 707cf746..b75fca28 100644 --- a/internal/mcpclient/handshake.go +++ b/internal/mcpclient/handshake.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os/exec" "strings" "time" @@ -43,32 +44,92 @@ func CheckServer(ctx context.Context, command string, args []string, timeout tim cmd := exec.CommandContext(ctx, command, args...) // #nosec G204 -- command/args come from the user's own MCP client config, not external input + stdin, stdout, stderr, err := setupCommand(cmd) + if err != nil { + return err + } + + if err := cmd.Start(); err != nil { + return handleStartError(command, err) + } + + done := waitForCommand(cmd) + response := readResponse(stdout) + + if err := sendHandshake(stdin, cmd, done); err != nil { + return classifyFailure(stderr.String(), err) + } + + result := waitForResponse(ctx, response) + + _ = cmd.Process.Kill() + <-done + + if result != nil { + return classifyFailure(stderr.String(), result) + } + + return nil +} + +func setupCommand(cmd *exec.Cmd) (io.WriteCloser, io.ReadCloser, *strings.Builder, error) { stdin, err := cmd.StdinPipe() if err != nil { - return fmt.Errorf("failed to open stdin: %w", err) + return nil, nil, nil, fmt.Errorf("failed to open stdin: %w", err) } + stdout, err := cmd.StdoutPipe() if err != nil { - return fmt.Errorf("failed to open stdout: %w", err) + return nil, nil, nil, fmt.Errorf("failed to open stdout: %w", err) } + var stderr strings.Builder cmd.Stderr = &stderr - if err := cmd.Start(); err != nil { - if errors.Is(err, exec.ErrNotFound) { - return fmt.Errorf("%q was not found on PATH", command) - } + return stdin, stdout, &stderr, nil +} - return fmt.Errorf("failed to start %q: %w", command, err) +func handleStartError(command string, err error) error { + if errors.Is(err, exec.ErrNotFound) { + return fmt.Errorf("%q was not found on PATH", command) } + return fmt.Errorf("failed to start %q: %w", command, err) +} + +func waitForCommand(cmd *exec.Cmd) <-chan error { done := make(chan error, 1) - go func() { done <- cmd.Wait() }() + go func() { + done <- cmd.Wait() + }() + + return done +} + +func sendHandshake( + stdin io.WriteCloser, + cmd *exec.Cmd, + done <-chan error, +) error { + _, err := stdin.Write(append(initializeRequest(), '\n')) + if err == nil { + return nil + } + + _ = cmd.Process.Kill() + <-done + + return fmt.Errorf("failed to send handshake request: %w", err) +} + +func readResponse(stdout io.Reader) <-chan error { response := make(chan error, 1) + go func() { scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { @@ -81,53 +142,57 @@ func CheckServer(ctx context.Context, command string, args []string, timeout tim return } + if resp.Error != nil { - response <- fmt.Errorf("server returned an error: %s", resp.Error.Message) + response <- fmt.Errorf( + "server returned an error: %s", + resp.Error.Message, + ) return } + response <- nil return } + + if err := scanner.Err(); err != nil { + response <- fmt.Errorf("failed to read server output: %w", err) + + return + } + response <- errors.New("server closed its output before responding") }() - if _, err := stdin.Write(append(initializeRequest(), '\n')); err != nil { - _ = cmd.Process.Kill() - <-done - - return classifyFailure(stderr.String(), fmt.Errorf("failed to send handshake request: %w", err)) - } + return response +} - var result error +func waitForResponse(ctx context.Context, response <-chan error) error { select { case <-ctx.Done(): - result = errors.New("timed out waiting for a response") - case result = <-response: - } - - _ = cmd.Process.Kill() - <-done - - if result != nil { - return classifyFailure(stderr.String(), result) + return errors.New("timed out waiting for a response") + case result := <-response: + return result } - - return nil } // classifyFailure turns a raw failure plus captured stderr into an actionable message, // recognizing the specific errors `debricked mcp start` (see start.go) prints. func classifyFailure(stderrOutput string, cause error) error { stderrOutput = strings.TrimSpace(stderrOutput) + switch { case strings.Contains(stderrOutput, "no access token found"): return errors.New("no access token found; run `debricked auth login` or pass --access-token") + case strings.Contains(stderrOutput, "access token rejected"): return fmt.Errorf("access token rejected: %s", stderrOutput) + case stderrOutput != "": return fmt.Errorf("%w (stderr: %s)", cause, stderrOutput) + default: return cause } From 14d01d6516f90331e45e3405c862b2cba6f762ca Mon Sep 17 00:00:00 2001 From: Rajpreet Singh Date: Wed, 30 Sep 2026 09:42:42 +0530 Subject: [PATCH 3/3] refactor: standardize config file paths and add type field in setup --- internal/cmd/mcp/setup/setup.go | 1 + internal/cmd/mcp/setup/setup_test.go | 23 ++++++++++++-------- internal/cmd/mcp/status/status_test.go | 7 ++++-- internal/cmd/mcp/uninstall/uninstall_test.go | 13 ++++++----- internal/mcpclient/client_test.go | 5 +++-- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/internal/cmd/mcp/setup/setup.go b/internal/cmd/mcp/setup/setup.go index 466049ba..5eabbc7e 100644 --- a/internal/cmd/mcp/setup/setup.go +++ b/internal/cmd/mcp/setup/setup.go @@ -66,6 +66,7 @@ so the server can authenticate when your MCP client starts it.`, command, portable := resolveCommand(env) fields := map[string]any{ + "type": "stdio", "command": command, "args": []string{"mcp", "start"}, } diff --git a/internal/cmd/mcp/setup/setup_test.go b/internal/cmd/mcp/setup/setup_test.go index 2c82964b..d2453025 100644 --- a/internal/cmd/mcp/setup/setup_test.go +++ b/internal/cmd/mcp/setup/setup_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os/exec" + "path/filepath" "testing" "github.com/debricked/cli/internal/mcpclient/testdata" @@ -61,14 +62,16 @@ func TestSetupCmdCreatesProjectConfig(t *testing.T) { token := "tok" cmd := NewSetupCmd(&token, fakeAuthenticator{}, "https://debricked.com", fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) cmd.SetArgs([]string{"--client", "vscode"}) + configPath := filepath.Join("/proj", ".vscode", "mcp.json") err := cmd.Execute() require.NoError(t, err) - content, ok := fs.Files["/proj/.vscode/mcp.json"] + content, ok := fs.Files[configPath] require.True(t, ok) assert.Contains(t, string(content), `"debricked"`) - assert.NotContains(t, fs.Files, "/proj/.vscode/mcp.json.bak") + assert.Contains(t, string(content), `"type": "stdio"`) + assert.NotContains(t, fs.Files, configPath+".bak") } func TestSetupCmdFallsBackToAbsolutePathWhenNotOnPATH(t *testing.T) { @@ -79,11 +82,12 @@ func TestSetupCmdFallsBackToAbsolutePathWhenNotOnPATH(t *testing.T) { env := testdata.Environment{Wd: "/proj", GOOSValue: "linux", ExecutablePath: "/opt/debricked/debricked"} cmd := NewSetupCmd(&token, fakeAuthenticator{}, "https://debricked.com", fs, env) cmd.SetArgs([]string{"--client", "vscode"}) + configPath := filepath.Join("/proj", ".vscode", "mcp.json") err := cmd.Execute() require.NoError(t, err) - assert.Contains(t, string(fs.Files["/proj/.vscode/mcp.json"]), "/opt/debricked/debricked") + assert.Contains(t, string(fs.Files[configPath]), "/opt/debricked/debricked") } func TestSetupCmdIdempotent(t *testing.T) { @@ -100,14 +104,15 @@ func TestSetupCmdIdempotent(t *testing.T) { cmd2.SetArgs([]string{"--client", "vscode"}) require.NoError(t, cmd2.Execute()) - assert.NotContains(t, fs.Files, "/proj/.vscode/mcp.json.bak") + assert.NotContains(t, fs.Files, filepath.Join("/proj", ".vscode", "mcp.json")+".bak") } func TestSetupCmdBacksUpExistingFile(t *testing.T) { withStubbedVerify(t, nil) withStubbedLookPath(t, true) fs := testdata.NewFileSystem() - fs.Files["/proj/.vscode/mcp.json"] = []byte(`{"servers":{"other":{"command":"foo"}}}`) + configPath := filepath.Join("/proj", ".vscode", "mcp.json") + fs.Files[configPath] = []byte(`{"servers":{"other":{"command":"foo"}}}`) token := "tok" cmd := NewSetupCmd(&token, fakeAuthenticator{}, "https://debricked.com", fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) cmd.SetArgs([]string{"--client", "vscode"}) @@ -115,15 +120,15 @@ func TestSetupCmdBacksUpExistingFile(t *testing.T) { err := cmd.Execute() require.NoError(t, err) - assert.Contains(t, fs.Files, "/proj/.vscode/mcp.json.bak") - assert.Contains(t, string(fs.Files["/proj/.vscode/mcp.json"]), "other") + assert.Contains(t, fs.Files, configPath+".bak") + assert.Contains(t, string(fs.Files[configPath]), "other") } func TestSetupCmdMalformedExistingFileAborts(t *testing.T) { withStubbedVerify(t, nil) withStubbedLookPath(t, true) fs := testdata.NewFileSystem() - fs.Files["/proj/.vscode/mcp.json"] = []byte(`{ not json`) + fs.Files[filepath.Join("/proj", ".vscode", "mcp.json")] = []byte(`{ not json`) token := "tok" cmd := NewSetupCmd(&token, fakeAuthenticator{}, "https://debricked.com", fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) cmd.SetArgs([]string{"--client", "vscode"}) @@ -145,5 +150,5 @@ func TestSetupCmdWarnsWhenNoCredentials(t *testing.T) { // config is still written successfully even without credentials require.NoError(t, err) - assert.Contains(t, fs.Files, "/proj/.vscode/mcp.json") + assert.Contains(t, fs.Files, filepath.Join("/proj", ".vscode", "mcp.json")) } diff --git a/internal/cmd/mcp/status/status_test.go b/internal/cmd/mcp/status/status_test.go index e7677285..0d99ddca 100644 --- a/internal/cmd/mcp/status/status_test.go +++ b/internal/cmd/mcp/status/status_test.go @@ -3,6 +3,7 @@ package status import ( "bytes" "os/exec" + "path/filepath" "runtime" "testing" @@ -41,7 +42,8 @@ func TestStatusCmdConfiguredAndWorking(t *testing.T) { } fs := testdata.NewFileSystem() - fs.Files["/home/test/.config/Claude/claude_desktop_config.json"] = []byte( + configPath := filepath.Join("/home/test", ".config", "Claude", "claude_desktop_config.json") + fs.Files[configPath] = []byte( `{"mcpServers":{"debricked":{"command":"sh","args":["-c","read line; echo '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}'"]}}}`, ) var out bytes.Buffer @@ -64,7 +66,8 @@ func TestStatusCmdConfiguredButFailingHandshake(t *testing.T) { } fs := testdata.NewFileSystem() - fs.Files["/home/test/.config/Claude/claude_desktop_config.json"] = []byte( + configPath := filepath.Join("/home/test", ".config", "Claude", "claude_desktop_config.json") + fs.Files[configPath] = []byte( `{"mcpServers":{"debricked":{"command":"sh","args":["-c","echo \"no access token found\" 1>&2; exit 1"]}}}`, ) var out bytes.Buffer diff --git a/internal/cmd/mcp/uninstall/uninstall_test.go b/internal/cmd/mcp/uninstall/uninstall_test.go index e3e5213e..821f9147 100644 --- a/internal/cmd/mcp/uninstall/uninstall_test.go +++ b/internal/cmd/mcp/uninstall/uninstall_test.go @@ -1,6 +1,7 @@ package uninstall import ( + "path/filepath" "testing" "github.com/debricked/cli/internal/mcpclient/testdata" @@ -20,32 +21,34 @@ func TestUninstallCmdRequiresClientFlag(t *testing.T) { func TestUninstallCmdAlreadyNotConfigured(t *testing.T) { fs := testdata.NewFileSystem() cmd := NewUninstallCmd(fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) + configPath := filepath.Join("/proj", ".vscode", "mcp.json") cmd.SetArgs([]string{"--client", "vscode"}) err := cmd.Execute() require.NoError(t, err) - assert.NotContains(t, fs.Files, "/proj/.vscode/mcp.json") + assert.NotContains(t, fs.Files, configPath) } func TestUninstallCmdRemovesEntryKeepsFile(t *testing.T) { fs := testdata.NewFileSystem() - fs.Files["/proj/.vscode/mcp.json"] = []byte(`{"servers":{"debricked":{"command":"debricked","args":["mcp","start"]},"other":{"command":"foo"}}}`) + configPath := filepath.Join("/proj", ".vscode", "mcp.json") + fs.Files[configPath] = []byte(`{"servers":{"debricked":{"command":"debricked","args":["mcp","start"]},"other":{"command":"foo"}}}`) cmd := NewUninstallCmd(fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) cmd.SetArgs([]string{"--client", "vscode"}) err := cmd.Execute() require.NoError(t, err) - content := string(fs.Files["/proj/.vscode/mcp.json"]) + content := string(fs.Files[configPath]) assert.NotContains(t, content, `"debricked":{`) assert.Contains(t, content, "other") - assert.Contains(t, fs.Files, "/proj/.vscode/mcp.json.bak") + assert.Contains(t, fs.Files, configPath+".bak") } func TestUninstallCmdMalformedExistingFileAborts(t *testing.T) { fs := testdata.NewFileSystem() - fs.Files["/proj/.vscode/mcp.json"] = []byte(`{ not json`) + fs.Files[filepath.Join("/proj", ".vscode", "mcp.json")] = []byte(`{ not json`) cmd := NewUninstallCmd(fs, testdata.Environment{Wd: "/proj", GOOSValue: "linux"}) cmd.SetArgs([]string{"--client", "vscode"}) diff --git a/internal/mcpclient/client_test.go b/internal/mcpclient/client_test.go index 8d5467ea..f471c7a2 100644 --- a/internal/mcpclient/client_test.go +++ b/internal/mcpclient/client_test.go @@ -1,6 +1,7 @@ package mcpclient_test import ( + "path/filepath" "testing" "github.com/debricked/cli/internal/mcpclient" @@ -50,11 +51,11 @@ func TestConfigPathLocal(t *testing.T) { path, err := mcpclient.ConfigPath(mcpclient.ClientVSCode, false, env) assert.NoError(t, err) - assert.Equal(t, "/home/user/project/.vscode/mcp.json", path) + assert.Equal(t, filepath.Join("/home/user/project", ".vscode", "mcp.json"), path) path, err = mcpclient.ConfigPath(mcpclient.ClientCursor, false, env) assert.NoError(t, err) - assert.Equal(t, "/home/user/project/.cursor/mcp.json", path) + assert.Equal(t, filepath.Join("/home/user/project", ".cursor", "mcp.json"), path) } func TestConfigPathClaudeAlwaysGlobal(t *testing.T) {