Files
kandev/apps/backend/internal/agent/mcpconfig/cursor_auth_bridge.go
T
2026-09-25 10:31:58 +01:00

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)
}