Files
ely-eye/ely-eye-mcp/internal/setup/setup.go
T
ZacharyZhang-NY 4cd29f91e5 Add Ely-Eye MCP server
Local read-only Model Context Protocol server (Go) exposing the .ely_eye memory
store to coding agents such as Codex and Claude Code: nine tools over status,
cartridges, Evidence Atoms, compiled evidence packs, proof suites, and HashHop
proofs. One-line cross-platform install (no Docker), with a prebuilt-binary
release workflow.
2026-05-30 23:51:45 -04:00

221 lines
5.8 KiB
Go

package setup
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/ZacharyZhang-NY/ely-eye/ely-eye-mcp/internal/config"
)
type Options struct {
Client string
Scope string
ServerName string
}
type Action struct {
Client string `json:"client"`
Scope string `json:"scope"`
Target string `json:"target"`
Detail string `json:"detail"`
Command string `json:"command,omitempty"`
}
type Result struct {
ServerName string `json:"server_name"`
Binary string `json:"binary"`
ProjectRoot string `json:"project_root"`
Actions []Action `json:"actions"`
}
func Run(ctx context.Context, cfg config.Config, options Options) (Result, error) {
client := normalizeClient(options.Client)
if client != "codex" && client != "claude" && client != "both" {
return Result{}, errors.New("client must be codex, claude, or both")
}
scope := normalizeScope(options.Scope)
if scope != "project" && scope != "user" {
return Result{}, errors.New("claude scope must be project or user")
}
serverName := options.ServerName
if serverName == "" {
serverName = "ely-eye"
}
if cfg.Binary == "" {
return Result{}, errors.New("binary path is required for setup")
}
result := Result{ServerName: serverName, Binary: cfg.Binary, ProjectRoot: cfg.ProjectRoot}
var actions []Action
if client == "codex" || client == "both" {
action, err := setupCodex(ctx, cfg, serverName)
if err != nil {
return Result{}, err
}
actions = append(actions, action)
}
if client == "claude" || client == "both" {
action, err := setupClaude(ctx, cfg, serverName, scope)
if err != nil {
return Result{}, err
}
actions = append(actions, action)
}
result.Actions = actions
return result, nil
}
func normalizeClient(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "all", "both":
return "both"
case "codex", "claude":
return strings.ToLower(value)
default:
return value
}
}
func normalizeScope(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "project":
return "project"
case "user":
return "user"
default:
return value
}
}
func serverCommand(cfg config.Config) []string {
return []string{
cfg.Binary,
"server",
"--project-root",
cfg.ProjectRoot,
"--ely-eye-home",
cfg.ElyEyeHome,
}
}
func serverEnv(cfg config.Config) map[string]string {
return map[string]string{
"ELY_EYE_HOME": cfg.ElyEyeHome,
}
}
func commandLine(parts []string) string {
quoted := make([]string, 0, len(parts))
for _, part := range parts {
if strings.ContainsAny(part, " \t\"'") {
quoted = append(quoted, `"`+strings.ReplaceAll(part, `"`, `\"`)+`"`)
} else {
quoted = append(quoted, part)
}
}
return strings.Join(quoted, " ")
}
func setupCodex(ctx context.Context, cfg config.Config, serverName string) (Action, error) {
codex, err := exec.LookPath("codex")
if err != nil {
return Action{}, errors.New("codex CLI is required to configure Codex MCP")
}
remove := exec.CommandContext(ctx, codex, "mcp", "remove", serverName)
_ = remove.Run()
args := []string{"mcp", "add", serverName, "--env", "ELY_EYE_HOME=" + cfg.ElyEyeHome, "--"}
args = append(args, serverCommand(cfg)...)
add := exec.CommandContext(ctx, codex, args...)
output, err := add.CombinedOutput()
if err != nil {
return Action{}, errors.New("codex mcp add failed: " + strings.TrimSpace(string(output)))
}
return Action{
Client: "codex",
Scope: "user",
Target: "~/.codex/config.toml",
Detail: "registered stdio MCP server through Codex CLI",
Command: commandLine(append([]string{"codex"}, args...)),
}, nil
}
func setupClaude(ctx context.Context, cfg config.Config, serverName string, scope string) (Action, error) {
if scope == "user" {
return setupClaudeUser(ctx, cfg, serverName)
}
if scope != "project" {
return Action{}, errors.New("claude scope must be project or user")
}
target := filepath.Join(cfg.ProjectRoot, ".mcp.json")
if err := writeProjectMCP(target, serverName, cfg); err != nil {
return Action{}, err
}
return Action{
Client: "claude",
Scope: "project",
Target: target,
Detail: "wrote project-scoped .mcp.json",
}, nil
}
func setupClaudeUser(ctx context.Context, cfg config.Config, serverName string) (Action, error) {
claude, err := exec.LookPath("claude")
if err != nil {
return Action{}, errors.New("claude CLI is required for user-scoped Claude Code MCP setup")
}
payload, err := json.Marshal(mcpServerConfig(cfg))
if err != nil {
return Action{}, err
}
args := []string{"mcp", "add-json", serverName, string(payload), "--scope", "user"}
cmd := exec.CommandContext(ctx, claude, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return Action{}, errors.New("claude mcp add-json failed: " + strings.TrimSpace(string(output)))
}
return Action{
Client: "claude",
Scope: "user",
Target: "~/.claude.json",
Detail: "registered stdio MCP server through Claude CLI",
Command: commandLine(append([]string{"claude"}, args...)),
}, nil
}
func writeProjectMCP(path string, serverName string, cfg config.Config) error {
payload := map[string]any{}
data, err := os.ReadFile(path)
if err == nil {
if err := json.Unmarshal(data, &payload); err != nil {
return err
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
servers, ok := payload["mcpServers"].(map[string]any)
if !ok {
servers = map[string]any{}
}
servers[serverName] = mcpServerConfig(cfg)
payload["mcpServers"] = servers
data, err = json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, append(data, '\n'), 0644)
}
func mcpServerConfig(cfg config.Config) map[string]any {
command := serverCommand(cfg)
return map[string]any{
"type": "stdio",
"command": command[0],
"args": command[1:],
"env": serverEnv(cfg),
}
}