diff --git a/CLAUDE.md b/CLAUDE.md index 5d31dda2..18bef51c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ Note: Integration tests require `LOCALSTACK_AUTH_TOKEN` environment variable for - `update/` - Self-update logic: version check via GitHub API, binary/Homebrew/npm update paths, archive extraction - `log/` - Internal diagnostic logging (not for user-facing output — use `output/` for that) - `iac/` - Wrappers for third-party infrastructure as code tools, such as Terraform and CDK. + - `mcpconfig/` - Native configuration of MCP clients (Cursor, Claude Code, VS Code, ...) to launch the LocalStack MCP server. Domain logic for `lstk mcp init`. # Logging @@ -97,6 +98,18 @@ A REF is parsed by helpers in `internal/snapshot/destination.go`: `ParseDestination` (save), `ParseSource` (load), and `ParseRemovable` (remove) share pod-name validation; `ParseRemovable` rejects local paths so the CLI cannot delete local files. +# MCP Integration + +`lstk mcp init` configures installed MCP clients to launch the LocalStack MCP server (`@localstack/localstack-mcp-server`) so coding agents can drive LocalStack. Domain logic lives in `internal/mcpconfig/`; `cmd/mcp.go` is wiring + output-mode selection. `lstk mcp` is a namespace parent (matching `claude mcp`/`gemini mcp`/`codex mcp` conventions); bare `lstk mcp` prints help. + +This is a native Go reimplementation of the standalone setup wizard shipped in the `localstack-mcp-server` repo — NOT a wrapper around `npx … init`. The rationale: lstk is a self-contained Go binary that has never required Node, and the users most likely to run `lstk mcp init` (Homebrew/raw-binary installs) often have no Node. Reimplementing natively keeps that property, reuses the auth token lstk already resolves (no token prompt), and matches lstk's output/sink house style. The entry it writes is kept byte-compatible with the npm wizard's (literally named `localstack`, same `LOCALSTACK_AUTH_TOKEN` convention) so the two installers are interchangeable. + +- Defaults to **Docker mode** (`command: docker run … localstack/localstack-mcp-server`) so the lstk user needs no Node at all — neither to run init nor to run the server. `--method npx` switches to the host-Node launcher. +- Reuses the resolved auth token (`cfg.AuthToken` from env or keyring); errors early if absent. The command needs no `initConfig`/config.toml. +- Two adapter kinds in `internal/mcpconfig/clients.go`: **file-based** (Cursor, Claude Desktop, VS Code) merge a JSON entry into the client's config (0600, token-bearing); **CLI-managed** (Claude Code, Codex) shell out to the client's own `mcp add` via an injectable `cliRunner`. VS Code uses the divergent top-level `servers` key + `type: "stdio"`. Adding a client = add an adapter to `allAdapters` (OpenCode and Amazon Q/Kiro are intentionally deferred). +- Per-client config paths/schemas were ported from the wizard source; `internal/mcpconfig/paths.go` resolves them per-OS. Limitation: the JSON merge reformats existing files and drops JSONC comments (acceptable for v1). +- By default every detected client is configured; `--client` narrows the selection (and bypasses detection). `--config KEY=VALUE` forwards extra server env; `--cache-dir`/`--workspace`/`--image-tag` tune Docker mode. + # Code Style - Don't add comments for self-explanatory code. Only comment when the "why" isn't obvious from the code itself. diff --git a/cmd/mcp.go b/cmd/mcp.go new file mode 100644 index 00000000..e5b0efb2 --- /dev/null +++ b/cmd/mcp.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/localstack/lstk/internal/env" + "github.com/localstack/lstk/internal/mcpconfig" + "github.com/localstack/lstk/internal/output" + "github.com/localstack/lstk/internal/ui" + "github.com/spf13/cobra" +) + +func newMCPCmd(cfg *env.Env) *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Manage the LocalStack MCP server integration", + Long: "Manage the LocalStack Model Context Protocol (MCP) server integration so coding agents can drive LocalStack. Use 'lstk mcp init' to configure your installed MCP clients.", + } + cmd.AddCommand(newMCPInitCmd(cfg)) + return cmd +} + +func newMCPInitCmd(cfg *env.Env) *cobra.Command { + var ( + method string + token string + imageTag string + cacheDir string + workspace string + clients []string + extraEnv []string + ) + + cmd := &cobra.Command{ + Use: "init", + Short: "Configure MCP clients to use the LocalStack MCP server", + Long: "Configure your installed MCP clients (Cursor, Claude Code, Claude Desktop, VS Code, Codex) to launch the LocalStack MCP server. Defaults to running the server in Docker (with access to your Docker socket so it can manage LocalStack containers), so no Node toolchain is required; use --method npx to run it via Node instead. The auth token is reused from your environment or 'lstk login'. By default every detected client is configured; pass --client to narrow the selection.", + RunE: func(cmd *cobra.Command, args []string) error { + resolvedToken := token + if resolvedToken == "" { + resolvedToken = cfg.AuthToken + } + + parsedEnv, err := parseEnvAssignments(extraEnv) + if err != nil { + return err + } + + resolvedCacheDir := cacheDir + if resolvedCacheDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("could not resolve home directory: %w", err) + } + resolvedCacheDir = filepath.Join(home, ".localstack-mcp") + } + + opts := mcpconfig.Options{ + Token: resolvedToken, + Method: mcpconfig.Method(method), + ExtraEnv: parsedEnv, + ClientIDs: clients, + Docker: mcpconfig.DockerOptions{ + CacheDir: resolvedCacheDir, + WorkspaceDir: workspace, + ImageTag: imageTag, + }, + } + + if isInteractiveMode(cfg) { + return ui.RunMCPInit(cmd.Context(), opts) + } + return mcpconfig.RunInit(cmd.Context(), output.NewPlainSink(os.Stdout), opts) + }, + } + + cmd.Flags().StringVar(&method, "method", string(mcpconfig.MethodDocker), "How clients launch the server: docker or npx") + cmd.Flags().StringSliceVar(&clients, "client", nil, "MCP clients to configure (default: all detected); repeatable or comma-separated: "+strings.Join(mcpconfig.SupportedClientIDs(), ", ")) + cmd.Flags().StringVar(&token, "token", "", "LocalStack auth token (default: from environment or 'lstk login')") + cmd.Flags().StringVar(&imageTag, "image-tag", "latest", "Docker image tag for the MCP server (docker method)") + cmd.Flags().StringVar(&cacheDir, "cache-dir", "", "Host directory for the server's cache (docker method; default: ~/.localstack-mcp)") + cmd.Flags().StringVar(&workspace, "workspace", "", "Host directory to mount into the server so its IaC tools can see your project (docker method; default: none)") + // StringArray (not StringSlice) so values containing commas — e.g. + // SERVICES=s3,sqs,lambda — are kept verbatim instead of being split. + cmd.Flags().StringArrayVar(&extraEnv, "config", nil, "Extra LocalStack env var forwarded to the server, as KEY=VALUE; repeat the flag for multiple") + + return cmd +} + +// parseEnvAssignments parses KEY=VALUE pairs into a map, erroring on malformed input. +func parseEnvAssignments(pairs []string) (map[string]string, error) { + if len(pairs) == 0 { + return nil, nil + } + out := make(map[string]string, len(pairs)) + for _, pair := range pairs { + key, value, found := strings.Cut(pair, "=") + if !found || key == "" { + return nil, fmt.Errorf("invalid --config %q: expected KEY=VALUE", pair) + } + out[key] = value + } + return out, nil +} diff --git a/cmd/root.go b/cmd/root.go index 15da84c5..8493ec31 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -78,6 +78,7 @@ func NewRootCmd(cfg *env.Env, tel *telemetry.Client, logger log.Logger) *cobra.C newStatusCmd(cfg), newLogsCmd(cfg), newSetupCmd(cfg), + newMCPCmd(cfg), newConfigCmd(cfg), newVolumeCmd(cfg), newUpdateCmd(cfg), diff --git a/internal/mcpconfig/clients.go b/internal/mcpconfig/clients.go new file mode 100644 index 00000000..ef44a21b --- /dev/null +++ b/internal/mcpconfig/clients.go @@ -0,0 +1,273 @@ +package mcpconfig + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Install outcome statuses. +const ( + statusInstalled = "installed" + statusSkipped = "skipped" + statusFailed = "failed" +) + +// InstallOutcome is the result of configuring a single client. +type InstallOutcome struct { + Status string // statusInstalled | statusSkipped | statusFailed + Detail string +} + +// ClientAdapter detects and configures one MCP client. +type ClientAdapter interface { + ID() string + Label() string + // Detect reports whether the client is installed. A non-empty unsupported + // reason means the client cannot be configured on this platform at all. + Detect(cctx ClientContext) (installed bool, unsupported string) + // Install configures the client. ctx is honored for any subprocess the + // adapter spawns so the operation stays cancellable. + Install(ctx context.Context, spec ServerSpec, cctx ClientContext) InstallOutcome +} + +// ---- entry builders (shape of the per-server config object) ---- + +func standardEntry(spec ServerSpec) map[string]any { + return map[string]any{"command": spec.Command, "args": spec.Args, "env": spec.Env} +} + +// vscodeEntry uses VS Code's required "type": "stdio" discriminator. +func vscodeEntry(spec ServerSpec) map[string]any { + return map[string]any{"type": "stdio", "command": spec.Command, "args": spec.Args, "env": spec.Env} +} + +// ---- file-based client adapter ---- + +type fileClient struct { + id, label string + rootPath []string + buildEntry func(ServerSpec) map[string]any + newFileSeed string + // configPath returns the path and whether the platform is supported. + configPath func(ClientContext) (string, bool) + // detectInstalled reports presence when the platform is supported. + detectInstalled func(ClientContext) bool +} + +func (c *fileClient) ID() string { return c.id } +func (c *fileClient) Label() string { return c.label } + +func (c *fileClient) Detect(cctx ClientContext) (bool, string) { + path, supported := c.configPath(cctx) + if !supported || path == "" { + return false, "not available on " + cctx.Platform + } + return c.detectInstalled(cctx), "" +} + +// Install writes a JSON config file; it does no I/O that ctx could cancel. +func (c *fileClient) Install(_ context.Context, spec ServerSpec, cctx ClientContext) InstallOutcome { + path, supported := c.configPath(cctx) + if !supported || path == "" { + return InstallOutcome{statusFailed, c.label + " is not available on this platform"} + } + + existing, err := os.ReadFile(path) + switch { + case err == nil: + // use existing content + case errors.Is(err, os.ErrNotExist): + existing = []byte(c.seed()) + default: + return InstallOutcome{statusFailed, "could not read " + path + ": " + err.Error()} + } + + verb := "added" + if hasServerEntry(existing, c.rootPath, ServerName) { + verb = "updated" + } + + updated, err := applyServerEntry(existing, c.rootPath, ServerName, c.buildEntry(spec)) + if err != nil { + return InstallOutcome{statusFailed, path + ": " + err.Error() + " — fix it manually and re-run"} + } + + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return InstallOutcome{statusFailed, "could not create " + filepath.Dir(path) + ": " + err.Error()} + } + // 0600: the file carries the auth token, so don't leave it world-readable. + if err := os.WriteFile(path, updated, 0600); err != nil { + return InstallOutcome{statusFailed, "could not write " + path + ": " + err.Error()} + } + return InstallOutcome{statusInstalled, verb + " " + ServerName + " in " + path} +} + +func (c *fileClient) seed() string { + if c.newFileSeed != "" { + return c.newFileSeed + } + return "{}" +} + +// ---- CLI-managed client adapter (claude, codex) ---- + +// cliRunner abstracts external CLI invocation for testability. +type cliRunner interface { + LookPath(bin string) bool + Run(ctx context.Context, bin string, args ...string) (exitCode int, stdout, stderr string, err error) +} + +type execRunner struct{} + +func (execRunner) LookPath(bin string) bool { + _, err := exec.LookPath(bin) + return err == nil +} + +func (execRunner) Run(ctx context.Context, bin string, args ...string) (int, string, string, error) { + var outBuf, errBuf bytes.Buffer + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + err := cmd.Run() + exitCode := 0 + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + err = nil // non-zero exit is conveyed via exitCode, not err + } + } + return exitCode, outBuf.String(), errBuf.String(), err +} + +type cliClient struct { + id, label, bin string + scope []string // e.g. ["--scope", "user"] for claude; nil for codex + removeFirst bool // claude: remove before add for idempotency + detailPath func(ClientContext) string + runner cliRunner +} + +func (c *cliClient) ID() string { return c.id } +func (c *cliClient) Label() string { return c.label } + +func (c *cliClient) Detect(_ ClientContext) (bool, string) { + return c.runner.LookPath(c.bin), "" +} + +func (c *cliClient) Install(ctx context.Context, rawSpec ServerSpec, cctx ClientContext) InstallOutcome { + // The token rides on argv (--env KEY=VALUE) because `claude/codex mcp add` + // only persists env into the config that way — this matches the npm wizard. + // It is briefly visible in the process table during the near-instant add; + // the persisted config (~/.claude.json, ~/.codex/config.toml) holds it after. + spec := rawSpec + if cctx.Platform == "windows" { + spec = windowsSpawnSafeSpec(rawSpec) + } + token := rawSpec.Env[AuthTokenEnv] + + if c.removeFirst { + // Best effort: clears any prior entry so `add` doesn't conflict. + removeArgs := append([]string{"mcp", "remove", ServerName}, c.scope...) + _, _, _, _ = c.runner.Run(ctx, c.bin, removeArgs...) + } + + args := append([]string{"mcp", "add", ServerName}, c.scope...) + for _, k := range sortedKeys(spec.Env) { + args = append(args, "--env", k+"="+spec.Env[k]) + } + args = append(args, "--", spec.Command) + args = append(args, spec.Args...) + + exitCode, _, stderr, err := c.runner.Run(ctx, c.bin, args...) + if err != nil { + return InstallOutcome{statusFailed, redactToken(c.bin+": "+err.Error(), token)} + } + if exitCode != 0 { + detail := strings.TrimSpace(stderr) + if detail == "" { + detail = c.bin + " exited non-zero" + } + return InstallOutcome{statusFailed, redactToken(detail, token)} + } + return InstallOutcome{statusInstalled, "added via `" + c.bin + " mcp add` (" + c.detailPath(cctx) + ")"} +} + +// redactToken hides the auth token in CLI error output. Only the token is +// redacted (not arbitrary --config values), and only when it is long enough to +// be a real secret, so short config values don't mangle the diagnostic. +func redactToken(s, token string) string { + if len(token) < 8 { + return s + } + return strings.ReplaceAll(s, token, "***") +} + +// ---- registry ---- + +func cursorAdapter() ClientAdapter { + return &fileClient{ + id: "cursor", label: "Cursor", + rootPath: []string{"mcpServers"}, buildEntry: standardEntry, + configPath: func(ctx ClientContext) (string, bool) { return cursorConfigPath(ctx), true }, + detectInstalled: func(ctx ClientContext) bool { return dirExists(filepath.Join(ctx.HomeDir, ".cursor")) }, + } +} + +func claudeDesktopAdapter() ClientAdapter { + return &fileClient{ + id: "claude-desktop", label: "Claude Desktop", + rootPath: []string{"mcpServers"}, buildEntry: standardEntry, + configPath: claudeDesktopConfigPath, + detectInstalled: func(ctx ClientContext) bool { + path, ok := claudeDesktopConfigPath(ctx) + return ok && dirExists(filepath.Dir(path)) + }, + } +} + +func vscodeAdapter() ClientAdapter { + return &fileClient{ + id: "vscode", label: "VS Code", + rootPath: []string{"servers"}, buildEntry: vscodeEntry, + configPath: func(ctx ClientContext) (string, bool) { return vscodeConfigPath(ctx), true }, + detectInstalled: func(ctx ClientContext) bool { return dirExists(vscodeUserDir(ctx)) }, + } +} + +func claudeCodeAdapter(runner cliRunner) ClientAdapter { + return &cliClient{ + id: "claude-code", label: "Claude Code", bin: "claude", + scope: []string{"--scope", "user"}, removeFirst: true, + detailPath: claudeCodeUserConfigPath, runner: runner, + } +} + +func codexAdapter(runner cliRunner) ClientAdapter { + return &cliClient{ + id: "codex", label: "Codex", bin: "codex", + detailPath: codexConfigPath, runner: runner, + } +} + +// allAdapters returns the supported client adapters in display order. +func allAdapters(runner cliRunner) []ClientAdapter { + return []ClientAdapter{ + cursorAdapter(), + claudeCodeAdapter(runner), + claudeDesktopAdapter(), + vscodeAdapter(), + codexAdapter(runner), + } +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/internal/mcpconfig/clients_test.go b/internal/mcpconfig/clients_test.go new file mode 100644 index 00000000..e976501e --- /dev/null +++ b/internal/mcpconfig/clients_test.go @@ -0,0 +1,155 @@ +package mcpconfig + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func readJSON(t *testing.T, path string) map[string]any { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + var m map[string]any + require.NoError(t, json.Unmarshal(data, &m)) + return m +} + +func TestFileClientInstallCursor(t *testing.T) { + home := t.TempDir() + ctx := ctxFor("darwin", home, nil) + spec := BuildNPXServerSpec("ls-token", nil) + + outcome := cursorAdapter().Install(context.Background(), spec, ctx) + require.Equal(t, statusInstalled, outcome.Status, outcome.Detail) + + path := filepath.Join(home, ".cursor", "mcp.json") + root := readJSON(t, path) + entry := root["mcpServers"].(map[string]any)["localstack"].(map[string]any) + assert.Equal(t, "npx", entry["command"]) + env := entry["env"].(map[string]any) + assert.Equal(t, "ls-token", env["LOCALSTACK_AUTH_TOKEN"]) + + info, err := os.Stat(path) + require.NoError(t, err) + if runtime.GOOS != "windows" { + // Unix mode bits aren't meaningfully enforced on Windows (ACL-based). + assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "token-bearing file must not be world-readable") + } +} + +func TestFileClientInstallVSCodeUsesServersKeyAndType(t *testing.T) { + home := t.TempDir() + ctx := ctxFor("linux", home, nil) + + outcome := vscodeAdapter().Install(context.Background(), BuildNPXServerSpec("ls-token", nil), ctx) + require.Equal(t, statusInstalled, outcome.Status, outcome.Detail) + + root := readJSON(t, filepath.Join(home, ".config", "Code", "User", "mcp.json")) + assert.NotContains(t, root, "mcpServers", "VS Code uses the top-level 'servers' key") + entry := root["servers"].(map[string]any)["localstack"].(map[string]any) + assert.Equal(t, "stdio", entry["type"], "VS Code entries require a type discriminator") +} + +func TestFileClientInstallPreservesExistingServers(t *testing.T) { + home := t.TempDir() + ctx := ctxFor("darwin", home, nil) + path := filepath.Join(home, ".cursor", "mcp.json") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0700)) + require.NoError(t, os.WriteFile(path, []byte(`{"mcpServers":{"other":{"command":"foo"},"localstack":{"command":"stale"}}}`), 0600)) + + outcome := cursorAdapter().Install(context.Background(), BuildNPXServerSpec("ls-token", nil), ctx) + require.Equal(t, statusInstalled, outcome.Status, outcome.Detail) + assert.Contains(t, outcome.Detail, "updated", "re-running over an existing localstack entry reports an update") + + servers := readJSON(t, path)["mcpServers"].(map[string]any) + assert.Contains(t, servers, "other") + assert.Contains(t, servers, "localstack") +} + +func TestFileClientDetectUnsupportedPlatform(t *testing.T) { + installed, reason := claudeDesktopAdapter().Detect(ctxFor("linux", "/home/u", nil)) + assert.False(t, installed) + assert.NotEmpty(t, reason) +} + +// --- CLI-managed adapter --- + +type fakeRunner struct { + available map[string]bool + calls [][]string + exit int + stderr string +} + +func (f *fakeRunner) LookPath(bin string) bool { return f.available[bin] } + +func (f *fakeRunner) Run(_ context.Context, bin string, args ...string) (int, string, string, error) { + f.calls = append(f.calls, append([]string{bin}, args...)) + return f.exit, "", f.stderr, nil +} + +func TestClaudeCodeAdapterInstallRemovesThenAdds(t *testing.T) { + runner := &fakeRunner{available: map[string]bool{"claude": true}} + ctx := ctxFor("linux", "/home/u", nil) + + outcome := claudeCodeAdapter(runner).Install(context.Background(), BuildNPXServerSpec("ls-token", nil), ctx) + require.Equal(t, statusInstalled, outcome.Status, outcome.Detail) + + require.Len(t, runner.calls, 2) + assert.Equal(t, []string{"claude", "mcp", "remove", "localstack", "--scope", "user"}, runner.calls[0]) + assert.Equal(t, []string{ + "claude", "mcp", "add", "localstack", "--scope", "user", + "--env", "LOCALSTACK_AUTH_TOKEN=ls-token", + "--", "npx", "-y", "@localstack/localstack-mcp-server", + }, runner.calls[1]) +} + +func TestCodexAdapterInstallAddsOnly(t *testing.T) { + runner := &fakeRunner{available: map[string]bool{"codex": true}} + outcome := codexAdapter(runner).Install(context.Background(), BuildNPXServerSpec("ls-token", nil), ctxFor("linux", "/home/u", nil)) + require.Equal(t, statusInstalled, outcome.Status) + + require.Len(t, runner.calls, 1, "codex add overwrites, no remove-first needed") + assert.Equal(t, "add", runner.calls[0][2]) +} + +func TestCliAdapterFailureRedactsTokenOnly(t *testing.T) { + runner := &fakeRunner{available: map[string]bool{"codex": true}, exit: 1, stderr: "boom: ls-secret-token failed on line 1"} + spec := BuildNPXServerSpec("ls-secret-token", map[string]string{"DEBUG": "1"}) + + outcome := codexAdapter(runner).Install(context.Background(), spec, ctxFor("linux", "/home/u", nil)) + assert.Equal(t, statusFailed, outcome.Status) + assert.NotContains(t, outcome.Detail, "ls-secret-token", "the token must be redacted from error output") + assert.Contains(t, outcome.Detail, "***") + assert.Contains(t, outcome.Detail, "line 1", "short --config values must not be redacted out of the diagnostic") +} + +func TestCliAdapterWrapsNpxOnWindows(t *testing.T) { + runner := &fakeRunner{available: map[string]bool{"claude": true}} + outcome := claudeCodeAdapter(runner).Install(context.Background(), + BuildNPXServerSpec("ls-secret-token", nil), ctxFor("windows", `C:\Users\u`, nil)) + require.Equal(t, statusInstalled, outcome.Status, outcome.Detail) + + // calls[1] is the add (calls[0] is the remove-first). On Windows the server + // must launch via `cmd /c npx` so the npx.cmd shim resolves. + add := runner.calls[1] + require.GreaterOrEqual(t, len(add), 5) + assert.Equal(t, []string{"cmd", "/c", "npx", "-y", "@localstack/localstack-mcp-server"}, add[len(add)-5:]) +} + +func TestCliAdapterDetect(t *testing.T) { + runner := &fakeRunner{available: map[string]bool{"claude": true}} + installed, reason := claudeCodeAdapter(runner).Detect(ctxFor("linux", "/home/u", nil)) + assert.True(t, installed) + assert.Empty(t, reason) + + installed, _ = codexAdapter(runner).Detect(ctxFor("linux", "/home/u", nil)) + assert.False(t, installed, "codex not on PATH") +} diff --git a/internal/mcpconfig/jsonconfig.go b/internal/mcpconfig/jsonconfig.go new file mode 100644 index 00000000..9f13194d --- /dev/null +++ b/internal/mcpconfig/jsonconfig.go @@ -0,0 +1,68 @@ +package mcpconfig + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// applyServerEntry inserts entry at root[rootPath...][serverName], creating any +// missing intermediate objects and preserving every other key. Existing JSON is +// reparsed and reformatted on write; comments (JSONC) are not preserved. +func applyServerEntry(existing []byte, rootPath []string, serverName string, entry map[string]any) ([]byte, error) { + root := map[string]any{} + if len(bytes.TrimSpace(existing)) > 0 { + if err := json.Unmarshal(existing, &root); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + if root == nil { + root = map[string]any{} + } + } + + cur := root + for _, key := range rootPath { + existing, present := cur[key] + if !present { + child := map[string]any{} + cur[key] = child + cur = child + continue + } + // Present but not an object: refuse to clobber the user's data, matching + // the conservative handling of an unparseable top-level document. + child, ok := existing.(map[string]any) + if !ok { + return nil, fmt.Errorf("%q is not an object", key) + } + cur = child + } + cur[serverName] = entry + + out, err := json.MarshalIndent(root, "", " ") + if err != nil { + return nil, err + } + return append(out, '\n'), nil +} + +// hasServerEntry reports whether serverName already exists under rootPath. +func hasServerEntry(existing []byte, rootPath []string, serverName string) bool { + if len(bytes.TrimSpace(existing)) == 0 { + return false + } + var root map[string]any + if err := json.Unmarshal(existing, &root); err != nil { + return false + } + cur := root + for _, key := range rootPath { + child, ok := cur[key].(map[string]any) + if !ok { + return false + } + cur = child + } + _, ok := cur[serverName] + return ok +} diff --git a/internal/mcpconfig/jsonconfig_test.go b/internal/mcpconfig/jsonconfig_test.go new file mode 100644 index 00000000..e087e6d4 --- /dev/null +++ b/internal/mcpconfig/jsonconfig_test.go @@ -0,0 +1,60 @@ +package mcpconfig + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyServerEntryNewFile(t *testing.T) { + out, err := applyServerEntry(nil, []string{"mcpServers"}, "localstack", map[string]any{"command": "docker"}) + require.NoError(t, err) + + var root map[string]any + require.NoError(t, json.Unmarshal(out, &root)) + servers := root["mcpServers"].(map[string]any) + entry := servers["localstack"].(map[string]any) + assert.Equal(t, "docker", entry["command"]) +} + +func TestApplyServerEntryPreservesOtherServers(t *testing.T) { + existing := []byte(`{"mcpServers":{"other":{"command":"foo"}},"unrelated":true}`) + out, err := applyServerEntry(existing, []string{"mcpServers"}, "localstack", map[string]any{"command": "docker"}) + require.NoError(t, err) + + var root map[string]any + require.NoError(t, json.Unmarshal(out, &root)) + servers := root["mcpServers"].(map[string]any) + assert.Contains(t, servers, "other", "existing servers must be preserved") + assert.Contains(t, servers, "localstack") + assert.Equal(t, true, root["unrelated"], "unrelated top-level keys must be preserved") +} + +func TestApplyServerEntryNestedRootPath(t *testing.T) { + out, err := applyServerEntry([]byte(`{}`), []string{"servers"}, "localstack", map[string]any{"type": "stdio"}) + require.NoError(t, err) + + var root map[string]any + require.NoError(t, json.Unmarshal(out, &root)) + assert.Contains(t, root, "servers") +} + +func TestApplyServerEntryInvalidJSON(t *testing.T) { + _, err := applyServerEntry([]byte(`{not json`), []string{"mcpServers"}, "localstack", nil) + assert.Error(t, err) +} + +func TestApplyServerEntryRefusesNonObjectKey(t *testing.T) { + // An existing key of the wrong type must not be silently clobbered. + _, err := applyServerEntry([]byte(`{"mcpServers":"oops"}`), []string{"mcpServers"}, "localstack", map[string]any{"command": "docker"}) + assert.Error(t, err) +} + +func TestHasServerEntry(t *testing.T) { + existing := []byte(`{"mcpServers":{"localstack":{"command":"docker"}}}`) + assert.True(t, hasServerEntry(existing, []string{"mcpServers"}, "localstack")) + assert.False(t, hasServerEntry(existing, []string{"mcpServers"}, "other")) + assert.False(t, hasServerEntry(nil, []string{"mcpServers"}, "localstack")) +} diff --git a/internal/mcpconfig/mcpconfig.go b/internal/mcpconfig/mcpconfig.go new file mode 100644 index 00000000..f314612d --- /dev/null +++ b/internal/mcpconfig/mcpconfig.go @@ -0,0 +1,163 @@ +package mcpconfig + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/localstack/lstk/internal/output" +) + +// Options configures a `lstk mcp init` run. Values are resolved at the command +// boundary and passed in explicitly (no config.Get in this package). +type Options struct { + Token string // LOCALSTACK_AUTH_TOKEN to embed in client configs + Method Method // MethodDocker (default) or MethodNPX + ExtraEnv map[string]string // additional env forwarded to the server + Docker DockerOptions // used when Method == MethodDocker + ClientIDs []string // explicit clients to configure; empty = all detected +} + +// RunInit configures the selected MCP clients to launch the LocalStack MCP +// server. It emits progress through the sink and works for both the interactive +// (TUI) and non-interactive (plain) paths. +func RunInit(ctx context.Context, sink output.Sink, opts Options) error { + cctx, err := currentClientContext() + if err != nil { + return fmt.Errorf("resolve home directory: %w", err) + } + return runInit(ctx, sink, opts, allAdapters(execRunner{}), cctx) +} + +func runInit(ctx context.Context, sink output.Sink, opts Options, adapters []ClientAdapter, cctx ClientContext) error { + if opts.Token == "" { + sink.Emit(output.ErrorEvent{ + Title: "No LocalStack auth token found", + Summary: "The MCP server needs a token to talk to LocalStack.", + Actions: []output.ErrorAction{ + {Label: "Log in:", Value: "lstk login"}, + {Label: "Or set:", Value: "export LOCALSTACK_AUTH_TOKEN=ls-..."}, + }, + }) + return output.NewSilentError(fmt.Errorf("no auth token")) + } + + spec, err := buildSpec(opts) + if err != nil { + sink.Emit(output.ErrorEvent{Title: "Invalid options", Summary: err.Error()}) + return output.NewSilentError(err) + } + + targets, err := selectTargets(adapters, cctx, opts.ClientIDs) + if err != nil { + sink.Emit(output.ErrorEvent{Title: "Unknown MCP client", Summary: err.Error()}) + return output.NewSilentError(err) + } + if len(targets) == 0 { + sink.Emit(output.MessageEvent{ + Severity: output.SeverityNote, + Text: "No supported MCP clients detected. Install one (Cursor, Claude Code, Claude Desktop, VS Code, Codex), or pass --client.", + }) + return nil + } + + labels := make([]string, len(targets)) + for i, a := range targets { + labels[i] = a.Label() + } + sink.Emit(output.MessageEvent{ + Severity: output.SeverityInfo, + Text: fmt.Sprintf("Configuring the LocalStack MCP server (%s) for: %s", opts.Method, strings.Join(labels, ", ")), + }) + + installed := 0 + for _, a := range targets { + outcome := a.Install(ctx, spec, cctx) + switch outcome.Status { + case statusInstalled: + installed++ + sink.Emit(output.MessageEvent{Severity: output.SeveritySuccess, Text: a.Label() + ": " + outcome.Detail}) + case statusSkipped: + sink.Emit(output.MessageEvent{Severity: output.SeverityNote, Text: a.Label() + ": " + outcome.Detail}) + default: + sink.Emit(output.MessageEvent{Severity: output.SeverityWarning, Text: a.Label() + ": " + outcome.Detail}) + } + } + + if installed == 0 { + sink.Emit(output.ErrorEvent{Title: "No MCP clients were configured", Summary: "Every selected client failed; see the messages above."}) + return output.NewSilentError(fmt.Errorf("no clients configured")) + } + + sink.Emit(output.MessageEvent{ + Severity: output.SeveritySuccess, + Text: "Done. Restart your MCP client(s), then ask your agent to start LocalStack.", + }) + if opts.Method == MethodDocker { + sink.Emit(output.MessageEvent{ + Severity: output.SeverityNote, + Text: "Docker mode mounts your Docker socket so the server can manage containers; use --method npx to avoid it.", + }) + sink.Emit(output.MessageEvent{ + Severity: output.SeveritySecondary, + Text: "The first run pulls " + DockerImage + " (~1.7 GB) — give it a minute.", + }) + } + return nil +} + +func buildSpec(opts Options) (ServerSpec, error) { + switch opts.Method { + case MethodNPX: + return BuildNPXServerSpec(opts.Token, opts.ExtraEnv), nil + case MethodDocker, "": + return BuildDockerServerSpec(opts.Token, opts.ExtraEnv, opts.Docker), nil + default: + return ServerSpec{}, fmt.Errorf("unknown method %q (use %q or %q)", opts.Method, MethodDocker, MethodNPX) + } +} + +// selectTargets resolves which adapters to configure. With explicit clientIDs it +// returns those adapters (erroring on unknown ids); otherwise it auto-detects +// installed, supported clients. +func selectTargets(adapters []ClientAdapter, cctx ClientContext, clientIDs []string) ([]ClientAdapter, error) { + if len(clientIDs) > 0 { + byID := make(map[string]ClientAdapter, len(adapters)) + for _, a := range adapters { + byID[a.ID()] = a + } + var targets []ClientAdapter + for _, id := range clientIDs { + a, ok := byID[id] + if !ok { + return nil, fmt.Errorf("%q (known: %s)", id, strings.Join(adapterIDs(adapters), ", ")) + } + targets = append(targets, a) + } + return targets, nil + } + + var targets []ClientAdapter + for _, a := range adapters { + installed, unsupported := a.Detect(cctx) + if unsupported == "" && installed { + targets = append(targets, a) + } + } + return targets, nil +} + +func adapterIDs(adapters []ClientAdapter) []string { + ids := make([]string, len(adapters)) + for i, a := range adapters { + ids[i] = a.ID() + } + sort.Strings(ids) + return ids +} + +// SupportedClientIDs returns the configurable client identifiers, for help text. +func SupportedClientIDs() []string { + return adapterIDs(allAdapters(execRunner{})) +} diff --git a/internal/mcpconfig/mcpconfig_test.go b/internal/mcpconfig/mcpconfig_test.go new file mode 100644 index 00000000..d82aed6a --- /dev/null +++ b/internal/mcpconfig/mcpconfig_test.go @@ -0,0 +1,140 @@ +package mcpconfig + +import ( + "context" + "testing" + + "github.com/localstack/lstk/internal/output" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingSink captures emitted events for assertions. +type recordingSink struct{ events []output.Event } + +func (s *recordingSink) Emit(e output.Event) { s.events = append(s.events, e) } + +func (s *recordingSink) messages() []output.MessageEvent { + var out []output.MessageEvent + for _, e := range s.events { + if m, ok := e.(output.MessageEvent); ok { + out = append(out, m) + } + } + return out +} + +func (s *recordingSink) hasError() bool { + for _, e := range s.events { + if _, ok := e.(output.ErrorEvent); ok { + return true + } + } + return false +} + +// fakeAdapter records Install calls and reports a fixed detection/outcome. +type fakeAdapter struct { + id string + installed bool + unsupported string + outcome string + installCnt int +} + +func (f *fakeAdapter) ID() string { return f.id } +func (f *fakeAdapter) Label() string { return f.id } +func (f *fakeAdapter) Detect(ClientContext) (bool, string) { return f.installed, f.unsupported } +func (f *fakeAdapter) Install(context.Context, ServerSpec, ClientContext) InstallOutcome { + f.installCnt++ + status := f.outcome + if status == "" { + status = statusInstalled + } + return InstallOutcome{status, "ok"} +} + +func TestRunInitRequiresToken(t *testing.T) { + sink := &recordingSink{} + err := runInit(context.Background(), sink, Options{Method: MethodDocker}, nil, ClientContext{}) + + require.Error(t, err) + assert.True(t, output.IsSilent(err), "missing-token error should be silent (already emitted)") + assert.True(t, sink.hasError()) +} + +func TestRunInitAutoDetectInstallsOnlyDetected(t *testing.T) { + sink := &recordingSink{} + a := &fakeAdapter{id: "a", installed: true} + b := &fakeAdapter{id: "b", installed: false} + c := &fakeAdapter{id: "c", installed: true, unsupported: "nope"} + + err := runInit(context.Background(), sink, Options{Token: "ls-x", Method: MethodNPX}, + []ClientAdapter{a, b, c}, ClientContext{}) + require.NoError(t, err) + + assert.Equal(t, 1, a.installCnt, "detected adapter installed") + assert.Equal(t, 0, b.installCnt, "undetected adapter skipped") + assert.Equal(t, 0, c.installCnt, "unsupported adapter skipped") +} + +func TestRunInitExplicitClientsBypassDetection(t *testing.T) { + sink := &recordingSink{} + a := &fakeAdapter{id: "a", installed: false} + b := &fakeAdapter{id: "b", installed: false} + + err := runInit(context.Background(), sink, Options{Token: "ls-x", Method: MethodNPX, ClientIDs: []string{"b"}}, + []ClientAdapter{a, b}, ClientContext{}) + require.NoError(t, err) + + assert.Equal(t, 0, a.installCnt) + assert.Equal(t, 1, b.installCnt, "explicitly requested client installed even if undetected") +} + +func TestRunInitUnknownClientErrors(t *testing.T) { + sink := &recordingSink{} + a := &fakeAdapter{id: "a", installed: true} + + err := runInit(context.Background(), sink, Options{Token: "ls-x", ClientIDs: []string{"nope"}}, + []ClientAdapter{a}, ClientContext{}) + require.Error(t, err) + assert.True(t, sink.hasError()) +} + +func TestRunInitNoTargetsEmitsNote(t *testing.T) { + sink := &recordingSink{} + a := &fakeAdapter{id: "a", installed: false} + + err := runInit(context.Background(), sink, Options{Token: "ls-x", Method: MethodNPX}, + []ClientAdapter{a}, ClientContext{}) + require.NoError(t, err) + + var sawNote bool + for _, m := range sink.messages() { + if m.Severity == output.SeverityNote { + sawNote = true + } + } + assert.True(t, sawNote, "should advise when no clients detected") +} + +func TestRunInitAllFailedReturnsError(t *testing.T) { + sink := &recordingSink{} + a := &fakeAdapter{id: "a", installed: true, outcome: statusFailed} + + err := runInit(context.Background(), sink, Options{Token: "ls-x", Method: MethodNPX}, + []ClientAdapter{a}, ClientContext{}) + require.Error(t, err) + assert.True(t, output.IsSilent(err)) +} + +func TestBuildSpecUnknownMethod(t *testing.T) { + _, err := buildSpec(Options{Token: "x", Method: "bogus"}) + assert.Error(t, err) +} + +func TestBuildSpecDefaultsToDocker(t *testing.T) { + spec, err := buildSpec(Options{Token: "x", Docker: DockerOptions{CacheDir: "/c", ImageTag: "latest"}}) + require.NoError(t, err) + assert.Equal(t, "docker", spec.Command) +} diff --git a/internal/mcpconfig/paths.go b/internal/mcpconfig/paths.go new file mode 100644 index 00000000..70a8bb9e --- /dev/null +++ b/internal/mcpconfig/paths.go @@ -0,0 +1,94 @@ +package mcpconfig + +import ( + "os" + "path/filepath" + "runtime" +) + +// ClientContext carries the platform facts client path/detection logic needs, +// injectable so the resolution is unit-testable without touching the real OS. +type ClientContext struct { + Platform string // GOOS: "darwin", "linux", "windows" + HomeDir string // user home directory + Getenv func(string) string // environment lookup (APPDATA, XDG_CONFIG_HOME, CODEX_HOME) +} + +func currentClientContext() (ClientContext, error) { + home, err := os.UserHomeDir() + if err != nil { + return ClientContext{}, err + } + return ClientContext{ + Platform: runtime.GOOS, + HomeDir: home, + Getenv: os.Getenv, + }, nil +} + +func (c ClientContext) env(key string) string { + if c.Getenv == nil { + return "" + } + return c.Getenv(key) +} + +func (c ClientContext) appData() string { + if v := c.env("APPDATA"); v != "" { + return v + } + return filepath.Join(c.HomeDir, "AppData", "Roaming") +} + +func (c ClientContext) xdgConfigHome() string { + if v := c.env("XDG_CONFIG_HOME"); v != "" { + return v + } + return filepath.Join(c.HomeDir, ".config") +} + +func cursorConfigPath(ctx ClientContext) string { + return filepath.Join(ctx.HomeDir, ".cursor", "mcp.json") +} + +// claudeDesktopConfigPath returns the config path and whether the platform is +// supported (Claude Desktop ships for macOS and Windows only). +func claudeDesktopConfigPath(ctx ClientContext) (string, bool) { + switch ctx.Platform { + case "darwin": + return filepath.Join(ctx.HomeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json"), true + case "windows": + return filepath.Join(ctx.appData(), "Claude", "claude_desktop_config.json"), true + default: + return "", false + } +} + +func vscodeUserDir(ctx ClientContext) string { + switch ctx.Platform { + case "darwin": + return filepath.Join(ctx.HomeDir, "Library", "Application Support", "Code", "User") + case "windows": + return filepath.Join(ctx.appData(), "Code", "User") + default: + return filepath.Join(ctx.xdgConfigHome(), "Code", "User") + } +} + +func vscodeConfigPath(ctx ClientContext) string { + return filepath.Join(vscodeUserDir(ctx), "mcp.json") +} + +// claudeCodeUserConfigPath is the file `claude mcp add --scope user` writes to; +// used only to describe where the entry landed. +func claudeCodeUserConfigPath(ctx ClientContext) string { + return filepath.Join(ctx.HomeDir, ".claude.json") +} + +// codexConfigPath is where `codex mcp add` persists servers; used for messaging. +func codexConfigPath(ctx ClientContext) string { + if v := ctx.env("CODEX_HOME"); v != "" { + return filepath.Join(v, "config.toml") + } + return filepath.Join(ctx.HomeDir, ".codex", "config.toml") +} diff --git a/internal/mcpconfig/paths_test.go b/internal/mcpconfig/paths_test.go new file mode 100644 index 00000000..cfef3ae9 --- /dev/null +++ b/internal/mcpconfig/paths_test.go @@ -0,0 +1,51 @@ +package mcpconfig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func ctxFor(platform, home string, env map[string]string) ClientContext { + return ClientContext{ + Platform: platform, + HomeDir: home, + Getenv: func(k string) string { return env[k] }, + } +} + +func TestCursorConfigPath(t *testing.T) { + assert.Equal(t, "/home/u/.cursor/mcp.json", cursorConfigPath(ctxFor("linux", "/home/u", nil))) +} + +func TestClaudeDesktopConfigPath(t *testing.T) { + mac, ok := claudeDesktopConfigPath(ctxFor("darwin", "/Users/u", nil)) + assert.True(t, ok) + assert.Equal(t, "/Users/u/Library/Application Support/Claude/claude_desktop_config.json", mac) + + _, ok = claudeDesktopConfigPath(ctxFor("linux", "/home/u", nil)) + assert.False(t, ok, "Claude Desktop is unsupported on Linux") + + win, ok := claudeDesktopConfigPath(ctxFor("windows", `C:\Users\u`, map[string]string{"APPDATA": `C:\Users\u\AppData\Roaming`})) + assert.True(t, ok) + assert.Contains(t, win, "Claude") +} + +func TestVSCodeConfigPath(t *testing.T) { + assert.Equal(t, + "/Users/u/Library/Application Support/Code/User/mcp.json", + vscodeConfigPath(ctxFor("darwin", "/Users/u", nil))) + + assert.Equal(t, + "/home/u/.config/Code/User/mcp.json", + vscodeConfigPath(ctxFor("linux", "/home/u", nil))) + + assert.Equal(t, + "/home/u/custom/Code/User/mcp.json", + vscodeConfigPath(ctxFor("linux", "/home/u", map[string]string{"XDG_CONFIG_HOME": "/home/u/custom"}))) +} + +func TestCodexConfigPath(t *testing.T) { + assert.Equal(t, "/home/u/.codex/config.toml", codexConfigPath(ctxFor("linux", "/home/u", nil))) + assert.Equal(t, "/custom/config.toml", codexConfigPath(ctxFor("linux", "/home/u", map[string]string{"CODEX_HOME": "/custom"}))) +} diff --git a/internal/mcpconfig/spec.go b/internal/mcpconfig/spec.go new file mode 100644 index 00000000..d069a448 --- /dev/null +++ b/internal/mcpconfig/spec.go @@ -0,0 +1,114 @@ +// Package mcpconfig configures MCP clients (Cursor, Claude Code, VS Code, ...) +// to launch the LocalStack MCP server. It mirrors the standalone setup wizard +// shipped in @localstack/localstack-mcp-server so that an entry written here is +// interchangeable with one written by `npx @localstack/localstack-mcp-server init`. +package mcpconfig + +import "sort" + +const ( + // ServerName is the entry key written into every client's config. Keeping + // it identical to the npm wizard's makes the two installers interchangeable. + ServerName = "localstack" + NPMPackage = "@localstack/localstack-mcp-server" + DockerImage = "localstack/localstack-mcp-server" + AuthTokenEnv = "LOCALSTACK_AUTH_TOKEN" +) + +// Method selects how the MCP server process is launched by the client. +type Method string + +const ( + MethodDocker Method = "docker" + MethodNPX Method = "npx" +) + +// ServerSpec describes how a client should launch the LocalStack MCP server. +type ServerSpec struct { + Command string + Args []string + Env map[string]string +} + +// DockerOptions tunes the `docker run` invocation written into client configs. +type DockerOptions struct { + CacheDir string + WorkspaceDir string // empty means no workspace mount + ImageTag string +} + +// windowsSpawnSafeSpec wraps an npx command in `cmd /c` so clients that spawn +// the server without a shell (Claude Code, Codex) can resolve the npx.cmd shim +// on native Windows. Non-npx and non-Windows specs pass through unchanged. This +// mirrors the npm wizard so the two installers stay interchangeable. +func windowsSpawnSafeSpec(spec ServerSpec) ServerSpec { + if spec.Command != "npx" { + return spec + } + return ServerSpec{ + Command: "cmd", + Args: append([]string{"/c", spec.Command}, spec.Args...), + Env: spec.Env, + } +} + +// BuildNPXServerSpec returns the spec for running the server via npx on the host. +func BuildNPXServerSpec(token string, extraEnv map[string]string) ServerSpec { + return ServerSpec{ + Command: "npx", + Args: []string{"-y", NPMPackage}, + Env: mergeEnv(token, extraEnv), + } +} + +// BuildDockerServerSpec returns the spec for running the server in a container. +// The server reaches the host's LocalStack via host.docker.internal and manages +// containers through the mounted Docker socket. +func BuildDockerServerSpec(token string, extraEnv map[string]string, opts DockerOptions) ServerSpec { + args := []string{ + "run", "-i", "--rm", + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-v", opts.CacheDir + ":" + opts.CacheDir, + "-e", "XDG_CACHE_HOME=" + opts.CacheDir, + "--add-host", "host.docker.internal:host-gateway", + "--add-host", "s3.host.docker.internal:host-gateway", + "--add-host", "snowflake.localhost.localstack.cloud:host-gateway", + "-e", AuthTokenEnv, + "-e", "LOCALSTACK_HOSTNAME=host.docker.internal", + } + + // Forward extra config vars by name; values stay in the env block so client + // UIs don't display them. Sorted for deterministic output. + for _, key := range sortedKeys(extraEnv) { + args = append(args, "-e", key) + } + + if opts.WorkspaceDir != "" { + args = append(args, "-v", opts.WorkspaceDir+":"+opts.WorkspaceDir) + } + + args = append(args, DockerImage+":"+opts.ImageTag) + + return ServerSpec{ + Command: "docker", + Args: args, + Env: mergeEnv(token, extraEnv), + } +} + +func mergeEnv(token string, extraEnv map[string]string) map[string]string { + env := map[string]string{AuthTokenEnv: token} + for k, v := range extraEnv { + env[k] = v + } + return env +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/mcpconfig/spec_test.go b/internal/mcpconfig/spec_test.go new file mode 100644 index 00000000..eab627c6 --- /dev/null +++ b/internal/mcpconfig/spec_test.go @@ -0,0 +1,86 @@ +package mcpconfig + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildNPXServerSpec(t *testing.T) { + spec := BuildNPXServerSpec("ls-token", nil) + + assert.Equal(t, "npx", spec.Command) + assert.Equal(t, []string{"-y", "@localstack/localstack-mcp-server"}, spec.Args) + assert.Equal(t, map[string]string{"LOCALSTACK_AUTH_TOKEN": "ls-token"}, spec.Env) +} + +func TestBuildNPXServerSpecWithExtraEnv(t *testing.T) { + spec := BuildNPXServerSpec("ls-token", map[string]string{"DEBUG": "1"}) + + assert.Equal(t, map[string]string{ + "LOCALSTACK_AUTH_TOKEN": "ls-token", + "DEBUG": "1", + }, spec.Env) +} + +func TestWindowsSpawnSafeSpec(t *testing.T) { + wrapped := windowsSpawnSafeSpec(BuildNPXServerSpec("ls-token", nil)) + assert.Equal(t, "cmd", wrapped.Command) + assert.Equal(t, []string{"/c", "npx", "-y", "@localstack/localstack-mcp-server"}, wrapped.Args) + assert.Equal(t, map[string]string{"LOCALSTACK_AUTH_TOKEN": "ls-token"}, wrapped.Env) + + docker := BuildDockerServerSpec("ls-token", nil, DockerOptions{CacheDir: "/c", ImageTag: "latest"}) + assert.Equal(t, docker, windowsSpawnSafeSpec(docker), "non-npx specs pass through unchanged") +} + +func TestBuildDockerServerSpec(t *testing.T) { + spec := BuildDockerServerSpec("ls-token", nil, DockerOptions{ + CacheDir: "/cache", + ImageTag: "latest", + }) + + assert.Equal(t, "docker", spec.Command) + assert.Equal(t, []string{ + "run", "-i", "--rm", + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-v", "/cache:/cache", + "-e", "XDG_CACHE_HOME=/cache", + "--add-host", "host.docker.internal:host-gateway", + "--add-host", "s3.host.docker.internal:host-gateway", + "--add-host", "snowflake.localhost.localstack.cloud:host-gateway", + "-e", "LOCALSTACK_AUTH_TOKEN", + "-e", "LOCALSTACK_HOSTNAME=host.docker.internal", + "localstack/localstack-mcp-server:latest", + }, spec.Args) + assert.Equal(t, map[string]string{"LOCALSTACK_AUTH_TOKEN": "ls-token"}, spec.Env) +} + +func TestBuildDockerServerSpecWithWorkspaceAndExtraEnv(t *testing.T) { + spec := BuildDockerServerSpec("ls-token", map[string]string{"DEBUG": "1", "ANOTHER": "x"}, DockerOptions{ + CacheDir: "/cache", + WorkspaceDir: "/work", + ImageTag: "1.2.3", + }) + + assert.Equal(t, []string{ + "run", "-i", "--rm", + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-v", "/cache:/cache", + "-e", "XDG_CACHE_HOME=/cache", + "--add-host", "host.docker.internal:host-gateway", + "--add-host", "s3.host.docker.internal:host-gateway", + "--add-host", "snowflake.localhost.localstack.cloud:host-gateway", + "-e", "LOCALSTACK_AUTH_TOKEN", + "-e", "LOCALSTACK_HOSTNAME=host.docker.internal", + // Extra env keys are forwarded in sorted order for determinism. + "-e", "ANOTHER", + "-e", "DEBUG", + "-v", "/work:/work", + "localstack/localstack-mcp-server:1.2.3", + }, spec.Args) + assert.Equal(t, map[string]string{ + "LOCALSTACK_AUTH_TOKEN": "ls-token", + "DEBUG": "1", + "ANOTHER": "x", + }, spec.Env) +} diff --git a/internal/ui/run_mcp.go b/internal/ui/run_mcp.go new file mode 100644 index 00000000..5346d924 --- /dev/null +++ b/internal/ui/run_mcp.go @@ -0,0 +1,17 @@ +package ui + +import ( + "context" + + "github.com/localstack/lstk/internal/mcpconfig" + "github.com/localstack/lstk/internal/output" +) + +// RunMCPInit runs `lstk mcp init` in the interactive TUI, streaming the +// configuration progress through a TUI sink. The command takes no interactive +// input — it just renders the domain events. +func RunMCPInit(ctx context.Context, opts mcpconfig.Options) error { + return runWithTUI(ctx, withoutHeader(), func(ctx context.Context, sink output.Sink) error { + return mcpconfig.RunInit(ctx, sink, opts) + }) +} diff --git a/test/integration/mcp_test.go b/test/integration/mcp_test.go new file mode 100644 index 00000000..23f51bd6 --- /dev/null +++ b/test/integration/mcp_test.go @@ -0,0 +1,93 @@ +package integration_test + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/localstack/lstk/test/integration/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMCPInitConfiguresClientAndEmitsTelemetry exercises the file-based client +// path end to end: a single `lstk mcp init` writes a valid, token-bearing entry +// to the client's config and emits the lstk_command telemetry event. +func TestMCPInitConfiguresClientAndEmitsTelemetry(t *testing.T) { + t.Parallel() + home := t.TempDir() + analyticsSrv, events := mockAnalyticsServer(t) + + environ := env.Environ(testEnvWithHome(home, "")). + With(env.AuthToken, "ls-test-token"). + With(env.AnalyticsEndpoint, analyticsSrv.URL) + + stdout, stderr, err := runLstk(t, testContext(t), "", environ, + "mcp", "init", "--method", "npx", "--client", "cursor") + require.NoError(t, err, "mcp init failed: stdout=%s stderr=%s", stdout, stderr) + assert.Contains(t, stdout, "Cursor") + + path := filepath.Join(home, ".cursor", "mcp.json") + data, err := os.ReadFile(path) + require.NoError(t, err, "expected %s to be written", path) + + var root struct { + MCPServers map[string]struct { + Command string `json:"command"` + Args []string `json:"args"` + Env map[string]string `json:"env"` + } `json:"mcpServers"` + } + require.NoError(t, json.Unmarshal(data, &root)) + entry, ok := root.MCPServers["localstack"] + require.True(t, ok, "localstack entry must be present in %s", path) + assert.Equal(t, "npx", entry.Command) + assert.Equal(t, []string{"-y", "@localstack/localstack-mcp-server"}, entry.Args) + assert.Equal(t, "ls-test-token", entry.Env["LOCALSTACK_AUTH_TOKEN"]) + + info, err := os.Stat(path) + require.NoError(t, err) + if runtime.GOOS != "windows" { + // Unix mode bits aren't meaningfully enforced on Windows (ACL-based). + assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "token-bearing config must not be world-readable") + } + + assertCommandTelemetry(t, events, "mcp init", 0) +} + +// TestMCPInitRequiresToken: with no token available the command fails fast with +// actionable guidance instead of writing a broken (token-less) config. +func TestMCPInitRequiresToken(t *testing.T) { + t.Parallel() + home := t.TempDir() + + environ := env.Environ(testEnvWithHome(home, "")).Without(env.AuthToken) + + stdout, _, err := runLstk(t, testContext(t), "", environ, + "mcp", "init", "--method", "npx", "--client", "cursor") + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "No LocalStack auth token") + assert.Contains(t, stdout, "lstk login") + + _, statErr := os.Stat(filepath.Join(home, ".cursor", "mcp.json")) + assert.True(t, os.IsNotExist(statErr), "no config should be written when the token is missing") +} + +// TestMCPInitUnknownClient: an unrecognized --client value is rejected and lists +// the supported clients. +func TestMCPInitUnknownClient(t *testing.T) { + t.Parallel() + home := t.TempDir() + + environ := env.Environ(testEnvWithHome(home, "")).With(env.AuthToken, "ls-test-token") + + stdout, _, err := runLstk(t, testContext(t), "", environ, + "mcp", "init", "--client", "bogus") + require.Error(t, err) + requireExitCode(t, 1, err) + assert.Contains(t, stdout, "Unknown MCP client") + assert.Contains(t, stdout, "cursor") +}