Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Sources/mcs/Core/ProjectDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,28 @@ enum ProjectDetector {
return nil
}

/// Walk up from `startingPath` looking for a path that exists in `projectKeys`.
/// Stops at (and includes) the first directory containing `.git/`.
/// Returns the matching key string, or nil if no match is found.
///
/// This resolves the mismatch between `findProjectRoot()` (which may return a
/// subdirectory containing CLAUDE.local.md) and the Claude CLI's convention of
/// keying project-scoped entries by the git root in `~/.claude.json`.
static func resolveProjectKey(from startingPath: URL, in projectKeys: Set<String>) -> String? {
let fm = FileManager.default
var current = startingPath.standardizedFileURL
while current.path != "/" {
if projectKeys.contains(current.path) {
return current.path
}
if fm.fileExists(atPath: current.appendingPathComponent(".git").path) {
break
}
current = current.deletingLastPathComponent()
}
return nil
}

/// Convenience: find project root from the current working directory.
static func findProjectRoot() -> URL? {
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
Expand Down
21 changes: 6 additions & 15 deletions Sources/mcs/Doctor/CoreDoctorChecks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,13 @@ struct MCPServerCheck: DoctorCheck {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return .fail("~/.claude.json contains invalid JSON")
}
// Walk up to the git root: Claude CLI keys local-scope servers by the git root,
// which may differ from the mcs project root in subdirectory projects.
if let root = projectRoot,
let projects = json[Constants.JSONKeys.projects] as? [String: Any] {
var candidate: URL? = root
while let path = candidate, path.path != "/" {
if let projectEntry = projects[path.path] as? [String: Any],
let projectMCP = projectEntry[Constants.JSONKeys.mcpServers] as? [String: Any],
projectMCP[serverName] != nil {
return .pass("registered")
}
if FileManager.default.fileExists(atPath: path.appendingPathComponent(".git").path) {
break
}
candidate = path.deletingLastPathComponent()
}
let projects = json[Constants.JSONKeys.projects] as? [String: Any],
let matchedKey = ProjectDetector.resolveProjectKey(from: root, in: Set(projects.keys)),
let projectEntry = projects[matchedKey] as? [String: Any],
let projectMCP = projectEntry[Constants.JSONKeys.mcpServers] as? [String: Any],
projectMCP[serverName] != nil {
return .pass("registered")
}
// Fall back to global/user-scoped servers
if let mcpServers = json[Constants.JSONKeys.mcpServers] as? [String: Any],
Expand Down
4 changes: 2 additions & 2 deletions Sources/mcs/Export/ConfigurationDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,9 @@ struct ConfigurationDiscovery {
}
}
case let .project(projectRoot):
// Read project-scoped servers from projects[path].mcpServers
if let projects = json[Constants.JSONKeys.projects] as? [String: Any],
let projectEntry = projects[projectRoot.path] as? [String: Any],
let matchedKey = ProjectDetector.resolveProjectKey(from: projectRoot, in: Set(projects.keys)),
let projectEntry = projects[matchedKey] as? [String: Any],
let servers = projectEntry[Constants.JSONKeys.mcpServers] as? [String: Any] {
for (name, value) in servers {
if let serverDict = value as? [String: Any] {
Expand Down
126 changes: 126 additions & 0 deletions Tests/MCSTests/ConfigurationDiscoveryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import Foundation
@testable import mcs
import Testing

struct ConfigurationDiscoveryTests {
@Test("discovers MCP servers when project root is a subdirectory of git root")
func discoversMCPServersViaWalkUp() throws {
let home = try makeGlobalTmpDir(label: "discovery-walkup")
defer { try? FileManager.default.removeItem(at: home) }
let env = Environment(home: home)

let gitRoot = home.appendingPathComponent("my-repo")
let subProject = gitRoot.appendingPathComponent("packages/lib")
try FileManager.default.createDirectory(
at: gitRoot.appendingPathComponent(".git"),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: subProject.appendingPathComponent(Constants.FileNames.claudeDirectory),
withIntermediateDirectories: true
)

let claudeJSON: [String: Any] = [
"projects": [
gitRoot.path: [
"mcpServers": [
"docs-server": [
"command": "npx",
"args": ["-y", "docs-server"],
],
],
],
],
]
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
try data.write(to: env.claudeJSON)

let discovery = ConfigurationDiscovery(environment: env, output: CLIOutput())
let config = discovery.discover(scope: ConfigurationDiscovery.Scope.project(subProject))

#expect(config.mcpServers.count == 1)
#expect(config.mcpServers.first?.name == "docs-server")
#expect(config.mcpServers.first?.scope == "local")
}

@Test("discovers MCP servers when project root equals git root")
func discoversMCPServersExactMatch() throws {
let home = try makeGlobalTmpDir(label: "discovery-exact")
defer { try? FileManager.default.removeItem(at: home) }
let env = Environment(home: home)

let projectRoot = home.appendingPathComponent("my-project")
try FileManager.default.createDirectory(
at: projectRoot.appendingPathComponent(".git"),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: projectRoot.appendingPathComponent(Constants.FileNames.claudeDirectory),
withIntermediateDirectories: true
)

let claudeJSON: [String: Any] = [
"projects": [
projectRoot.path: [
"mcpServers": [
"docs-server": [
"command": "npx",
"args": ["-y", "docs-server"],
],
],
],
],
]
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
try data.write(to: env.claudeJSON)

let discovery = ConfigurationDiscovery(environment: env, output: CLIOutput())
let config = discovery.discover(scope: ConfigurationDiscovery.Scope.project(projectRoot))

#expect(config.mcpServers.count == 1)
#expect(config.mcpServers.first?.name == "docs-server")
}

@Test("returns empty when no MCP servers match subdirectory project")
func noMCPServersWhenBoundaryBlocks() throws {
let home = try makeGlobalTmpDir(label: "discovery-boundary")
defer { try? FileManager.default.removeItem(at: home) }
let env = Environment(home: home)

let outerRepo = home.appendingPathComponent("outer")
let innerRepo = outerRepo.appendingPathComponent("inner")
try FileManager.default.createDirectory(
at: outerRepo.appendingPathComponent(".git"),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: innerRepo.appendingPathComponent(".git"),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: innerRepo.appendingPathComponent(Constants.FileNames.claudeDirectory),
withIntermediateDirectories: true
)

// Server is keyed at outer repo, but inner repo has its own .git boundary
let claudeJSON: [String: Any] = [
"projects": [
outerRepo.path: [
"mcpServers": [
"docs-server": [
"command": "npx",
"args": ["-y", "docs-server"],
],
],
],
],
]
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
try data.write(to: env.claudeJSON)

let discovery = ConfigurationDiscovery(environment: env, output: CLIOutput())
let config = discovery.discover(scope: ConfigurationDiscovery.Scope.project(innerRepo))

#expect(config.mcpServers.isEmpty)
}
}
112 changes: 112 additions & 0 deletions Tests/MCSTests/CoreDoctorCheckSandboxTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,118 @@ struct MCPServerCheckSandboxTests {
return
}
}

@Test("pass when subdirectory project root walks up to find server at git root")
func passWalkUpToGitRoot() throws {
let home = try makeGlobalTmpDir(label: "mcp-walkup")
defer { try? FileManager.default.removeItem(at: home) }
let env = Environment(home: home)

let gitRoot = home.appendingPathComponent("my-project")
let subProject = gitRoot.appendingPathComponent("packages/lib")
try FileManager.default.createDirectory(
at: gitRoot.appendingPathComponent(".git"),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(at: subProject, withIntermediateDirectories: true)

let claudeJSON: [String: Any] = [
"projects": [
gitRoot.path: [
"mcpServers": [
"serena": ["command": "npx", "args": ["-y", "serena"]],
],
],
],
]
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
try data.write(to: env.claudeJSON)

let check = MCPServerCheck(
name: "Serena", serverName: "serena",
projectRoot: subProject, environment: env
)
let result = check.check()
guard case .pass = result else {
Issue.record("Expected .pass (walk-up), got \(result)")
return
}
}

@Test("walk-up stops at .git boundary and does not escape repo")
func walkUpStopsAtGitBoundary() throws {
let home = try makeGlobalTmpDir(label: "mcp-boundary")
defer { try? FileManager.default.removeItem(at: home) }
let env = Environment(home: home)

let outerRepo = home.appendingPathComponent("outer")
let innerRepo = outerRepo.appendingPathComponent("inner")
try FileManager.default.createDirectory(
at: outerRepo.appendingPathComponent(".git"),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: innerRepo.appendingPathComponent(".git"),
withIntermediateDirectories: true
)

let claudeJSON: [String: Any] = [
"projects": [
outerRepo.path: [
"mcpServers": [
"serena": ["command": "npx", "args": ["-y", "serena"]],
],
],
],
]
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
try data.write(to: env.claudeJSON)

let check = MCPServerCheck(
name: "Serena", serverName: "serena",
projectRoot: innerRepo, environment: env
)
let result = check.check()
guard case .fail = result else {
Issue.record("Expected .fail (should not escape git boundary), got \(result)")
return
}
}

@Test("pass when projectRoot equals gitRoot (regression)")
func passExactMatchRegression() throws {
let home = try makeGlobalTmpDir(label: "mcp-exact")
defer { try? FileManager.default.removeItem(at: home) }
let env = Environment(home: home)

let projectRoot = home.appendingPathComponent("my-project")
try FileManager.default.createDirectory(
at: projectRoot.appendingPathComponent(".git"),
withIntermediateDirectories: true
)

let claudeJSON: [String: Any] = [
"projects": [
projectRoot.path: [
"mcpServers": [
"serena": ["command": "npx", "args": ["-y", "serena"]],
],
],
],
]
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
try data.write(to: env.claudeJSON)

let check = MCPServerCheck(
name: "Serena", serverName: "serena",
projectRoot: projectRoot, environment: env
)
let result = check.check()
guard case .pass = result else {
Issue.record("Expected .pass (exact match regression), got \(result)")
return
}
}
}

// MARK: - PluginCheck Sandbox Tests
Expand Down
65 changes: 65 additions & 0 deletions Tests/MCSTests/DoctorRunnerIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,69 @@ struct DoctorRunnerIntegrationTests {
// Should detect the pack from project state
try runner.run()
}

@Test("MCPServerCheck passes via walk-up when project root is a subdirectory of git root")
func mcpCheckWalksUpToGitRoot() throws {
let home = try makeGlobalTmpDir(label: "runner-walkup")
defer { try? FileManager.default.removeItem(at: home) }
let env = Environment(home: home)

// Git root at home/my-repo, project root at home/my-repo/packages/lib
let gitRoot = home.appendingPathComponent("my-repo")
let subProject = gitRoot.appendingPathComponent("packages/lib")
try FileManager.default.createDirectory(
at: gitRoot.appendingPathComponent(".git"),
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: subProject.appendingPathComponent(Constants.FileNames.claudeDirectory),
withIntermediateDirectories: true
)

// Pack with an MCP component
let mcpComponent = ComponentDefinition(
id: "test-pack.my-mcp",
displayName: "My MCP",
description: "Test MCP server",
type: .mcpServer,
packIdentifier: "test-pack",
dependencies: [],
isRequired: true,
installAction: .mcpServer(MCPServerConfig(
name: "my-mcp", command: "npx", args: ["-y", "my-mcp"], env: [:]
))
)
let pack = MockTechPack(
identifier: "test-pack",
displayName: "Test Pack",
components: [mcpComponent]
)
let registry = TechPackRegistry(packs: [pack])

// Record pack in project state at the subdirectory root
var state = try ProjectState(projectRoot: subProject)
state.recordPack("test-pack")
state.setArtifacts(
PackArtifactRecord(mcpServers: [MCPServerRef(name: "my-mcp", scope: "local")]),
for: "test-pack"
)
try state.save()

// Write ~/.claude.json with the server keyed at the git root (as Claude CLI does)
let claudeJSON: [String: Any] = [
"projects": [
gitRoot.path: [
"mcpServers": [
"my-mcp": ["command": "npx", "args": ["-y", "my-mcp"]],
],
],
],
]
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
try data.write(to: env.claudeJSON)

// DoctorRunner with projectRoot at the subdirectory
var runner = makeRunner(home: home, projectRoot: subProject, registry: registry)
try runner.run()
}
}
Loading
Loading