Skip to content

sap-devs mcp — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add sap-devs mcp with list, install, and status subcommands so developers can discover, install, and verify SAP MCP server entries in their AI tool configs.

Architecture: Add content helpers (FlattenMCPServers, FindMCPServer) in internal/content/mcp.go and a Detect function in internal/adapter/detect.go; add ReadMCPConfig alongside the existing WriteMCPConfig; extract multi-layer adapter loading from newAdapterEngine into a reusable loadAdapters() helper in cmd/root.go; then wire everything together in a thin cmd/mcp.go presentation layer. No new dependencies.

Tech Stack: Go 1.26, github.com/spf13/cobra (existing), github.com/stretchr/testify (existing)

Note on local testing: go test is blocked by Windows Defender on this machine (test binaries land in %TEMP%). Use go build ./... + go vet ./... for local verification. CI runs go test ./... on ubuntu-latest.


File Map

FileActionPurpose
internal/content/pack.goModifyAdd PackID string to MCPServer; set it in LoadPack
internal/content/mcp.goCreateFlattenMCPServers, FindMCPServer
internal/content/mcp_test.goCreateUnit tests for content helpers
internal/adapter/detect.goCreateDetect(a Adapter) bool
internal/adapter/detect_test.goCreateUnit tests for Detect
internal/adapter/mcp_wire.goModifyAdd ReadMCPConfig
internal/adapter/mcp_wire_test.goModifyAdd ReadMCPConfig tests
cmd/root.goModifyExtract loadAdapters() from newAdapterEngine
cmd/mcp.goCreatemcp, mcp list, mcp status, mcp install subcommands

Task 1: Add PackID to MCPServer and content helpers (TDD)

Files:

  • Modify: internal/content/pack.go
  • Create: internal/content/mcp_test.go
  • Create: internal/content/mcp.go

MCPServer has no PackID field yet. Like Resource, it needs one set at load time so mcp list can display which pack owns each server. Add the field, set it in LoadPack, then write FlattenMCPServers and FindMCPServer.

  • [ ] Step 1: Write the failing tests

Create internal/content/mcp_test.go:

go
package content_test

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/SAP-samples/sap-devs-cli/internal/content"
)

func mcpFixturePacks() []*content.Pack {
	return []*content.Pack{
		{
			ID: "cap",
			MCPServers: []content.MCPServer{
				{ID: "cap-mcp", Name: "CAP MCP Server", Hosts: []string{"claude-code"}, PackID: "cap"},
			},
		},
		{
			ID: "abap",
			MCPServers: []content.MCPServer{
				{ID: "abap-mcp", Name: "ABAP MCP Server", Hosts: []string{"cursor"}, PackID: "abap"},
			},
		},
	}
}

func TestFlattenMCPServers(t *testing.T) {
	got := content.FlattenMCPServers(mcpFixturePacks())
	require.Len(t, got, 2)
	assert.Equal(t, "cap-mcp", got[0].ID)
	assert.Equal(t, "cap", got[0].PackID)
	assert.Equal(t, "abap-mcp", got[1].ID)
	assert.Equal(t, "abap", got[1].PackID)
}

func TestFlattenMCPServers_Empty(t *testing.T) {
	got := content.FlattenMCPServers([]*content.Pack{{ID: "empty"}})
	assert.Empty(t, got)
}

func TestFindMCPServer_Found(t *testing.T) {
	got := content.FindMCPServer(mcpFixturePacks(), "abap-mcp")
	require.NotNil(t, got)
	assert.Equal(t, "ABAP MCP Server", got.Name)
}

func TestFindMCPServer_NotFound(t *testing.T) {
	got := content.FindMCPServer(mcpFixturePacks(), "nonexistent")
	assert.Nil(t, got)
}

func TestLoadPackSetsMCPPackID(t *testing.T) {
	dir := t.TempDir()

	require.NoError(t, os.WriteFile(filepath.Join(dir, "pack.yaml"), []byte(`
id: mypak
name: My Pack
description: Test pack
`), 0644))

	require.NoError(t, os.WriteFile(filepath.Join(dir, "mcp.yaml"), []byte(`
- id: mypak-mcp
  name: My MCP Server
  install:
    command: npx
    args: ["-y", "my-mcp"]
  hosts: [claude-code]
`), 0644))

	pack, err := content.LoadPack(dir)
	require.NoError(t, err)
	require.Len(t, pack.MCPServers, 1)
	assert.Equal(t, "mypak", pack.MCPServers[0].PackID)
}
  • [ ] Step 2: Verify tests fail to compile

Run: go build ./... Expected: compile error — content.FlattenMCPServers undefined and content.MCPServer has no PackID field.

  • [ ] Step 3: Add PackID string to MCPServer in internal/content/pack.go

Find the MCPServer struct (around line 53) and add the field:

go
// MCPServer defines an installable MCP server for this pack's domain.
type MCPServer struct {
	ID          string     `yaml:"id"`
	Name        string     `yaml:"name"`
	Description string     `yaml:"description"`
	Install     MCPInstall `yaml:"install"`
	Hosts       []string   `yaml:"hosts"`
	PackID      string     // set at load time, not in YAML
}

Then in LoadPack, after the mcp.yaml unmarshal block, add the same pattern used for Resources:

The existing block is:

go
if data, err := os.ReadFile(filepath.Join(packDir, "mcp.yaml")); err == nil {
    _ = yaml.Unmarshal(data, &pack.MCPServers)
}

Replace it with:

go
if data, err := os.ReadFile(filepath.Join(packDir, "mcp.yaml")); err == nil {
    _ = yaml.Unmarshal(data, &pack.MCPServers)
    for i := range pack.MCPServers {
        pack.MCPServers[i].PackID = pack.ID
    }
}
  • [ ] Step 4: Create internal/content/mcp.go
go
package content

// FlattenMCPServers returns all MCPServer entries from all packs in order.
func FlattenMCPServers(packs []*Pack) []MCPServer {
	var out []MCPServer
	for _, p := range packs {
		out = append(out, p.MCPServers...)
	}
	return out
}

// FindMCPServer returns the first MCPServer with the given ID across packs, or nil.
func FindMCPServer(packs []*Pack, id string) *MCPServer {
	for _, p := range packs {
		for i := range p.MCPServers {
			if p.MCPServers[i].ID == id {
				return &p.MCPServers[i]
			}
		}
	}
	return nil
}
  • [ ] Step 5: Verify build

Run: go build ./... Expected: clean.

  • [ ] Step 6: Verify vet

Run: go vet ./... Expected: clean.

  • [ ] Step 7: Commit
bash
git add internal/content/pack.go internal/content/mcp.go internal/content/mcp_test.go
git commit -m "feat: add MCPServer.PackID and FlattenMCPServers/FindMCPServer helpers"

Task 2: Add Detect function to adapter package (TDD)

Files:

  • Create: internal/adapter/detect_test.go
  • Create: internal/adapter/detect.go

Detect(a Adapter) bool runs each DetectRule in an adapter and returns true if any rule passes. Command rules check exit code; path rules check filesystem existence.

  • [ ] Step 1: Write the failing tests

Create internal/adapter/detect_test.go:

go
package adapter_test

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/SAP-samples/sap-devs-cli/internal/adapter"
)

func TestDetect_Empty(t *testing.T) {
	a := adapter.Adapter{ID: "test", Detect: nil}
	assert.False(t, adapter.Detect(a))
}

func TestDetect_PathRule_Exists(t *testing.T) {
	dir := t.TempDir()
	file := filepath.Join(dir, "tool")
	require.NoError(t, os.WriteFile(file, []byte{}, 0644))

	a := adapter.Adapter{
		ID:     "test",
		Detect: []adapter.DetectRule{{Path: file}},
	}
	assert.True(t, adapter.Detect(a))
}

func TestDetect_PathRule_Missing(t *testing.T) {
	a := adapter.Adapter{
		ID:     "test",
		Detect: []adapter.DetectRule{{Path: "/sap-devs-nonexistent-path-xyz/tool"}},
	}
	assert.False(t, adapter.Detect(a))
}

func TestDetect_CommandRule_Success(t *testing.T) {
	// "go version" is always available in CI and on dev machines with Go installed
	a := adapter.Adapter{
		ID:     "test",
		Detect: []adapter.DetectRule{{Command: "go version"}},
	}
	assert.True(t, adapter.Detect(a))
}

func TestDetect_CommandRule_Fail(t *testing.T) {
	a := adapter.Adapter{
		ID:     "test",
		Detect: []adapter.DetectRule{{Command: "sap-devs-nonexistent-binary-xyz"}},
	}
	assert.False(t, adapter.Detect(a))
}

func TestDetect_AnyPassesReturnsTrue(t *testing.T) {
	dir := t.TempDir()
	file := filepath.Join(dir, "exists")
	require.NoError(t, os.WriteFile(file, []byte{}, 0644))

	a := adapter.Adapter{
		ID: "test",
		Detect: []adapter.DetectRule{
			{Command: "sap-devs-nonexistent-binary-xyz"}, // fails
			{Path: file},                                  // passes
		},
	}
	assert.True(t, adapter.Detect(a))
}
  • [ ] Step 2: Verify tests fail to compile

Run: go build ./... Expected: compile error — adapter.Detect undefined.

  • [ ] Step 3: Create internal/adapter/detect.go
go
package adapter

import (
	"os"
	"os/exec"
	"strings"
)

// Detect returns true if the adapter is present on this machine.
// It iterates the adapter's Detect rules and returns true on the first passing rule.
// A "command" rule passes if the command exits with code 0.
// A "path" rule passes if the expanded path exists on the filesystem.
// Returns false if Detect is empty or all rules fail.
func Detect(a Adapter) bool {
	for _, rule := range a.Detect {
		if rule.Command != "" {
			parts := strings.Fields(rule.Command)
			if len(parts) == 0 {
				continue
			}
			if err := exec.Command(parts[0], parts[1:]...).Run(); err == nil {
				return true
			}
		}
		if rule.Path != "" {
			expanded, err := ExpandHome(rule.Path)
			if err != nil {
				continue
			}
			if _, err := os.Stat(expanded); err == nil {
				return true
			}
		}
	}
	return false
}
  • [ ] Step 4: Verify build

Run: go build ./... Expected: clean.

  • [ ] Step 5: Verify vet

Run: go vet ./... Expected: clean.

  • [ ] Step 6: Commit
bash
git add internal/adapter/detect.go internal/adapter/detect_test.go
git commit -m "feat: add adapter.Detect for tool presence detection"

Task 3: Add ReadMCPConfig to mcp_wire.go (TDD)

Files:

  • Modify: internal/adapter/mcp_wire_test.go
  • Modify: internal/adapter/mcp_wire.go

ReadMCPConfig is the read-counterpart to WriteMCPConfig. It reads the server map from a JSON settings file. A missing file is not an error — it returns an empty map.

  • [ ] Step 1: Append the new tests to internal/adapter/mcp_wire_test.go

Add after the existing TestWriteMCPConfig_BadKeyType test:

go
func TestReadMCPConfig_Missing(t *testing.T) {
	dir := t.TempDir()
	settingsPath := filepath.Join(dir, "settings.json")

	m, err := adapter.ReadMCPConfig(settingsPath, "mcpServers")
	require.NoError(t, err)
	assert.Empty(t, m)
}

func TestReadMCPConfig_Present(t *testing.T) {
	dir := t.TempDir()
	settingsPath := filepath.Join(dir, "settings.json")
	require.NoError(t, os.WriteFile(settingsPath, []byte(
		`{"mcpServers":{"cap-mcp":{"command":"npx","args":["-y","@sap/cap-mcp-server"]}}}`,
	), 0644))

	m, err := adapter.ReadMCPConfig(settingsPath, "mcpServers")
	require.NoError(t, err)
	require.Len(t, m, 1)
	_, ok := m["cap-mcp"]
	assert.True(t, ok)
}

func TestReadMCPConfig_KeyAbsent(t *testing.T) {
	dir := t.TempDir()
	settingsPath := filepath.Join(dir, "settings.json")
	require.NoError(t, os.WriteFile(settingsPath, []byte(`{"theme":"dark"}`), 0644))

	m, err := adapter.ReadMCPConfig(settingsPath, "mcpServers")
	require.NoError(t, err)
	assert.Empty(t, m)
}

func TestReadMCPConfig_BadKeyType(t *testing.T) {
	dir := t.TempDir()
	settingsPath := filepath.Join(dir, "settings.json")
	require.NoError(t, os.WriteFile(settingsPath, []byte(`{"mcpServers":"not-an-object"}`), 0644))

	_, err := adapter.ReadMCPConfig(settingsPath, "mcpServers")
	require.Error(t, err)
	assert.Contains(t, err.Error(), "not a JSON object")
}
  • [ ] Step 2: Verify tests fail to compile

Run: go build ./... Expected: compile error — adapter.ReadMCPConfig undefined.

  • [ ] Step 3: Append ReadMCPConfig to internal/adapter/mcp_wire.go

Add after the closing brace of WriteMCPConfig:

go
// ReadMCPConfig reads the mcpServers map from a JSON settings file.
// Returns an empty map (not an error) if the file does not exist or the key is absent.
// Returns an error if the file exists but cannot be parsed, or if the key is not a JSON object.
func ReadMCPConfig(settingsPath, key string) (map[string]interface{}, error) {
	data, err := os.ReadFile(settingsPath)
	if os.IsNotExist(err) {
		return map[string]interface{}{}, nil
	}
	if err != nil {
		return nil, err
	}
	var root map[string]interface{}
	if err := json.Unmarshal(data, &root); err != nil {
		return nil, fmt.Errorf("parse %s: %w", settingsPath, err)
	}
	v, ok := root[key]
	if !ok || v == nil {
		return map[string]interface{}{}, nil
	}
	m, ok := v.(map[string]interface{})
	if !ok {
		return nil, fmt.Errorf("key %q in %s is not a JSON object (got %T); cannot read", key, settingsPath, v)
	}
	return m, nil
}
  • [ ] Step 4: Verify build

Run: go build ./... Expected: clean.

  • [ ] Step 5: Verify vet

Run: go vet ./... Expected: clean.

  • [ ] Step 6: Commit
bash
git add internal/adapter/mcp_wire.go internal/adapter/mcp_wire_test.go
git commit -m "feat: add adapter.ReadMCPConfig for reading MCP settings files"

Task 4: Extract loadAdapters from root.go

Files:

  • Modify: cmd/root.go

cmd/mcp.go needs the full multi-layer adapter slice ([]adapter.Adapter), not an adapter.Engine. Extract the loading logic from newAdapterEngine into a new loadAdapters() helper, then have newAdapterEngine call it. No behaviour change — just a refactor.

  • [ ] Step 1: Read cmd/root.go to understand the current structure

newAdapterEngine (lines 58–92) currently:

  1. Calls xdg.New() and config.Load to get paths and config
  2. Loads official adapters from filepath.Join(paths.CacheDir, "official", "content", "adapters")
  3. Optionally loads company adapters and merges by ID
  4. Optionally loads dev-fallback adapters (SAP_DEVS_DEV=1) from "content/adapters"
  5. Returns adapter.NewEngine(allAdapters, renderedContext, opts)
  • [ ] Step 2: Add loadAdapters() to cmd/root.go

Insert the new function before newAdapterEngine. Replace the body of newAdapterEngine to call loadAdapters:

go
// loadAdapters returns the merged adapter list across all configured layers:
// official cache, optional company cache, and an optional SAP_DEVS_DEV=1 local fallback.
func loadAdapters() ([]adapter.Adapter, error) {
	paths, err := xdg.New()
	if err != nil {
		return nil, err
	}
	cfg, err := config.Load(paths.ConfigDir)
	if err != nil {
		return nil, err
	}

	var allAdapters []adapter.Adapter

	officialAdaptersDir := filepath.Join(paths.CacheDir, "official", "content", "adapters")
	if a, err := adapter.LoadAdapters(officialAdaptersDir); err == nil {
		allAdapters = append(allAdapters, a...)
	}

	if cfg.CompanyRepo != "" {
		companyAdaptersDir := filepath.Join(paths.CacheDir, "company", "content", "adapters")
		if a, err := adapter.LoadAdapters(companyAdaptersDir); err == nil {
			allAdapters = mergeAdapters(allAdapters, a)
		}
	}

	if os.Getenv("SAP_DEVS_DEV") == "1" {
		if a, err := adapter.LoadAdapters("content/adapters"); err == nil {
			allAdapters = mergeAdapters(allAdapters, a)
		}
	}

	return allAdapters, nil
}

// newAdapterEngine constructs an adapter engine from all configured adapter layers.
func newAdapterEngine(renderedContext string, opts adapter.Options) (*adapter.Engine, error) {
	allAdapters, err := loadAdapters()
	if err != nil {
		return nil, err
	}
	return adapter.NewEngine(allAdapters, renderedContext, opts), nil
}

The newAdapterEngine function no longer duplicates the adapter loading — it delegates to loadAdapters. The mergeAdapters helper stays unchanged.

  • [ ] Step 3: Verify build

Run: go build ./... Expected: clean.

  • [ ] Step 4: Verify vet

Run: go vet ./... Expected: clean.

  • [ ] Step 5: Commit
bash
git add cmd/root.go
git commit -m "refactor: extract loadAdapters from newAdapterEngine for reuse"

Task 5: Create cmd/mcp.go with mcp list and mcp status

Files:

  • Create: cmd/mcp.go

Create the mcp parent command plus the list and status subcommands. The install subcommand is added in Task 6.

The file uses these helpers defined in later steps and in this task:

  • printMCPTable — formats the list output

  • mcpWireAdapters — filters the flat adapter slice to mcp-wire adapters matching a host set

  • detectAdapters — applies adapter.Detect to filter to installed adapters

  • [ ] Step 1: Create cmd/mcp.go

go
package cmd

import (
	"fmt"
	"strings"

	"github.com/spf13/cobra"
	"github.com/SAP-samples/sap-devs-cli/internal/adapter"
	"github.com/SAP-samples/sap-devs-cli/internal/config"
	"github.com/SAP-samples/sap-devs-cli/internal/content"
	"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)

var mcpCmd = &cobra.Command{
	Use:   "mcp",
	Short: "Manage SAP MCP servers",
}

// --- mcp list ---

var mcpListAll bool

var mcpListCmd = &cobra.Command{
	Use:   "list",
	Short: "List available SAP MCP servers",
	RunE: func(cmd *cobra.Command, args []string) error {
		loader, err := newContentLoader()
		if err != nil {
			return err
		}
		var packs []*content.Pack
		if mcpListAll {
			packs, err = loader.LoadPacks(nil)
			if err != nil {
				return err
			}
		} else {
			paths, err2 := xdg.New()
			if err2 != nil {
				return err2
			}
			profileCfg, err2 := config.LoadProfile(paths.ConfigDir)
			if err2 != nil {
				return err2
			}
			if profileCfg.ID == "" {
				return fmt.Errorf("no profile set — run 'sap-devs profile set <name>' first")
			}
			activeProfile, err2 := loader.FindProfile(profileCfg.ID)
			if err2 != nil {
				return err2
			}
			if activeProfile == nil {
				return fmt.Errorf("profile %q not found — run 'sap-devs sync' to refresh content", profileCfg.ID)
			}
			packs, err = loader.LoadPacks(activeProfile)
			if err != nil {
				return err
			}
		}
		servers := content.FlattenMCPServers(packs)
		if len(servers) == 0 {
			fmt.Println("No MCP servers found for your current profile.")
			return nil
		}
		printMCPTable(servers)
		return nil
	},
}

func printMCPTable(servers []content.MCPServer) {
	fmt.Printf("%-24s %-12s %-28s %s\n", "ID", "PACK", "HOSTS", "NAME")
	fmt.Println(strings.Repeat("-", 80))
	for _, s := range servers {
		fmt.Printf("%-24s %-12s %-28s %s\n", s.ID, s.PackID, strings.Join(s.Hosts, ", "), s.Name)
	}
}

// --- mcp status ---

var mcpStatusCmd = &cobra.Command{
	Use:   "status",
	Short: "Show which SAP MCP servers are registered in your AI tool configs",
	RunE: func(cmd *cobra.Command, args []string) error {
		adapters, err := loadAdapters()
		if err != nil {
			return err
		}
		mcpAdapters := mcpWireAdapters(adapters, nil)
		loader, err := newContentLoader()
		if err != nil {
			return err
		}
		packs, err := loader.LoadPacks(nil)
		if err != nil {
			return err
		}
		servers := content.FlattenMCPServers(packs)
		if len(mcpAdapters) == 0 && len(servers) == 0 {
			fmt.Println("No MCP adapters or servers found.")
			return nil
		}

		// Build lookup: adapterID → registered server ID map
		registered := make(map[string]map[string]interface{})
		for _, a := range mcpAdapters {
			path, err := adapter.ExpandHome(a.MCPConfig.Path)
			if err != nil {
				continue
			}
			m, err := adapter.ReadMCPConfig(path, a.MCPConfig.Key)
			if err != nil {
				continue
			}
			registered[a.ID] = m
		}

		fmt.Printf("%-20s %-14s %s\n", "SERVER", "HOST", "STATUS")
		fmt.Println(strings.Repeat("-", 50))
		for _, s := range servers {
			for _, hostID := range s.Hosts {
				m, ok := registered[hostID]
				status := "not installed"
				if ok {
					if _, found := m[s.ID]; found {
						status = "installed"
					}
				}
				fmt.Printf("%-20s %-14s %s\n", s.ID, hostID, status)
			}
		}
		return nil
	},
}

// --- shared helpers ---

// mcpWireAdapters returns adapters of type "mcp-wire" with a non-nil MCPConfig.
// If hostSet is non-nil, only adapters whose ID is in hostSet are returned.
func mcpWireAdapters(adapters []adapter.Adapter, hostSet map[string]bool) []adapter.Adapter {
	var out []adapter.Adapter
	for _, a := range adapters {
		if a.Type != "mcp-wire" || a.MCPConfig == nil {
			continue
		}
		if hostSet != nil && !hostSet[a.ID] {
			continue
		}
		out = append(out, a)
	}
	return out
}

// detectAdapters filters adapters to those detected as installed on this machine.
func detectAdapters(adapters []adapter.Adapter) []adapter.Adapter {
	var out []adapter.Adapter
	for _, a := range adapters {
		if adapter.Detect(a) {
			out = append(out, a)
		}
	}
	return out
}

// containsString returns true if slice contains s.
func containsString(slice []string, s string) bool {
	for _, v := range slice {
		if v == s {
			return true
		}
	}
	return false
}

func init() {
	mcpListCmd.Flags().BoolVar(&mcpListAll, "all", false, "list servers from all packs (default: active profile only)")
	mcpCmd.AddCommand(mcpListCmd, mcpStatusCmd)
	rootCmd.AddCommand(mcpCmd)
}
  • [ ] Step 2: Verify build

Run: go build ./... Expected: clean.

  • [ ] Step 3: Verify vet

Run: go vet ./... Expected: clean.

  • [ ] Step 4: Commit
bash
git add cmd/mcp.go
git commit -m "feat: add mcp list and mcp status subcommands"

Task 6: Add mcp install subcommand to cmd/mcp.go

Files:

  • Modify: cmd/mcp.go

mcp install &lt;id&gt; detects installed compatible hosts, prompts the user to pick, and calls WriteMCPConfig. mcp install --all does the same for every server in the active profile, with a single host-selection prompt.

  • [ ] Step 1: Add the install imports to cmd/mcp.go

The current import block in cmd/mcp.go is:

go
import (
	"fmt"
	"strings"

	"github.com/spf13/cobra"
	"github.com/SAP-samples/sap-devs-cli/internal/adapter"
	"github.com/SAP-samples/sap-devs-cli/internal/config"
	"github.com/SAP-samples/sap-devs-cli/internal/content"
	"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)

Replace it with:

go
import (
	"bufio"
	"fmt"
	"os"
	"strconv"
	"strings"

	"github.com/spf13/cobra"
	"github.com/SAP-samples/sap-devs-cli/internal/adapter"
	"github.com/SAP-samples/sap-devs-cli/internal/config"
	"github.com/SAP-samples/sap-devs-cli/internal/content"
	"github.com/SAP-samples/sap-devs-cli/internal/xdg"
)
  • [ ] Step 2: Add the install command variables and command definition to cmd/mcp.go

Insert before the // --- shared helpers --- comment:

go
// --- mcp install ---

var mcpInstallAll bool
var mcpInstallDryRun bool

var mcpInstallCmd = &cobra.Command{
	Use:   "install [id]",
	Short: "Install and wire an SAP MCP server into your AI tools",
	Args:  cobra.MaximumNArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		if len(args) == 0 && !mcpInstallAll {
			return fmt.Errorf("specify a server ID or use --all")
		}
		if len(args) > 0 && mcpInstallAll {
			return fmt.Errorf("cannot use both a server ID and --all")
		}

		allAdapters, err := loadAdapters()
		if err != nil {
			return err
		}
		loader, err := newContentLoader()
		if err != nil {
			return err
		}

		if mcpInstallAll {
			return installAll(loader, allAdapters)
		}
		return installOne(loader, allAdapters, args[0])
	},
}

func installOne(loader *content.ContentLoader, allAdapters []adapter.Adapter, id string) error {
	packs, err := loader.LoadPacks(nil)
	if err != nil {
		return err
	}
	server := content.FindMCPServer(packs, id)
	if server == nil {
		return fmt.Errorf("MCP server %q not found — use 'sap-devs mcp list --all' to browse", id)
	}

	hostSet := make(map[string]bool)
	for _, h := range server.Hosts {
		hostSet[h] = true
	}
	detected := detectAdapters(mcpWireAdapters(allAdapters, hostSet))
	if len(detected) == 0 {
		return fmt.Errorf("no compatible hosts detected for %q — install one of: %s",
			server.ID, strings.Join(server.Hosts, ", "))
	}

	fmt.Printf("Detected hosts compatible with %s:\n", server.ID)
	for i, a := range detected {
		path, _ := adapter.ExpandHome(a.MCPConfig.Path)
		fmt.Printf("  %d. %s  (%s)\n", i+1, a.Name, path)
	}
	chosen, err := pickAdapters(detected)
	if err != nil {
		return err
	}

	for _, a := range chosen {
		path, err := adapter.ExpandHome(a.MCPConfig.Path)
		if err != nil {
			return err
		}
		if err := adapter.WriteMCPConfig(path, a.MCPConfig.Key, *server, mcpInstallDryRun); err != nil {
			return fmt.Errorf("install → %s: %w", a.Name, err)
		}
		if !mcpInstallDryRun {
			fmt.Printf("✓ Registered %s in %s\n", server.ID, path)
		}
	}
	return nil
}

func installAll(loader *content.ContentLoader, allAdapters []adapter.Adapter) error {
	paths, err := xdg.New()
	if err != nil {
		return err
	}
	profileCfg, err := config.LoadProfile(paths.ConfigDir)
	if err != nil {
		return err
	}
	if profileCfg.ID == "" {
		return fmt.Errorf("no profile set — run 'sap-devs profile set <name>' first")
	}
	activeProfile, err := loader.FindProfile(profileCfg.ID)
	if err != nil {
		return err
	}
	if activeProfile == nil {
		return fmt.Errorf("profile %q not found — run 'sap-devs sync' to refresh content", profileCfg.ID)
	}
	packs, err := loader.LoadPacks(activeProfile)
	if err != nil {
		return err
	}
	servers := content.FlattenMCPServers(packs)
	if len(servers) == 0 {
		fmt.Println("No MCP servers defined for your current profile.")
		return nil
	}

	// Collect union of all host IDs across all servers
	hostSet := make(map[string]bool)
	for _, s := range servers {
		for _, h := range s.Hosts {
			hostSet[h] = true
		}
	}
	detected := detectAdapters(mcpWireAdapters(allAdapters, hostSet))
	if len(detected) == 0 {
		var allHosts []string
		for h := range hostSet {
			allHosts = append(allHosts, h)
		}
		return fmt.Errorf("no compatible hosts detected — install one of: %s", strings.Join(allHosts, ", "))
	}

	fmt.Println("Detected compatible hosts:")
	for i, a := range detected {
		path, _ := adapter.ExpandHome(a.MCPConfig.Path)
		fmt.Printf("  %d. %s  (%s)\n", i+1, a.Name, path)
	}
	chosen, err := pickAdapters(detected)
	if err != nil {
		return err
	}

	installed := 0
	for _, s := range servers {
		for _, a := range chosen {
			if !containsString(s.Hosts, a.ID) {
				continue
			}
			path, err := adapter.ExpandHome(a.MCPConfig.Path)
			if err != nil {
				return err
			}
			if err := adapter.WriteMCPConfig(path, a.MCPConfig.Key, s, mcpInstallDryRun); err != nil {
				return fmt.Errorf("install %s%s: %w", s.ID, a.Name, err)
			}
			if !mcpInstallDryRun {
				fmt.Printf("✓ Registered %s in %s\n", s.ID, path)
				installed++
			}
		}
	}
	if !mcpInstallDryRun {
		fmt.Printf("Registered %d server(s) in %d host(s).\n", installed/len(chosen), len(chosen))
	}
	return nil
}

// pickAdapters prints a numbered list and reads a selection from stdin.
// The user may enter comma/space-separated numbers or "all".
func pickAdapters(adapters []adapter.Adapter) ([]adapter.Adapter, error) {
	fmt.Print("Install to (enter numbers comma-separated, or \"all\"): ")
	reader := bufio.NewReader(os.Stdin)
	line, err := reader.ReadString('\n')
	if err != nil {
		return nil, err
	}
	line = strings.TrimSpace(line)
	if strings.ToLower(line) == "all" {
		return adapters, nil
	}
	var chosen []adapter.Adapter
	for _, part := range strings.FieldsFunc(line, func(r rune) bool { return r == ',' || r == ' ' }) {
		part = strings.TrimSpace(part)
		if part == "" {
			continue
		}
		n, err := strconv.Atoi(part)
		if err != nil || n < 1 || n > len(adapters) {
			return nil, fmt.Errorf("invalid selection %q — enter numbers (e.g. 1,2) or \"all\"", part)
		}
		chosen = append(chosen, adapters[n-1])
	}
	if len(chosen) == 0 {
		return nil, fmt.Errorf("invalid selection %q — enter numbers (e.g. 1,2) or \"all\"", line)
	}
	return chosen, nil
}
  • [ ] Step 3: Register the install command in init()

Find the init() function at the bottom of cmd/mcp.go:

go
func init() {
	mcpListCmd.Flags().BoolVar(&mcpListAll, "all", false, "list servers from all packs (default: active profile only)")
	mcpCmd.AddCommand(mcpListCmd, mcpStatusCmd)
	rootCmd.AddCommand(mcpCmd)
}

Replace it with:

go
func init() {
	mcpListCmd.Flags().BoolVar(&mcpListAll, "all", false, "list servers from all packs (default: active profile only)")
	mcpInstallCmd.Flags().BoolVar(&mcpInstallAll, "all", false, "install all MCP servers for the active profile")
	mcpInstallCmd.Flags().BoolVar(&mcpInstallDryRun, "dry-run", false, "preview without writing config files")
	mcpCmd.AddCommand(mcpListCmd, mcpInstallCmd, mcpStatusCmd)
	rootCmd.AddCommand(mcpCmd)
}
  • [ ] Step 4: Verify build

Run: go build ./... Expected: clean.

  • [ ] Step 5: Verify vet

Run: go vet ./... Expected: clean.

  • [ ] Step 6: Commit
bash
git add cmd/mcp.go
git commit -m "feat: add mcp install subcommand with host detection and interactive prompt"

Task 7: Final Verification

  • [ ] Step 1: Full build

Run: go build ./... Expected: clean.

  • [ ] Step 2: Full vet

Run: go vet ./... Expected: clean.

  • [ ] Step 3: Build binary

Run: go build -o sap-devs.exe . Expected: binary produced.

  • [ ] Step 4: Smoke test — mcp help

Run: ./sap-devs.exe mcp --help Expected: usage with list, install, status subcommands listed.

  • [ ] Step 5: Smoke test — mcp list with no profile

Run: ./sap-devs.exe mcp list Expected: error "no profile set" OR a table if a profile is already configured.

  • [ ] Step 6: Smoke test — mcp list --all with empty cache

Run: ./sap-devs.exe mcp list --all Expected: "No MCP servers found for your current profile." or a table if content is cached.

  • [ ] Step 7: Smoke test — mcp install unknown server

Run: ./sap-devs.exe mcp install nonexistent-server-id Expected: error MCP server "nonexistent-server-id" not found

  • [ ] Step 8: Smoke test — mcp status

Run: ./sap-devs.exe mcp status Expected: "No MCP adapters or servers found." or a table if adapters and content are cached.

  • [ ] Step 9: Clean up binary and commit
bash
rm -f sap-devs.exe
git status

If clean (no unexpected changes):

bash
git commit --allow-empty -m "chore: plan5 mcp complete" 2>/dev/null || echo "nothing to commit"

If there are modified files, stage and commit them:

bash
git add -A
git commit -m "chore: plan5 mcp complete"