From 1823893c16366c0f04ac868fbcd01721ac871d6b Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 5 Feb 2026 20:40:06 +0000 Subject: [PATCH] feat(mcp): add marketplace server Summary:\n- added vendor-neutral MCP stdio server with marketplace, core CLI, and ethics tools\n- implemented plugin discovery across commands and skills\n- added Good/Bad/Ugly tests and Go module dependency updates --- go.mod | 14 +++++ go.sum | 39 +++++++++++++ mcp/core_cli.go | 89 +++++++++++++++++++++++++++++ mcp/core_cli_test.go | 35 ++++++++++++ mcp/ethics.go | 37 +++++++++++++ mcp/ethics_test.go | 37 +++++++++++++ mcp/main.go | 14 +++++ mcp/marketplace.go | 33 +++++++++++ mcp/marketplace_test.go | 52 +++++++++++++++++ mcp/plugin_info.go | 120 ++++++++++++++++++++++++++++++++++++++++ mcp/server.go | 77 ++++++++++++++++++++++++++ mcp/types.go | 43 ++++++++++++++ mcp/util.go | 56 +++++++++++++++++++ 13 files changed, 646 insertions(+) create mode 100644 go.sum create mode 100644 mcp/core_cli.go create mode 100644 mcp/core_cli_test.go create mode 100644 mcp/ethics.go create mode 100644 mcp/ethics_test.go create mode 100644 mcp/main.go create mode 100644 mcp/marketplace.go create mode 100644 mcp/marketplace_test.go create mode 100644 mcp/plugin_info.go create mode 100644 mcp/server.go create mode 100644 mcp/types.go create mode 100644 mcp/util.go diff --git a/go.mod b/go.mod index fe97fce..49d3a1b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,17 @@ module core-agent go 1.24 + +require github.com/mark3labs/mcp-go v0.43.2 + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..17bd675 --- /dev/null +++ b/go.sum @@ -0,0 +1,39 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I= +github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mcp/core_cli.go b/mcp/core_cli.go new file mode 100644 index 0000000..f577f75 --- /dev/null +++ b/mcp/core_cli.go @@ -0,0 +1,89 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" +) + +var allowedCorePrefixes = map[string]struct{}{ + "dev": {}, + "go": {}, + "php": {}, + "build": {}, +} + +func coreCliHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + command, err := request.RequireString("command") + if err != nil { + return mcp.NewToolResultError("command is required"), nil + } + + args := request.GetStringSlice("args", nil) + base, mergedArgs, err := normalizeCoreCommand(command, args) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + execCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + result := runCoreCommand(execCtx, base, mergedArgs) + return mcp.NewToolResultStructuredOnly(result), nil +} + +func normalizeCoreCommand(command string, args []string) (string, []string, error) { + parts := strings.Fields(command) + if len(parts) == 0 { + return "", nil, errors.New("command cannot be empty") + } + + base := parts[0] + if _, ok := allowedCorePrefixes[base]; !ok { + return "", nil, fmt.Errorf("command not allowed: %s", base) + } + + merged := append([]string{}, parts[1:]...) + merged = append(merged, args...) + + return base, merged, nil +} + +func runCoreCommand(ctx context.Context, command string, args []string) CoreCliResult { + cmd := exec.CommandContext(ctx, "core", append([]string{command}, args...)...) + + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + exitCode := 0 + if err := cmd.Run(); err != nil { + exitCode = 1 + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else if errors.Is(err, context.DeadlineExceeded) { + exitCode = 124 + } + if errors.Is(err, context.DeadlineExceeded) { + if stderr.Len() > 0 { + stderr.WriteString("\n") + } + stderr.WriteString("command timed out after 30s") + } + } + + return CoreCliResult{ + Command: command, + Args: args, + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + } +} diff --git a/mcp/core_cli_test.go b/mcp/core_cli_test.go new file mode 100644 index 0000000..905df61 --- /dev/null +++ b/mcp/core_cli_test.go @@ -0,0 +1,35 @@ +package main + +import "testing" + +func TestNormalizeCoreCommand_Good(t *testing.T) { + command, args, err := normalizeCoreCommand("go", []string{"test"}) + if err != nil { + t.Fatalf("expected command to be allowed: %v", err) + } + if command != "go" { + t.Fatalf("expected go command, got %s", command) + } + if len(args) != 1 || args[0] != "test" { + t.Fatalf("unexpected args: %#v", args) + } +} + +func TestNormalizeCoreCommand_Bad(t *testing.T) { + if _, _, err := normalizeCoreCommand("rm -rf", nil); err == nil { + t.Fatalf("expected command to be rejected") + } +} + +func TestNormalizeCoreCommand_Ugly(t *testing.T) { + command, args, err := normalizeCoreCommand("go test", []string{"-v"}) + if err != nil { + t.Fatalf("expected command to be allowed: %v", err) + } + if command != "go" { + t.Fatalf("expected go command, got %s", command) + } + if len(args) != 2 || args[0] != "test" || args[1] != "-v" { + t.Fatalf("unexpected args: %#v", args) + } +} diff --git a/mcp/ethics.go b/mcp/ethics.go new file mode 100644 index 0000000..bfd5bfe --- /dev/null +++ b/mcp/ethics.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/mark3labs/mcp-go/mcp" +) + +const ethicsModalPath = "codex/ethics/MODAL.md" +const ethicsAxiomsPath = "codex/ethics/kernel/axioms.json" + +func ethicsCheckHandler(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + root, err := findRepoRoot() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to locate repo root: %v", err)), nil + } + + modalBytes, err := os.ReadFile(filepath.Join(root, ethicsModalPath)) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to read modal: %v", err)), nil + } + + axioms, err := readJSONMap(filepath.Join(root, ethicsAxiomsPath)) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to read axioms: %v", err)), nil + } + + payload := EthicsContext{ + Modal: string(modalBytes), + Axioms: axioms, + } + + return mcp.NewToolResultStructuredOnly(payload), nil +} diff --git a/mcp/ethics_test.go b/mcp/ethics_test.go new file mode 100644 index 0000000..4e7fc60 --- /dev/null +++ b/mcp/ethics_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEthicsCheck_Good(t *testing.T) { + root, err := findRepoRoot() + if err != nil { + t.Fatalf("expected repo root: %v", err) + } + + modalPath := filepath.Join(root, ethicsModalPath) + modal, err := os.ReadFile(modalPath) + if err != nil { + t.Fatalf("expected modal to read: %v", err) + } + if len(modal) == 0 { + t.Fatalf("expected modal content") + } + + axioms, err := readJSONMap(filepath.Join(root, ethicsAxiomsPath)) + if err != nil { + t.Fatalf("expected axioms to read: %v", err) + } + if len(axioms) == 0 { + t.Fatalf("expected axioms data") + } +} + +func TestReadJSONMap_Bad(t *testing.T) { + if _, err := readJSONMap("/missing/file.json"); err == nil { + t.Fatalf("expected error for missing json") + } +} diff --git a/mcp/main.go b/mcp/main.go new file mode 100644 index 0000000..86405a7 --- /dev/null +++ b/mcp/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "log" + + "github.com/mark3labs/mcp-go/server" +) + +func main() { + srv := newServer() + if err := server.ServeStdio(srv); err != nil { + log.Fatalf("mcp server failed: %v", err) + } +} diff --git a/mcp/marketplace.go b/mcp/marketplace.go new file mode 100644 index 0000000..59396c0 --- /dev/null +++ b/mcp/marketplace.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/mark3labs/mcp-go/mcp" +) + +func loadMarketplace() (Marketplace, string, error) { + root, err := findRepoRoot() + if err != nil { + return Marketplace{}, "", err + } + + path := filepath.Join(root, marketplacePath) + var marketplace Marketplace + if err := readJSONFile(path, &marketplace); err != nil { + return Marketplace{}, "", err + } + + return marketplace, root, nil +} + +func marketplaceListHandler(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { + marketplace, _, err := loadMarketplace() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to load marketplace: %v", err)), nil + } + + return mcp.NewToolResultStructuredOnly(marketplace), nil +} diff --git a/mcp/marketplace_test.go b/mcp/marketplace_test.go new file mode 100644 index 0000000..f8748bd --- /dev/null +++ b/mcp/marketplace_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestMarketplaceLoad_Good(t *testing.T) { + marketplace, root, err := loadMarketplace() + if err != nil { + t.Fatalf("expected marketplace to load: %v", err) + } + if marketplace.Name == "" { + t.Fatalf("expected marketplace name to be set") + } + if len(marketplace.Plugins) == 0 { + t.Fatalf("expected marketplace plugins") + } + if root == "" { + t.Fatalf("expected repo root") + } +} + +func TestMarketplacePluginInfo_Bad(t *testing.T) { + marketplace, _, err := loadMarketplace() + if err != nil { + t.Fatalf("expected marketplace to load: %v", err) + } + if _, ok := findMarketplacePlugin(marketplace, "missing-plugin"); ok { + t.Fatalf("expected missing plugin") + } +} + +func TestMarketplacePluginInfo_Good(t *testing.T) { + marketplace, root, err := loadMarketplace() + if err != nil { + t.Fatalf("expected marketplace to load: %v", err) + } + + plugin, ok := findMarketplacePlugin(marketplace, "claude-code") + if !ok { + t.Fatalf("expected claude-code plugin") + } + + commands, err := listCommands(filepath.Join(root, plugin.Source)) + if err != nil { + t.Fatalf("expected commands to list: %v", err) + } + if len(commands) == 0 { + t.Fatalf("expected commands for claude-code") + } +} diff --git a/mcp/plugin_info.go b/mcp/plugin_info.go new file mode 100644 index 0000000..6e6d02d --- /dev/null +++ b/mcp/plugin_info.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/mark3labs/mcp-go/mcp" +) + +func marketplacePluginInfoHandler(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name, err := request.RequireString("name") + if err != nil { + return mcp.NewToolResultError("name is required"), nil + } + + marketplace, root, err := loadMarketplace() + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("failed to load marketplace: %v", err)), nil + } + + plugin, ok := findMarketplacePlugin(marketplace, name) + if !ok { + return mcp.NewToolResultError(fmt.Sprintf("plugin not found: %s", name)), nil + } + + path := filepath.Join(root, plugin.Source) + commands, _ := listCommands(path) + skills, _ := listSkills(path) + manifest, _ := loadPluginManifest(path) + + info := PluginInfo{ + Plugin: plugin, + Path: path, + Manifest: manifest, + Commands: commands, + Skills: skills, + } + + return mcp.NewToolResultStructuredOnly(info), nil +} + +func findMarketplacePlugin(marketplace Marketplace, name string) (MarketplacePlugin, bool) { + for _, plugin := range marketplace.Plugins { + if plugin.Name == name { + return plugin, true + } + } + + return MarketplacePlugin{}, false +} + +func listCommands(path string) ([]string, error) { + commandsPath := filepath.Join(path, "commands") + info, err := os.Stat(commandsPath) + if err != nil || !info.IsDir() { + return nil, nil + } + + var commands []string + _ = filepath.WalkDir(commandsPath, func(entryPath string, entry os.DirEntry, err error) error { + if err != nil { + return nil + } + if entry.IsDir() { + return nil + } + rel, relErr := filepath.Rel(commandsPath, entryPath) + if relErr != nil { + return nil + } + commands = append(commands, filepath.ToSlash(rel)) + return nil + }) + + sort.Strings(commands) + return commands, nil +} + +func listSkills(path string) ([]string, error) { + skillsPath := filepath.Join(path, "skills") + info, err := os.Stat(skillsPath) + if err != nil || !info.IsDir() { + return nil, nil + } + + entries, err := os.ReadDir(skillsPath) + if err != nil { + return nil, err + } + + var skills []string + for _, entry := range entries { + if entry.IsDir() { + skills = append(skills, entry.Name()) + } + } + + sort.Strings(skills) + return skills, nil +} + +func loadPluginManifest(path string) (map[string]any, error) { + candidates := []string{ + filepath.Join(path, ".claude-plugin", "plugin.json"), + filepath.Join(path, ".codex-plugin", "plugin.json"), + filepath.Join(path, "gemini-extension.json"), + } + + for _, candidate := range candidates { + payload, err := readJSONMap(candidate) + if err == nil { + return payload, nil + } + } + + return nil, nil +} diff --git a/mcp/server.go b/mcp/server.go new file mode 100644 index 0000000..d616db6 --- /dev/null +++ b/mcp/server.go @@ -0,0 +1,77 @@ +package main + +import ( + "encoding/json" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +const serverName = "host-uk-marketplace" +const serverVersion = "0.1.0" + +func newServer() *server.MCPServer { + srv := server.NewMCPServer( + serverName, + serverVersion, + ) + + srv.AddTool(marketplaceListTool(), marketplaceListHandler) + srv.AddTool(marketplacePluginInfoTool(), marketplacePluginInfoHandler) + srv.AddTool(coreCliTool(), coreCliHandler) + srv.AddTool(ethicsCheckTool(), ethicsCheckHandler) + + return srv +} + +func marketplaceListTool() mcp.Tool { + return mcp.NewTool( + "marketplace_list", + mcp.WithDescription("List available marketplace plugins"), + ) +} + +func marketplacePluginInfoTool() mcp.Tool { + return mcp.NewTool( + "marketplace_plugin_info", + mcp.WithDescription("Return plugin metadata, commands, and skills"), + mcp.WithString("name", mcp.Required(), mcp.Description("Marketplace plugin name")), + ) +} + +func coreCliTool() mcp.Tool { + rawSchema, err := json.Marshal(map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{ + "type": "string", + "description": "Core CLI command group (dev, go, php, build)", + }, + "args": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Arguments for the command", + }, + }, + "required": []string{"command"}, + }) + + options := []mcp.ToolOption{ + mcp.WithDescription("Run approved core CLI commands"), + } + if err == nil { + options = append(options, mcp.WithRawInputSchema(rawSchema)) + } + + return mcp.NewTool( + "core_cli", + options..., + ) +} + +func ethicsCheckTool() mcp.Tool { + return mcp.NewTool( + "ethics_check", + mcp.WithDescription("Return the Axioms of Life ethics modal and kernel"), + ) +} diff --git a/mcp/types.go b/mcp/types.go new file mode 100644 index 0000000..101994a --- /dev/null +++ b/mcp/types.go @@ -0,0 +1,43 @@ +package main + +type Marketplace struct { + Schema string `json:"$schema,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Owner MarketplaceOwner `json:"owner"` + Plugins []MarketplacePlugin `json:"plugins"` +} + +type MarketplaceOwner struct { + Name string `json:"name"` + Email string `json:"email"` +} + +type MarketplacePlugin struct { + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + Source string `json:"source"` + Category string `json:"category"` +} + +type PluginInfo struct { + Plugin MarketplacePlugin `json:"plugin"` + Path string `json:"path"` + Manifest map[string]any `json:"manifest,omitempty"` + Commands []string `json:"commands,omitempty"` + Skills []string `json:"skills,omitempty"` +} + +type CoreCliResult struct { + Command string `json:"command"` + Args []string `json:"args"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` +} + +type EthicsContext struct { + Modal string `json:"modal"` + Axioms map[string]any `json:"axioms"` +} diff --git a/mcp/util.go b/mcp/util.go new file mode 100644 index 0000000..a2ae654 --- /dev/null +++ b/mcp/util.go @@ -0,0 +1,56 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" +) + +const marketplacePath = ".claude-plugin/marketplace.json" + +func findRepoRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + + path := cwd + for { + candidate := filepath.Join(path, marketplacePath) + if _, err := os.Stat(candidate); err == nil { + return path, nil + } + + parent := filepath.Dir(path) + if parent == path { + break + } + path = parent + } + + return "", errors.New("repository root not found") +} + +func readJSONFile(path string, target any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + + return json.Unmarshal(data, target) +} + +func readJSONMap(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + return nil, err + } + + return payload, nil +}