mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-15 14:19:13 +02:00
304 lines
8.8 KiB
Go
304 lines
8.8 KiB
Go
package execenv
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
// CursorMcpAuthSourceEnv is an agent custom_env key the daemon consumes
|
|
// before launching cursor-agent. When set, it must point at an explicit
|
|
// mcp-auth.json file, or at a Cursor project data directory containing one.
|
|
// The key is intentionally not MULTICA_* because custom_env blocks those
|
|
// from being set by users.
|
|
CursorMcpAuthSourceEnv = "CURSOR_MCP_AUTH_SOURCE"
|
|
|
|
cursorWorkspaceTrustedFile = ".workspace-trusted"
|
|
cursorMcpAuthFile = "mcp-auth.json"
|
|
)
|
|
|
|
type cursorMcpConfigFile struct {
|
|
McpServers map[string]json.RawMessage `json:"mcpServers"`
|
|
}
|
|
|
|
// prepareCursorMcpConfig writes the Cursor-native MCP sidecars for agents that
|
|
// have an explicit managed mcp_config saved. A nil/null mcp_config means "let
|
|
// Cursor behave normally", so no .cursor/mcp.json or CURSOR_DATA_DIR is created.
|
|
func prepareCursorMcpConfig(envRoot, workDir string, mcpConfig json.RawMessage, mcpAuthSource string, manifest *sidecarManifest) (string, error) {
|
|
if !hasManagedCursorMcpConfig(mcpConfig) {
|
|
return "", nil
|
|
}
|
|
if envRoot == "" {
|
|
return "", fmt.Errorf("env root is required for managed cursor mcp_config")
|
|
}
|
|
|
|
projectRoot := cursorProjectRoot(workDir)
|
|
servers, err := parseCursorManagedMcpServers(mcpConfig)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
cursorDir := filepath.Join(projectRoot, ".cursor")
|
|
if err := recordMkdirAll(cursorDir, 0o755, manifest); err != nil {
|
|
return "", fmt.Errorf("create .cursor dir: %w", err)
|
|
}
|
|
configData, err := marshalCursorMcpConfig(servers)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := recordWriteFile(filepath.Join(cursorDir, "mcp.json"), configData, 0o600, manifest); err != nil {
|
|
if errors.Is(err, errPathPreExists) {
|
|
return "", fmt.Errorf("managed cursor mcp_config would overwrite existing .cursor/mcp.json")
|
|
}
|
|
return "", fmt.Errorf("write .cursor/mcp.json: %w", err)
|
|
}
|
|
|
|
cursorDataDir := filepath.Join(envRoot, "cursor-data")
|
|
projectDataDir := filepath.Join(cursorDataDir, "projects", cursorSlugifyPath(projectRoot))
|
|
if err := os.MkdirAll(projectDataDir, 0o700); err != nil {
|
|
return "", fmt.Errorf("create cursor project data dir: %w", err)
|
|
}
|
|
if err := removeCursorMcpAuthFile(projectDataDir); err != nil {
|
|
return "", err
|
|
}
|
|
approvals, err := cursorMcpApprovalKeys(projectRoot, servers)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
approvalData, err := json.MarshalIndent(approvals, "", " ")
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal cursor mcp approvals: %w", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(projectDataDir, "mcp-approvals.json"), approvalData, 0o600); err != nil {
|
|
return "", fmt.Errorf("write cursor mcp approvals: %w", err)
|
|
}
|
|
trustData, err := json.MarshalIndent(map[string]string{
|
|
"trustedAt": "1970-01-01T00:00:00Z",
|
|
"workspacePath": projectRoot,
|
|
"trustMethod": "multica-managed",
|
|
}, "", " ")
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal cursor workspace trust: %w", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(projectDataDir, cursorWorkspaceTrustedFile), trustData, 0o600); err != nil {
|
|
return "", fmt.Errorf("write cursor workspace trust: %w", err)
|
|
}
|
|
if strings.TrimSpace(mcpAuthSource) != "" {
|
|
if err := seedCursorMcpAuthFile(projectDataDir, mcpAuthSource); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
|
|
return cursorDataDir, nil
|
|
}
|
|
|
|
func seedCursorMcpAuthFile(projectDataDir, source string) error {
|
|
sourcePath, err := resolveCursorMcpAuthSource(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target := filepath.Join(projectDataDir, cursorMcpAuthFile)
|
|
if err := os.Symlink(sourcePath, target); err == nil {
|
|
return nil
|
|
}
|
|
if err := copyCursorMcpAuthFile(target, sourcePath); err != nil {
|
|
return fmt.Errorf("seed cursor mcp auth file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func removeCursorMcpAuthFile(projectDataDir string) error {
|
|
target := filepath.Join(projectDataDir, cursorMcpAuthFile)
|
|
if err := os.Remove(target); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return fmt.Errorf("remove prior cursor mcp auth file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveCursorMcpAuthSource(source string) (string, error) {
|
|
source = strings.TrimSpace(source)
|
|
if source == "" {
|
|
return "", fmt.Errorf("%s is empty", CursorMcpAuthSourceEnv)
|
|
}
|
|
if source == "~" || strings.HasPrefix(source, "~/") {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve %s home directory: %w", CursorMcpAuthSourceEnv, err)
|
|
}
|
|
if source == "~" {
|
|
source = home
|
|
} else {
|
|
source = filepath.Join(home, source[2:])
|
|
}
|
|
}
|
|
if !filepath.IsAbs(source) {
|
|
return "", fmt.Errorf("%s must be an absolute path to %s or its containing Cursor project directory", CursorMcpAuthSourceEnv, cursorMcpAuthFile)
|
|
}
|
|
source = filepath.Clean(source)
|
|
info, err := os.Stat(source)
|
|
if err != nil {
|
|
return "", fmt.Errorf("stat %s: %w", CursorMcpAuthSourceEnv, err)
|
|
}
|
|
if info.IsDir() {
|
|
source = filepath.Join(source, cursorMcpAuthFile)
|
|
info, err = os.Stat(source)
|
|
if err != nil {
|
|
return "", fmt.Errorf("stat %s %s: %w", CursorMcpAuthSourceEnv, cursorMcpAuthFile, err)
|
|
}
|
|
}
|
|
if info.IsDir() {
|
|
return "", fmt.Errorf("%s must resolve to a file, got directory %s", CursorMcpAuthSourceEnv, source)
|
|
}
|
|
if filepath.Base(source) != cursorMcpAuthFile {
|
|
return "", fmt.Errorf("%s must point at %s, got %s", CursorMcpAuthSourceEnv, cursorMcpAuthFile, filepath.Base(source))
|
|
}
|
|
return source, nil
|
|
}
|
|
|
|
func copyCursorMcpAuthFile(target, source string) error {
|
|
in, err := os.Open(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer in.Close()
|
|
|
|
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, copyErr := io.Copy(out, in)
|
|
closeErr := out.Close()
|
|
if copyErr != nil {
|
|
_ = os.Remove(target)
|
|
return copyErr
|
|
}
|
|
if closeErr != nil {
|
|
_ = os.Remove(target)
|
|
return closeErr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasManagedCursorMcpConfig(raw json.RawMessage) bool {
|
|
trimmed := bytes.TrimSpace(raw)
|
|
return len(trimmed) > 0 && !bytes.Equal(trimmed, []byte("null"))
|
|
}
|
|
|
|
func parseCursorManagedMcpServers(raw json.RawMessage) (map[string]json.RawMessage, error) {
|
|
var cfg struct {
|
|
McpServers map[string]json.RawMessage `json:"mcpServers"`
|
|
}
|
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
|
return nil, fmt.Errorf("parse mcp_config json: %w", err)
|
|
}
|
|
if cfg.McpServers == nil {
|
|
return map[string]json.RawMessage{}, nil
|
|
}
|
|
for name, server := range cfg.McpServers {
|
|
if strings.TrimSpace(name) == "" {
|
|
return nil, fmt.Errorf("mcp server name must not be empty")
|
|
}
|
|
var obj map[string]any
|
|
if err := json.Unmarshal(server, &obj); err != nil {
|
|
return nil, fmt.Errorf("mcp_servers.%s: %w", name, err)
|
|
}
|
|
if obj == nil {
|
|
return nil, fmt.Errorf("mcp_servers.%s must be a JSON object", name)
|
|
}
|
|
}
|
|
return cfg.McpServers, nil
|
|
}
|
|
|
|
func marshalCursorMcpConfig(servers map[string]json.RawMessage) ([]byte, error) {
|
|
if servers == nil {
|
|
servers = map[string]json.RawMessage{}
|
|
}
|
|
data, err := json.MarshalIndent(cursorMcpConfigFile{McpServers: servers}, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal cursor mcp config: %w", err)
|
|
}
|
|
return append(data, '\n'), nil
|
|
}
|
|
|
|
func cursorMcpApprovalKeys(projectRoot string, servers map[string]json.RawMessage) ([]string, error) {
|
|
names := make([]string, 0, len(servers))
|
|
for name := range servers {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
approvals := make([]string, 0, len(names))
|
|
for _, name := range names {
|
|
compact := &bytes.Buffer{}
|
|
if err := json.Compact(compact, servers[name]); err != nil {
|
|
return nil, fmt.Errorf("compact mcp_servers.%s: %w", name, err)
|
|
}
|
|
pathJSON, err := json.Marshal(projectRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal cursor project root: %w", err)
|
|
}
|
|
payload := []byte(`{"path":`)
|
|
payload = append(payload, pathJSON...)
|
|
payload = append(payload, []byte(`,"server":`)...)
|
|
payload = append(payload, compact.Bytes()...)
|
|
payload = append(payload, '}')
|
|
|
|
sum := sha256.Sum256(payload)
|
|
approvals = append(approvals, name+"-"+hex.EncodeToString(sum[:])[:16])
|
|
}
|
|
return approvals, nil
|
|
}
|
|
|
|
func cursorProjectRoot(workDir string) string {
|
|
if workDir == "" {
|
|
return workDir
|
|
}
|
|
dir, err := filepath.EvalSymlinks(workDir)
|
|
if err != nil {
|
|
dir = workDir
|
|
}
|
|
dir, err = filepath.Abs(dir)
|
|
if err != nil {
|
|
dir = workDir
|
|
}
|
|
fallback := dir
|
|
for {
|
|
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
|
return dir
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
return fallback
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
func cursorSlugifyPath(path string) string {
|
|
var b strings.Builder
|
|
lastDash := false
|
|
for _, r := range path {
|
|
isAlphaNum := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
|
if isAlphaNum {
|
|
b.WriteRune(r)
|
|
lastDash = false
|
|
continue
|
|
}
|
|
if !lastDash {
|
|
b.WriteByte('-')
|
|
lastDash = true
|
|
}
|
|
}
|
|
return strings.Trim(b.String(), "-")
|
|
}
|