464 lines
13 KiB
Go
464 lines
13 KiB
Go
package mcpconfig
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
const (
|
|
cursorMCPAuthFilename = "mcp-auth.json"
|
|
cursorMCPAuthUnifiedFilename = "kandev-mcp-auth-unified.json"
|
|
cursorTaskProjectMarker = "kandev-tasks"
|
|
)
|
|
|
|
var cursorMCPAuthMutex sync.Mutex
|
|
|
|
type cursorMCPAuthSource struct {
|
|
path string
|
|
modTime int64
|
|
servers map[string]json.RawMessage
|
|
}
|
|
|
|
type cursorMCPAuthSnapshot struct {
|
|
data []byte
|
|
hasSources bool
|
|
}
|
|
|
|
// DeriveCursorProjectSlug derives Cursor's project directory name from a path.
|
|
func DeriveCursorProjectSlug(workspacePath string) string {
|
|
normalizedPath := strings.ReplaceAll(workspacePath, `\`, "/")
|
|
slug := strings.NewReplacer("/", "-", ".", "-", "_", "-", ":", "-").Replace(normalizedPath)
|
|
return strings.Trim(slug, "-")
|
|
}
|
|
|
|
// AggregateCursorMCPAuth publishes the latest valid project auth snapshot.
|
|
// It leaves the existing master unchanged when no eligible source is valid.
|
|
func AggregateCursorMCPAuth(cursorHome string, excludedWorkspaceRoots ...string) error {
|
|
cursorMCPAuthMutex.Lock()
|
|
defer cursorMCPAuthMutex.Unlock()
|
|
|
|
snapshot, err := aggregateCursorMCPAuth(cursorHome, excludedWorkspaceRoots...)
|
|
if err != nil || !snapshot.hasSources {
|
|
return err
|
|
}
|
|
return publishCursorMCPAuth(cursorHome, snapshot.data)
|
|
}
|
|
|
|
// LinkCursorMCPAuth refreshes the shared snapshot and links a canonical
|
|
// workspace's Cursor project auth file to it when at least one source is valid.
|
|
func LinkCursorMCPAuth(workspacePath, cursorHome string, excludedWorkspaceRoots ...string) error {
|
|
cursorMCPAuthMutex.Lock()
|
|
defer cursorMCPAuthMutex.Unlock()
|
|
return linkCursorMCPAuth(workspacePath, cursorHome, excludedWorkspaceRoots...)
|
|
}
|
|
|
|
// PrepareCursorMCPAuth applies the enabled or disabled bridge behavior for a
|
|
// local Cursor launch.
|
|
func PrepareCursorMCPAuth(workspacePath, cursorHome string, enabled bool, excludedWorkspaceRoots ...string) error {
|
|
cursorMCPAuthMutex.Lock()
|
|
defer cursorMCPAuthMutex.Unlock()
|
|
if !enabled {
|
|
return disableCursorMCPAuth(workspacePath, cursorHome)
|
|
}
|
|
return linkCursorMCPAuth(workspacePath, cursorHome, excludedWorkspaceRoots...)
|
|
}
|
|
|
|
func linkCursorMCPAuth(workspacePath, cursorHome string, excludedWorkspaceRoots ...string) error {
|
|
workspaceRoots := append([]string{workspacePath}, excludedWorkspaceRoots...)
|
|
snapshot, err := aggregateCursorMCPAuth(cursorHome, workspaceRoots...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !snapshot.hasSources {
|
|
return nil
|
|
}
|
|
masterPath, err := cursorMasterPath(cursorHome)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := publishCursorMCPAuth(cursorHome, snapshot.data); err != nil {
|
|
return err
|
|
}
|
|
destination, err := cursorProjectAuthPath(workspacePath, cursorHome, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return linkCursorAuthFile(destination, masterPath)
|
|
}
|
|
|
|
func disableCursorMCPAuth(workspacePath, cursorHome string) error {
|
|
destination, err := cursorProjectAuthPath(workspacePath, cursorHome, false)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
masterPath, err := cursorMasterPath(cursorHome)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, err := os.Lstat(destination)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
target, err := os.Readlink(destination)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !samePath(resolveLinkTarget(filepath.Dir(destination), target), masterPath) {
|
|
return nil
|
|
}
|
|
return os.Remove(destination)
|
|
}
|
|
|
|
func aggregateCursorMCPAuth(cursorHome string, excludedWorkspaceRoots ...string) (cursorMCPAuthSnapshot, error) {
|
|
projectsPath := filepath.Join(cursorHome, "projects")
|
|
projectsInfo, err := os.Lstat(projectsPath)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return cursorMCPAuthSnapshot{}, nil
|
|
}
|
|
if err != nil {
|
|
return cursorMCPAuthSnapshot{}, err
|
|
}
|
|
if projectsInfo.Mode()&os.ModeSymlink != 0 || !projectsInfo.IsDir() {
|
|
return cursorMCPAuthSnapshot{}, errors.New("cursor projects path is not a regular directory")
|
|
}
|
|
entries, err := os.ReadDir(projectsPath)
|
|
if err != nil {
|
|
return cursorMCPAuthSnapshot{}, err
|
|
}
|
|
excludedSlugs, err := cursorAuthExcludedProjectSlugs(excludedWorkspaceRoots)
|
|
if err != nil {
|
|
return cursorMCPAuthSnapshot{}, err
|
|
}
|
|
sources := collectCursorMCPAuthSources(projectsPath, entries, excludedSlugs)
|
|
if len(sources) == 0 {
|
|
return cursorMCPAuthSnapshot{}, nil
|
|
}
|
|
sort.Slice(sources, func(i, j int) bool {
|
|
if sources[i].modTime != sources[j].modTime {
|
|
return sources[i].modTime > sources[j].modTime
|
|
}
|
|
return sources[i].path < sources[j].path
|
|
})
|
|
servers := make(map[string]json.RawMessage)
|
|
for _, source := range sources {
|
|
for name, value := range source.servers {
|
|
if _, exists := servers[name]; !exists {
|
|
servers[name] = value
|
|
}
|
|
}
|
|
}
|
|
data, err := json.MarshalIndent(servers, "", " ")
|
|
if err != nil {
|
|
return cursorMCPAuthSnapshot{}, err
|
|
}
|
|
return cursorMCPAuthSnapshot{data: append(data, '\n'), hasSources: true}, nil
|
|
}
|
|
|
|
func collectCursorMCPAuthSources(projectsPath string, entries []os.DirEntry, excludedSlugs []string) []cursorMCPAuthSource {
|
|
sources := make([]cursorMCPAuthSource, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if strings.Contains(entry.Name(), cursorTaskProjectMarker) || isCursorTaskProjectSlug(entry.Name(), excludedSlugs) {
|
|
continue
|
|
}
|
|
projectPath := filepath.Join(projectsPath, entry.Name())
|
|
projectInfo, err := os.Lstat(projectPath)
|
|
if err != nil || !projectInfo.IsDir() {
|
|
continue
|
|
}
|
|
source, ok := readCursorMCPAuthSource(filepath.Join(projectPath, cursorMCPAuthFilename))
|
|
if ok {
|
|
sources = append(sources, source)
|
|
}
|
|
}
|
|
return sources
|
|
}
|
|
|
|
func cursorAuthExcludedProjectSlugs(workspaceRoots []string) ([]string, error) {
|
|
slugs := make([]string, 0, len(workspaceRoots))
|
|
for _, root := range workspaceRoots {
|
|
if root == "" {
|
|
continue
|
|
}
|
|
canonicalRoot, err := canonicalPathIncludingMissingSuffix(root)
|
|
if err != nil {
|
|
return nil, errors.New("could not resolve Cursor MCP auth exclusion root")
|
|
}
|
|
slug := DeriveCursorProjectSlug(canonicalRoot)
|
|
if slug == "" {
|
|
return nil, errors.New("cursor MCP auth exclusion root has an empty project slug")
|
|
}
|
|
slugs = append(slugs, slug)
|
|
}
|
|
return slugs, nil
|
|
}
|
|
|
|
func isCursorTaskProjectSlug(projectSlug string, taskRootSlugs []string) bool {
|
|
for _, rootSlug := range taskRootSlugs {
|
|
if projectSlug == rootSlug || strings.HasPrefix(projectSlug, rootSlug+"-") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func readCursorMCPAuthSource(path string) (cursorMCPAuthSource, bool) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
return cursorMCPAuthSource{}, false
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return cursorMCPAuthSource{}, false
|
|
}
|
|
defer func() { _ = file.Close() }()
|
|
openedInfo, err := file.Stat()
|
|
if err != nil || !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) {
|
|
return cursorMCPAuthSource{}, false
|
|
}
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
return cursorMCPAuthSource{}, false
|
|
}
|
|
servers, valid := decodeCursorMCPAuth(data)
|
|
if !valid {
|
|
return cursorMCPAuthSource{}, false
|
|
}
|
|
return cursorMCPAuthSource{path: path, modTime: info.ModTime().UnixNano(), servers: servers}, true
|
|
}
|
|
|
|
func decodeCursorMCPAuth(data []byte) (map[string]json.RawMessage, bool) {
|
|
trimmed := bytes.TrimSpace(data)
|
|
if len(trimmed) < 2 || trimmed[0] != '{' {
|
|
return nil, false
|
|
}
|
|
var root map[string]json.RawMessage
|
|
if err := json.Unmarshal(trimmed, &root); err != nil || root == nil {
|
|
return nil, false
|
|
}
|
|
for _, server := range root {
|
|
if !isJSONObject(server) {
|
|
return nil, false
|
|
}
|
|
}
|
|
return root, true
|
|
}
|
|
|
|
func isJSONObject(data []byte) bool {
|
|
trimmed := bytes.TrimSpace(data)
|
|
if len(trimmed) < 2 || trimmed[0] != '{' {
|
|
return false
|
|
}
|
|
var object map[string]json.RawMessage
|
|
return json.Unmarshal(trimmed, &object) == nil && object != nil
|
|
}
|
|
|
|
func publishCursorMCPAuth(cursorHome string, data []byte) error {
|
|
masterPath, err := cursorMasterPath(cursorHome)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ensureReplaceableMaster(masterPath); err != nil {
|
|
return err
|
|
}
|
|
tempFile, err := os.CreateTemp(cursorHome, ".kandev-mcp-auth-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempPath := tempFile.Name()
|
|
defer func() { _ = os.Remove(tempPath) }()
|
|
if err := tempFile.Chmod(0o600); err != nil {
|
|
_ = tempFile.Close()
|
|
return err
|
|
}
|
|
if _, err := tempFile.Write(data); err != nil {
|
|
_ = tempFile.Close()
|
|
return err
|
|
}
|
|
if err := tempFile.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureReplaceableMaster(masterPath); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tempPath, masterPath)
|
|
}
|
|
|
|
func ensureReplaceableMaster(path string) error {
|
|
info, err := os.Lstat(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return errors.New("cursor unified auth path is not a regular file")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cursorProjectAuthPath(workspacePath, cursorHome string, create bool) (string, error) {
|
|
canonicalPath, err := canonicalWorkspacePath(workspacePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
slug := DeriveCursorProjectSlug(canonicalPath)
|
|
if slug == "" {
|
|
return "", errors.New("cursor project path has an empty slug")
|
|
}
|
|
projectsPath := filepath.Join(cursorHome, "projects")
|
|
projectsInfo, err := os.Lstat(projectsPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if projectsInfo.Mode()&os.ModeSymlink != 0 || !projectsInfo.IsDir() {
|
|
return "", errors.New("cursor projects path is not a regular directory")
|
|
}
|
|
projectPath, err := cursorProjectDirectoryPath(projectsPath, slug, create)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(projectPath, cursorMCPAuthFilename), nil
|
|
}
|
|
|
|
func cursorProjectDirectoryPath(projectsPath, slug string, create bool) (string, error) {
|
|
projectPath := filepath.Join(projectsPath, slug)
|
|
projectInfo, err := os.Lstat(projectPath)
|
|
if errors.Is(err, os.ErrNotExist) && create {
|
|
if err := os.Mkdir(projectPath, 0o755); err != nil && !errors.Is(err, os.ErrExist) {
|
|
return "", err
|
|
}
|
|
projectInfo, err = os.Lstat(projectPath)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if projectInfo.Mode()&os.ModeSymlink != 0 || !projectInfo.IsDir() {
|
|
return "", errors.New("cursor project path is not a regular directory")
|
|
}
|
|
return projectPath, nil
|
|
}
|
|
|
|
func canonicalWorkspacePath(workspacePath string) (string, error) {
|
|
absolutePath, err := filepath.Abs(workspacePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.EvalSymlinks(absolutePath)
|
|
}
|
|
|
|
// canonicalPathIncludingMissingSuffix resolves symlinked ancestors when a task root has been removed.
|
|
func canonicalPathIncludingMissingSuffix(path string) (string, error) {
|
|
absolutePath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for existingPath := absolutePath; ; existingPath = filepath.Dir(existingPath) {
|
|
resolvedPath, resolveErr := filepath.EvalSymlinks(existingPath)
|
|
if resolveErr == nil {
|
|
suffix, relErr := filepath.Rel(existingPath, absolutePath)
|
|
if relErr != nil {
|
|
return "", relErr
|
|
}
|
|
return filepath.Join(resolvedPath, suffix), nil
|
|
}
|
|
if !errors.Is(resolveErr, os.ErrNotExist) {
|
|
return "", resolveErr
|
|
}
|
|
parent := filepath.Dir(existingPath)
|
|
if parent == existingPath {
|
|
return "", resolveErr
|
|
}
|
|
}
|
|
}
|
|
|
|
func cursorMasterPath(cursorHome string) (string, error) {
|
|
absoluteHome, err := filepath.Abs(cursorHome)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(absoluteHome, cursorMCPAuthUnifiedFilename), nil
|
|
}
|
|
|
|
func linkCursorAuthFile(destination, masterPath string) error {
|
|
info, err := os.Lstat(destination)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return os.Symlink(masterPath, destination)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink == 0 {
|
|
return nil
|
|
}
|
|
target, err := os.Readlink(destination)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if samePath(resolveLinkTarget(filepath.Dir(destination), target), masterPath) {
|
|
return nil
|
|
}
|
|
return replaceCursorAuthSymlink(destination, masterPath)
|
|
}
|
|
|
|
func replaceCursorAuthSymlink(destination, masterPath string) error {
|
|
file, err := os.CreateTemp(filepath.Dir(destination), ".kandev-mcp-auth-link-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempPath := file.Name()
|
|
if err := file.Close(); err != nil {
|
|
_ = os.Remove(tempPath)
|
|
return err
|
|
}
|
|
if err := os.Remove(tempPath); err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = os.Remove(tempPath) }()
|
|
if err := os.Symlink(masterPath, tempPath); err != nil {
|
|
return err
|
|
}
|
|
info, err := os.Lstat(destination)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return os.Rename(tempPath, destination)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink == 0 {
|
|
return nil
|
|
}
|
|
return os.Rename(tempPath, destination)
|
|
}
|
|
|
|
func resolveLinkTarget(linkDirectory, target string) string {
|
|
if !filepath.IsAbs(target) {
|
|
target = filepath.Join(linkDirectory, target)
|
|
}
|
|
absolute, err := filepath.Abs(target)
|
|
if err != nil {
|
|
return filepath.Clean(target)
|
|
}
|
|
return filepath.Clean(absolute)
|
|
}
|
|
|
|
func samePath(left, right string) bool {
|
|
return filepath.Clean(left) == filepath.Clean(right)
|
|
}
|