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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
21 changes: 21 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package auth

import (
"context"
"errors"
"strings"

"github.com/golang-jwt/jwt"
Expand Down Expand Up @@ -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()
Expand Down
54 changes: 54 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package auth

import (
"context"
"runtime"
"testing"

Expand Down Expand Up @@ -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)
}
})
}
}
13 changes: 12 additions & 1 deletion internal/cmd/mcp/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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
}
14 changes: 7 additions & 7 deletions internal/cmd/mcp/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
17 changes: 17 additions & 0 deletions internal/cmd/mcp/setup/fake_authenticator_test.go
Original file line number Diff line number Diff line change
@@ -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
}
140 changes: 140 additions & 0 deletions internal/cmd/mcp/setup/setup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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{
"type": "stdio",
"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)
}
}
Loading
Loading