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
2 changes: 1 addition & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ RESOURCES
spec {init,list,show,status,generate,approve,validate,test-report,delete}
skill {create,list,show,add-reference,add-script,add-asset,validate,delete}
agent {create,list,show,delete}
mcp {add,list,show,remove,enable,disable,validate}
mcp {add,install,presets,list,show,remove,enable,disable,validate}
export {kiro,codex} Convert the workspace to Kiro / Codex format.
web Launch a read-only web dashboard (live spec progress + file viewer).

Expand Down
4 changes: 4 additions & 0 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ func runMCP(args []string) int {
switch action {
case "add":
return mcpAdd(rest)
case "install":
return mcpInstall(rest)
case "presets":
return mcpPresets(rest)
case "list":
return mcpList(rest)
case "show":
Expand Down
157 changes: 157 additions & 0 deletions internal/cli/mcp_presets.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package cli

import (
"flag"
"fmt"
"sort"
"strings"

"github.com/protonspy/csdd/internal/render"
)

// Preset is a named, pre-filled MCP server configuration that `mcp install`
// expands into an MCPAddOptions and hands to MCPAdd. Adding a preset is a single
// registry entry — there is no separate write path, so duplicate detection,
// transport validation, and the on-disk shape are identical to a manual add.
type Preset struct {
Name string // server name written to .mcp.json (kebab-case)
Summary string // one-line description shown by `mcp presets`
Transport string // display label: "http" | "sse" | "stdio"
Command string // stdio only
Args []string // stdio only
URL string // remote only
Type string // remote only: "sse" | "http"
Note string // optional hint emitted after install (e.g. auth caveat)
}

// mcpPresetRegistry holds the known servers `mcp install` can register. Keep the
// entries secret-free: a preset stores only non-sensitive connection details.
var mcpPresetRegistry = map[string]Preset{
"context7": {
Name: "context7",
Summary: "Up-to-date library/API docs.",
Transport: "http",
URL: "https://mcp.context7.com/mcp",
Type: "http",
},
"playwright": {
Name: "playwright",
Summary: "Browser automation for frontend e2e/QA.",
Transport: "stdio",
Command: "npx",
Args: []string{"@playwright/mcp@latest"},
},
"github": {
Name: "github",
Summary: "GitHub repos, PRs, issues, Actions.",
Transport: "http",
URL: "https://api.githubcopilot.com/mcp/",
Type: "http",
Note: "Requires GitHub auth — authenticate via your client's OAuth on first use (or add a PAT). No token is stored by csdd.",
},
}

// MCPPresets returns every preset sorted by name. The CLI listing and the TUI
// preset picker both read this, so neither can drift from the registry.
func MCPPresets() []Preset {
out := make([]Preset, 0, len(mcpPresetRegistry))
for _, p := range mcpPresetRegistry {
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}

// presetNames returns the preset names, sorted — used in error messages. It
// projects from MCPPresets() so there is a single sort path.
func presetNames() []string {
presets := MCPPresets()
names := make([]string, len(presets))
for i, p := range presets {
names[i] = p.Name
}
return names
}

// MCPInstallPresetOptions is the headless input shared by the CLI and the TUI.
type MCPInstallPresetOptions struct {
Root string
Names []string
Force bool
}

// MCPInstallPreset expands each named preset to an MCPAddOptions and installs it
// via MCPAdd. Every name is validated before any write, so an unknown name in a
// multi-install leaves .mcp.json untouched. Each successful add prints its own
// render.OK; a preset Note is surfaced afterwards.
func MCPInstallPreset(opts MCPInstallPresetOptions) error {
if len(opts.Names) == 0 {
return fmt.Errorf("no preset name given")
}
for _, name := range opts.Names {
if _, ok := mcpPresetRegistry[name]; !ok {
return fmt.Errorf("unknown preset: %s (available: %s)", name, strings.Join(presetNames(), ", "))
}
}
for _, name := range opts.Names {
p := mcpPresetRegistry[name]
add := MCPAddOptions{
Root: opts.Root,
Name: p.Name,
Command: p.Command,
Args: p.Args,
URL: p.URL,
Type: p.Type,
Force: opts.Force,
}
if err := MCPAdd(add); err != nil {
return err
}
if p.Note != "" {
render.Info(p.Note)
}
}
return nil
}

func mcpInstall(args []string) int {
fs := flag.NewFlagSet("mcp install", flag.ContinueOnError)
var opts MCPInstallPresetOptions
addRoot(fs, &opts.Root)
addForce(fs, &opts.Force)
positionals, err := parseFlags(fs, args)
if err != nil {
return failOnFlagParse(err)
}
if len(positionals) < 1 {
render.Err("usage: " + prog() + " mcp install NAME [NAME...] [--force]")
return 1
}
opts.Names = positionals
if err := MCPInstallPreset(opts); err != nil {
render.Err(err.Error())
return 1
}
return 0
}

func mcpPresets(args []string) int {
fs := flag.NewFlagSet("mcp presets", flag.ContinueOnError)
var root string
addRoot(fs, &root) // accepted for surface symmetry; the registry is static.
if _, err := parseFlags(fs, args); err != nil {
return failOnFlagParse(err)
}
presets := MCPPresets()
maxName := len("name")
for _, p := range presets {
if len(p.Name) > maxName {
maxName = len(p.Name)
}
}
fmt.Printf(" %-*s %-7s %s\n", maxName, "name", "type", "summary")
for _, p := range presets {
fmt.Printf(" %-*s %-7s %s\n", maxName, p.Name, p.Transport, p.Summary)
}
return 0
}
213 changes: 213 additions & 0 deletions internal/cli/mcp_presets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package cli

import (
"strings"
"testing"
)

// ---- registry accessor ----

func TestMCPPresetsAccessor(t *testing.T) {
got := MCPPresets()
if len(got) != 3 {
t.Fatalf("expected 3 presets, got %d: %+v", len(got), got)
}
// Name-sorted: context7 < github < playwright.
wantOrder := []string{"context7", "github", "playwright"}
for i, p := range got {
if p.Name != wantOrder[i] {
t.Errorf("preset[%d] = %q, want %q (must be name-sorted)", i, p.Name, wantOrder[i])
}
if p.Summary == "" || p.Transport == "" {
t.Errorf("preset %q missing summary/transport: %+v", p.Name, p)
}
}
}

// ---- install: per-preset transport correctness ----

func TestMCPInstallContext7(t *testing.T) {
dir := freshWorkspace(t)
if code, _, errOut := run(t, "mcp", "install", "context7", "--root", dir); code != 0 {
t.Fatalf("install context7 should succeed: %s", errOut)
}
srv := loadServer(t, dir, "context7")
if srv.URL != "https://mcp.context7.com/mcp" || srv.Type != "http" {
t.Errorf("context7 remote config wrong: %+v", srv)
}
if srv.Command != "" {
t.Errorf("remote preset must not carry a command: %+v", srv)
}
}

func TestMCPInstallPlaywright(t *testing.T) {
dir := freshWorkspace(t)
if code, _, errOut := run(t, "mcp", "install", "playwright", "--root", dir); code != 0 {
t.Fatalf("install playwright should succeed: %s", errOut)
}
srv := loadServer(t, dir, "playwright")
if srv.Command != "npx" || len(srv.Args) != 1 || srv.Args[0] != "@playwright/mcp@latest" {
t.Errorf("playwright stdio config wrong: %+v", srv)
}
if srv.URL != "" || srv.Type != "" {
t.Errorf("stdio preset must not carry url/type: %+v", srv)
}
}

func TestMCPInstallGithub(t *testing.T) {
dir := freshWorkspace(t)
if code, _, errOut := run(t, "mcp", "install", "github", "--root", dir); code != 0 {
t.Fatalf("install github should succeed: %s", errOut)
}
srv := loadServer(t, dir, "github")
if srv.URL != "https://api.githubcopilot.com/mcp/" || srv.Type != "http" {
t.Errorf("github remote config wrong: %+v", srv)
}
if srv.Command != "" || len(srv.Env) != 0 {
t.Errorf("github preset must store no command and no secret env: %+v", srv)
}
}

func TestMCPInstallMultiple(t *testing.T) {
dir := freshWorkspace(t)
if code, _, errOut := run(t, "mcp", "install", "context7", "playwright", "github", "--root", dir); code != 0 {
t.Fatalf("multi-install should succeed: %s", errOut)
}
cfg, _ := loadMCP(mcpJSONPath(dir))
for _, name := range []string{"context7", "playwright", "github"} {
if _, ok := cfg.MCPServers[name]; !ok {
t.Errorf("expected %q registered after multi-install", name)
}
}
}

// ---- install: error paths ----

func TestMCPInstallUnknown(t *testing.T) {
dir := freshWorkspace(t)
code, _, errOut := run(t, "mcp", "install", "nope", "--root", dir)
if code != 1 {
t.Fatalf("unknown preset should exit 1, got %d", code)
}
if !strings.Contains(errOut, "unknown preset") {
t.Errorf("error should mention 'unknown preset': %q", errOut)
}
for _, name := range []string{"context7", "playwright", "github"} {
if !strings.Contains(errOut, name) {
t.Errorf("error should list valid preset %q: %q", name, errOut)
}
}
cfg, _ := loadMCP(mcpJSONPath(dir))
if _, ok := cfg.MCPServers["nope"]; ok {
t.Error("unknown preset must not write any entry")
}
}

func TestMCPInstallUnknownInMultiWritesNothing(t *testing.T) {
dir := freshWorkspace(t)
if code, _, _ := run(t, "mcp", "install", "context7", "nope", "--root", dir); code != 1 {
t.Fatalf("a single unknown name should reject the whole call (exit 1)")
}
cfg, _ := loadMCP(mcpJSONPath(dir))
if _, ok := cfg.MCPServers["context7"]; ok {
t.Error("no entry should be written when any name is unknown")
}
}

func TestMCPInstallDuplicateInMultiIsNotAtomic(t *testing.T) {
// Documented contract: name validation is up front, but installs go through
// MCPAdd per preset — so a *duplicate* later in the list does not roll back
// earlier successful adds (mirrors running two `mcp add` calls).
dir := freshWorkspace(t)
if code, _, _ := run(t, "mcp", "install", "github", "--root", dir); code != 0 {
t.Fatal("seed install of github should succeed")
}
// context7 is new, github already exists -> context7 lands, github errors.
if code, _, _ := run(t, "mcp", "install", "context7", "github", "--root", dir); code != 1 {
t.Fatalf("a duplicate later in the list should exit 1")
}
cfg, _ := loadMCP(mcpJSONPath(dir))
if _, ok := cfg.MCPServers["context7"]; !ok {
t.Error("the earlier (new) preset should have been written before the duplicate failed")
}
}

func TestMCPInstallMissingName(t *testing.T) {
dir := freshWorkspace(t)
if code, _, _ := run(t, "mcp", "install", "--root", dir); code != 1 {
t.Error("install without a preset name should exit 1")
}
}

func TestMCPInstallDuplicateNeedsForce(t *testing.T) {
dir := freshWorkspace(t)
if code, _, _ := run(t, "mcp", "install", "context7", "--root", dir); code != 0 {
t.Fatal("first install should succeed")
}
if code, _, _ := run(t, "mcp", "install", "context7", "--root", dir); code == 0 {
t.Error("duplicate install without --force should fail")
}
if code, _, _ := run(t, "mcp", "install", "context7", "--force", "--root", dir); code != 0 {
t.Error("duplicate install with --force should replace")
}
srv := loadServer(t, dir, "context7")
if srv.URL == "" || srv.Type != "http" {
t.Errorf("forced reinstall should keep the context7 remote config: %+v", srv)
}
}

func TestMCPInstallForceReplacesManualEntry(t *testing.T) {
dir := freshWorkspace(t)
// Pre-add a *different* (stdio) server under the same name.
if code, _, _ := run(t, "mcp", "add", "context7", "--command", "foo", "--root", dir); code != 0 {
t.Fatal("manual add should succeed")
}
if code, _, _ := run(t, "mcp", "install", "context7", "--force", "--root", dir); code != 0 {
t.Fatal("forced install should replace the manual entry")
}
srv := loadServer(t, dir, "context7")
if srv.URL != "https://mcp.context7.com/mcp" || srv.Type != "http" || srv.Command != "" {
t.Errorf("force install should fully overwrite with the preset config: %+v", srv)
}
}

// ---- install: note emission ----

func TestMCPInstallGithubEmitsAuthNote(t *testing.T) {
dir := freshWorkspace(t)
code, out, _ := run(t, "mcp", "install", "github", "--root", dir)
if code != 0 {
t.Fatal("install github should succeed")
}
if !strings.Contains(strings.ToLower(out), "auth") {
t.Errorf("github install should surface an auth note:\n%s", out)
}
}

// ---- presets listing ----

func TestMCPPresetsLists(t *testing.T) {
code, out, _ := run(t, "mcp", "presets")
if code != 0 {
t.Fatalf("mcp presets should exit 0, got %d", code)
}
for _, want := range []string{"name", "type", "summary", "context7", "playwright", "github", "http", "stdio"} {
if !strings.Contains(out, want) {
t.Errorf("presets listing missing %q:\n%s", want, out)
}
}
}

// loadServer reads .mcp.json and returns the named server, failing if absent.
func loadServer(t *testing.T, dir, name string) MCPServer {
t.Helper()
cfg, err := loadMCP(mcpJSONPath(dir))
if err != nil {
t.Fatal(err)
}
srv, ok := cfg.MCPServers[name]
if !ok {
t.Fatalf("server %q not found in %+v", name, cfg.MCPServers)
}
return srv
}
Loading
Loading