Files
pad/internal/mcp/install.go
T
xarmian e90ee18907 feat(mcp): pad mcp install / uninstall / status (TASK-948) (#338)
* feat(mcp): pad mcp install / uninstall / status (TASK-948)

One-shot config writers for the three MCP-capable client apps:

  pad mcp install claude-desktop   # ~/.config/Claude/claude_desktop_config.json (linux)
  pad mcp install cursor           # ~/.cursor/mcp.json
  pad mcp install windsurf         # ~/.codeium/windsurf/mcp_config.json
  pad mcp install --all            # all three
  pad mcp uninstall cursor         # remove
  pad mcp status                   # report install state

Implementation:
- internal/mcp/install.go (new) — Agent registry with per-OS path
  resolvers (PathFor takes (home, goos) so tests inject); AddPadEntry
  / RemovePadEntry / HasPadEntry primitives that read-modify-write
  JSON, preserving every entry except mcpServers.pad. Installer
  façade with Home/GOOS overrides for tests.
- cmd/pad/mcp.go — three new cobra subcommands wired into mcpCmd:
  install (no-args = status, --all = batch), uninstall, status.
  Binary path resolved via os.Executable().

DOD coverage:
- Existing entries preserved (TestAddPadEntry_PreservesOtherServers
  asserts both other mcpServers and unrelated top-level keys survive).
- Idempotent install (binary unchanged → modified=false).
- Update install (binary changed → modified=true).
- Idempotent uninstall (missing file / missing entry → no-op).
- Per-platform path resolution tested for linux + darwin.
- 16 unit tests including edge cases: empty/whitespace files,
  malformed JSON rejected (no silent overwrite), case-insensitive
  agent aliases.

Live verified end-to-end:
- HOME=/tmp/fakehome pad mcp install cursor → writes valid JSON
- pad mcp status → shows [x] Cursor with command path
- pad mcp uninstall cursor → leaves mcpServers:{} skeleton

Config file perms: 0600 (configs may hold credentials for OTHER
MCP servers; tighten on principle).

Parent: PLAN-942.

* fix(mcp): tighten install argument validation + chmod existing configs (Codex round 1)

Two findings on PR #338:

1. `pad mcp install` had no Args validator so cobra silently accepted
   extras: `pad mcp install cursor windsurf` only installed Cursor.
   Added cobra.MaximumNArgs(1) plus an explicit guard rejecting
   `--all` combined with an agent name (those flows are
   mutually exclusive).

2. os.WriteFile(path, data, 0o600) only honors the mode when CREATING
   the file. A pre-existing 0644 config kept 0644 after the install,
   defeating the security-tightening claim in the comment. Added an
   explicit os.Chmod(path, 0o600) after writing; chmod failures are
   stderr warnings, not hard errors (the data write already
   succeeded; perms hardening is best-effort defense-in-depth).

New test TestAddPadEntry_TightensExistingFilePerms locks the
0600-after-install contract; live verified the cobra guards reject
both error cases with clean messages.

Parent: PLAN-942.

* fix(mcp): tighten perms on idempotent install path too (Codex round 2)

Codex caught: AddPadEntry's no-op early-return (when desired config
matches existing) skipped the chmod step from round 1's fix. So an
already-up-to-date 0644 config retained 0644 after re-running
`pad mcp install`.

Extracted tightenPerms() as a helper called from BOTH paths:
- writeJSONConfig (modified path) — chmod after write
- AddPadEntry's no-op return — chmod even when content is unchanged

Best-effort: chmod failures still emit a warning rather than failing
the install (the user's intent already succeeded; perms tightening is
defense-in-depth, not core functionality).

New test TestAddPadEntry_TightensPermsOnIdempotentNoop locks the
no-op-path contract.

Parent: PLAN-942.
2026-05-01 09:15:23 -04:00

429 lines
13 KiB
Go

package mcp
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
)
// MCPServerKey is the canonical name pad registers itself under in
// each agent's `mcpServers` map. Stable across versions; renaming
// would orphan every existing install.
const MCPServerKey = "pad"
// Agent describes a known MCP-compatible client and how to find its
// per-user config file.
type Agent struct {
// Name is the canonical lookup key, used as the command argument
// (e.g. `pad mcp install claude-desktop`).
Name string
// Label is the human-readable name for prints.
Label string
// Aliases match alternate user inputs.
Aliases []string
// PathFor resolves the config path given a user's home directory
// and runtime.GOOS. Pure function — no I/O — so tests override
// `home` and `goos` to verify path logic without touching the
// real filesystem.
PathFor func(home, goos string) (string, error)
}
// SupportedAgents is the canonical list of MCP-aware clients pad
// knows how to configure. Order is the iteration order for the
// auto-detect / status path.
var SupportedAgents = []Agent{
{
Name: "claude-desktop",
Label: "Claude Desktop",
Aliases: []string{"claude", "anthropic"},
PathFor: claudeDesktopPathFor,
},
{
Name: "cursor",
Label: "Cursor",
PathFor: cursorPathFor,
},
{
Name: "windsurf",
Label: "Windsurf",
PathFor: windsurfPathFor,
},
}
// FindAgent returns the matching Agent for a name or alias. Names are
// case-insensitive (Claude / claude / CLAUDE all resolve).
func FindAgent(name string) (*Agent, error) {
for i := range SupportedAgents {
a := &SupportedAgents[i]
if equalFold(a.Name, name) {
return a, nil
}
for _, alias := range a.Aliases {
if equalFold(alias, name) {
return a, nil
}
}
}
return nil, fmt.Errorf("unknown agent %q (supported: claude-desktop, cursor, windsurf)", name)
}
// equalFold is a tiny case-insensitive string compare without
// pulling in strings.EqualFold's full Unicode logic — agent names
// are ASCII.
func equalFold(a, b string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i < len(a); i++ {
ca, cb := a[i], b[i]
if ca >= 'A' && ca <= 'Z' {
ca += 32
}
if cb >= 'A' && cb <= 'Z' {
cb += 32
}
if ca != cb {
return false
}
}
return true
}
func claudeDesktopPathFor(home, goos string) (string, error) {
switch goos {
case "darwin":
return filepath.Join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"), nil
case "windows":
appData := os.Getenv("APPDATA")
if appData == "" {
return "", errors.New("APPDATA env var not set; cannot resolve Claude Desktop config path")
}
return filepath.Join(appData, "Claude", "claude_desktop_config.json"), nil
default: // linux + bsd + others
return filepath.Join(home, ".config", "Claude", "claude_desktop_config.json"), nil
}
}
func cursorPathFor(home, _ string) (string, error) {
// Cursor uses ~/.cursor/mcp.json on every platform.
return filepath.Join(home, ".cursor", "mcp.json"), nil
}
func windsurfPathFor(home, goos string) (string, error) {
if goos == "windows" {
appData := os.Getenv("APPDATA")
if appData == "" {
return "", errors.New("APPDATA env var not set; cannot resolve Windsurf config path")
}
return filepath.Join(appData, "Codeium", "windsurf", "mcp_config.json"), nil
}
return filepath.Join(home, ".codeium", "windsurf", "mcp_config.json"), nil
}
// AddPadEntry reads (or creates) the JSON config at path, ensures
// mcpServers.pad points to `binary`, and writes the file back.
// Existing entries outside `mcpServers.pad` are preserved.
//
// Returns (modified=true, nil) when the on-disk content actually
// changed, (false, nil) when the file was already up to date.
func AddPadEntry(path, binary string) (bool, error) {
if binary == "" {
return false, errors.New("AddPadEntry: binary path is required")
}
cfg, err := loadJSONConfig(path)
if err != nil {
return false, err
}
wantedEntry := map[string]any{
"command": binary,
"args": []any{"mcp", "serve"},
}
servers, _ := cfg["mcpServers"].(map[string]any)
if servers == nil {
servers = map[string]any{}
}
current, hadPad := servers[MCPServerKey].(map[string]any)
if hadPad && jsonEqual(current, wantedEntry) {
// Idempotent path: content already correct, no rewrite.
// Still tighten perms — a previous tool / manual edit may
// have left the file 0644, and the security-tightening
// guarantee should not depend on whether content changed.
// Codex round 2 (TASK-948) caught this gap.
tightenPerms(path)
return false, nil
}
servers[MCPServerKey] = wantedEntry
cfg["mcpServers"] = servers
if err := writeJSONConfig(path, cfg); err != nil {
return false, err
}
return true, nil
}
// tightenPerms sets path to 0600 if it exists. Best-effort: errors
// only emit a warning to stderr (the user's primary intent — install /
// no-op — already succeeded). Used by both the write-modified and
// idempotent code paths so the security claim ("config is 0600 after
// install") holds regardless of whether content changed.
func tightenPerms(path string) {
if _, err := os.Stat(path); err != nil {
return // nothing to tighten
}
if err := os.Chmod(path, 0o600); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to chmod 0600 %s: %v\n", path, err)
}
}
// RemovePadEntry deletes mcpServers.pad while leaving other servers +
// top-level keys intact. Returns (true, nil) when an entry was
// removed, (false, nil) when there was nothing to do (file missing,
// no mcpServers key, or no `pad` entry).
func RemovePadEntry(path string) (bool, error) {
if _, err := os.Stat(path); err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("stat %s: %w", path, err)
}
cfg, err := loadJSONConfig(path)
if err != nil {
return false, err
}
servers, ok := cfg["mcpServers"].(map[string]any)
if !ok {
return false, nil
}
if _, exists := servers[MCPServerKey]; !exists {
return false, nil
}
delete(servers, MCPServerKey)
cfg["mcpServers"] = servers
if err := writeJSONConfig(path, cfg); err != nil {
return false, err
}
return true, nil
}
// HasPadEntry returns whether mcpServers.pad is present, plus the
// current `command` value (empty string when missing). Missing files
// are reported as not installed without error.
func HasPadEntry(path string) (installed bool, command string, err error) {
if _, statErr := os.Stat(path); statErr != nil {
if errors.Is(statErr, os.ErrNotExist) {
return false, "", nil
}
return false, "", fmt.Errorf("stat %s: %w", path, statErr)
}
cfg, err := loadJSONConfig(path)
if err != nil {
return false, "", err
}
servers, _ := cfg["mcpServers"].(map[string]any)
if servers == nil {
return false, "", nil
}
entry, ok := servers[MCPServerKey].(map[string]any)
if !ok {
return false, "", nil
}
cmd, _ := entry["command"].(string)
return true, cmd, nil
}
// loadJSONConfig reads path as a JSON object. A missing or
// whitespace-only file is treated as an empty config (so AddPadEntry
// can populate from scratch on first install). Callers that need to
// distinguish "missing" from "empty" check os.Stat separately.
//
// Read or parse errors on a non-empty file propagate verbatim — we
// never silently overwrite a corrupt config; the user has to fix or
// remove it.
func loadJSONConfig(path string) (map[string]any, error) {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return map[string]any{}, nil
}
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
if isAllWhitespace(b) {
return map[string]any{}, nil
}
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if raw == nil {
raw = map[string]any{}
}
return raw, nil
}
func isAllWhitespace(b []byte) bool {
for _, c := range b {
if c != ' ' && c != '\t' && c != '\n' && c != '\r' {
return false
}
}
return true
}
func writeJSONConfig(path string, cfg map[string]any) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
// 0o600 — config files often hold credentials for OTHER MCP
// servers (postgres URIs, OpenAI keys, etc.); tighten perms even
// though pad's own entry has no secrets.
//
// os.WriteFile only honors the mode when CREATING the file — an
// existing 0644 config keeps 0644 after the write. Codex caught
// this on TASK-948 round 1, so we Chmod after writing. Best-effort:
// chmod failures don't fail the install (the data write
// succeeded; the user can re-tighten manually).
if err := os.WriteFile(path, append(b, '\n'), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
tightenPerms(path)
return nil
}
// jsonEqual compares two parsed-JSON maps for value equality. Cheaper
// than reflect.DeepEqual and accepts the float/string nuances of
// json.Unmarshal output.
func jsonEqual(a, b map[string]any) bool {
if len(a) != len(b) {
return false
}
ab, _ := json.Marshal(a)
bb, _ := json.Marshal(b)
if len(ab) == 0 || len(bb) == 0 {
return false
}
// Cheap-but-correct: round-trip both and compare the canonical
// JSON byte form. Order is deterministic per encoding/json.
return string(ab) == string(bb)
}
// Installer is the high-level façade over AddPadEntry / RemovePadEntry,
// resolving config paths from per-agent rules and the user's home dir.
//
// Production callers leave Home/GOOS empty so the installer queries
// the runtime; tests inject explicit values to point at a tempdir.
type Installer struct {
// Binary is the pad executable to register. Required for Install.
Binary string
// Home overrides os.UserHomeDir when non-empty (test-only).
Home string
// GOOS overrides runtime.GOOS when non-empty (test-only).
GOOS string
}
// AgentStatus is one row of Installer.Status output.
type AgentStatus struct {
Name string
Label string
ConfigPath string // empty when path resolution failed
Installed bool
Command string // current `command` value, when installed
// Error captures any non-fatal issue (path resolution failed,
// config file unreadable). Status keeps reporting on success of
// other agents even if one row errors.
Error string
}
func (i *Installer) home() (string, error) {
if i.Home != "" {
return i.Home, nil
}
return os.UserHomeDir()
}
func (i *Installer) goos() string {
if i.GOOS != "" {
return i.GOOS
}
return runtime.GOOS
}
// ResolvePath returns the absolute config path for the given agent
// using the installer's home + goos overrides.
func (i *Installer) ResolvePath(agent *Agent) (string, error) {
home, err := i.home()
if err != nil {
return "", err
}
if agent.PathFor == nil {
return "", fmt.Errorf("agent %q has no PathFor resolver", agent.Name)
}
return agent.PathFor(home, i.goos())
}
// Install adds (or refreshes) the pad entry in the named agent's
// config. Returns the resolved path + whether the file was actually
// modified (false on a no-op refresh).
func (i *Installer) Install(agentName string) (string, bool, error) {
if i.Binary == "" {
return "", false, errors.New("Installer.Binary is required")
}
agent, err := FindAgent(agentName)
if err != nil {
return "", false, err
}
path, err := i.ResolvePath(agent)
if err != nil {
return "", false, err
}
modified, err := AddPadEntry(path, i.Binary)
return path, modified, err
}
// Uninstall removes the pad entry from the named agent's config.
// Idempotent: a missing file or missing entry is not an error and
// returns (path, false, nil).
func (i *Installer) Uninstall(agentName string) (string, bool, error) {
agent, err := FindAgent(agentName)
if err != nil {
return "", false, err
}
path, err := i.ResolvePath(agent)
if err != nil {
return "", false, err
}
removed, err := RemovePadEntry(path)
return path, removed, err
}
// Status walks every supported agent and reports installation state.
// Per-agent failures are captured in AgentStatus.Error rather than
// short-circuiting the whole report.
func (i *Installer) Status() []AgentStatus {
out := make([]AgentStatus, 0, len(SupportedAgents))
for idx := range SupportedAgents {
agent := &SupportedAgents[idx]
row := AgentStatus{Name: agent.Name, Label: agent.Label}
path, err := i.ResolvePath(agent)
if err != nil {
row.Error = err.Error()
out = append(out, row)
continue
}
row.ConfigPath = path
installed, cmd, err := HasPadEntry(path)
if err != nil {
row.Error = err.Error()
}
row.Installed = installed
row.Command = cmd
out = append(out, row)
}
return out
}