Skip to content

Commit 706125f

Browse files
authored
#298: Extract shared git-root resolver for project key lookups (#299)
* Extract shared git-root resolver for ~/.claude.json project key lookups - Add ProjectDetector.resolveProjectKey(from:in:) to walk up from project root to git root when resolving ~/.claude.json project keys - Fix ConfigurationDiscovery.discoverMCPServers() using the shared utility (mcs export was silently missing MCP servers in subdirectory projects) - Refactor MCPServerCheck to use the shared utility instead of inline walk-up loop * Add ConfigurationDiscovery tests for subdirectory project MCP server discovery - Walk-up finds servers keyed at git root from subdirectory project - Exact match when projectRoot equals gitRoot (regression) - Boundary: nested .git prevents escaping to outer repo
1 parent b01f658 commit 706125f

7 files changed

Lines changed: 422 additions & 17 deletions

Sources/mcs/Core/ProjectDetector.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,28 @@ enum ProjectDetector {
2121
return nil
2222
}
2323

24+
/// Walk up from `startingPath` looking for a path that exists in `projectKeys`.
25+
/// Stops at (and includes) the first directory containing `.git/`.
26+
/// Returns the matching key string, or nil if no match is found.
27+
///
28+
/// This resolves the mismatch between `findProjectRoot()` (which may return a
29+
/// subdirectory containing CLAUDE.local.md) and the Claude CLI's convention of
30+
/// keying project-scoped entries by the git root in `~/.claude.json`.
31+
static func resolveProjectKey(from startingPath: URL, in projectKeys: Set<String>) -> String? {
32+
let fm = FileManager.default
33+
var current = startingPath.standardizedFileURL
34+
while current.path != "/" {
35+
if projectKeys.contains(current.path) {
36+
return current.path
37+
}
38+
if fm.fileExists(atPath: current.appendingPathComponent(".git").path) {
39+
break
40+
}
41+
current = current.deletingLastPathComponent()
42+
}
43+
return nil
44+
}
45+
2446
/// Convenience: find project root from the current working directory.
2547
static func findProjectRoot() -> URL? {
2648
let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)

Sources/mcs/Doctor/CoreDoctorChecks.swift

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -68,22 +68,13 @@ struct MCPServerCheck: DoctorCheck {
6868
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
6969
return .fail("~/.claude.json contains invalid JSON")
7070
}
71-
// Walk up to the git root: Claude CLI keys local-scope servers by the git root,
72-
// which may differ from the mcs project root in subdirectory projects.
7371
if let root = projectRoot,
74-
let projects = json[Constants.JSONKeys.projects] as? [String: Any] {
75-
var candidate: URL? = root
76-
while let path = candidate, path.path != "/" {
77-
if let projectEntry = projects[path.path] as? [String: Any],
78-
let projectMCP = projectEntry[Constants.JSONKeys.mcpServers] as? [String: Any],
79-
projectMCP[serverName] != nil {
80-
return .pass("registered")
81-
}
82-
if FileManager.default.fileExists(atPath: path.appendingPathComponent(".git").path) {
83-
break
84-
}
85-
candidate = path.deletingLastPathComponent()
86-
}
72+
let projects = json[Constants.JSONKeys.projects] as? [String: Any],
73+
let matchedKey = ProjectDetector.resolveProjectKey(from: root, in: Set(projects.keys)),
74+
let projectEntry = projects[matchedKey] as? [String: Any],
75+
let projectMCP = projectEntry[Constants.JSONKeys.mcpServers] as? [String: Any],
76+
projectMCP[serverName] != nil {
77+
return .pass("registered")
8778
}
8879
// Fall back to global/user-scoped servers
8980
if let mcpServers = json[Constants.JSONKeys.mcpServers] as? [String: Any],

Sources/mcs/Export/ConfigurationDiscovery.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,9 @@ struct ConfigurationDiscovery {
161161
}
162162
}
163163
case let .project(projectRoot):
164-
// Read project-scoped servers from projects[path].mcpServers
165164
if let projects = json[Constants.JSONKeys.projects] as? [String: Any],
166-
let projectEntry = projects[projectRoot.path] as? [String: Any],
165+
let matchedKey = ProjectDetector.resolveProjectKey(from: projectRoot, in: Set(projects.keys)),
166+
let projectEntry = projects[matchedKey] as? [String: Any],
167167
let servers = projectEntry[Constants.JSONKeys.mcpServers] as? [String: Any] {
168168
for (name, value) in servers {
169169
if let serverDict = value as? [String: Any] {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import Foundation
2+
@testable import mcs
3+
import Testing
4+
5+
struct ConfigurationDiscoveryTests {
6+
@Test("discovers MCP servers when project root is a subdirectory of git root")
7+
func discoversMCPServersViaWalkUp() throws {
8+
let home = try makeGlobalTmpDir(label: "discovery-walkup")
9+
defer { try? FileManager.default.removeItem(at: home) }
10+
let env = Environment(home: home)
11+
12+
let gitRoot = home.appendingPathComponent("my-repo")
13+
let subProject = gitRoot.appendingPathComponent("packages/lib")
14+
try FileManager.default.createDirectory(
15+
at: gitRoot.appendingPathComponent(".git"),
16+
withIntermediateDirectories: true
17+
)
18+
try FileManager.default.createDirectory(
19+
at: subProject.appendingPathComponent(Constants.FileNames.claudeDirectory),
20+
withIntermediateDirectories: true
21+
)
22+
23+
let claudeJSON: [String: Any] = [
24+
"projects": [
25+
gitRoot.path: [
26+
"mcpServers": [
27+
"docs-server": [
28+
"command": "npx",
29+
"args": ["-y", "docs-server"],
30+
],
31+
],
32+
],
33+
],
34+
]
35+
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
36+
try data.write(to: env.claudeJSON)
37+
38+
let discovery = ConfigurationDiscovery(environment: env, output: CLIOutput())
39+
let config = discovery.discover(scope: ConfigurationDiscovery.Scope.project(subProject))
40+
41+
#expect(config.mcpServers.count == 1)
42+
#expect(config.mcpServers.first?.name == "docs-server")
43+
#expect(config.mcpServers.first?.scope == "local")
44+
}
45+
46+
@Test("discovers MCP servers when project root equals git root")
47+
func discoversMCPServersExactMatch() throws {
48+
let home = try makeGlobalTmpDir(label: "discovery-exact")
49+
defer { try? FileManager.default.removeItem(at: home) }
50+
let env = Environment(home: home)
51+
52+
let projectRoot = home.appendingPathComponent("my-project")
53+
try FileManager.default.createDirectory(
54+
at: projectRoot.appendingPathComponent(".git"),
55+
withIntermediateDirectories: true
56+
)
57+
try FileManager.default.createDirectory(
58+
at: projectRoot.appendingPathComponent(Constants.FileNames.claudeDirectory),
59+
withIntermediateDirectories: true
60+
)
61+
62+
let claudeJSON: [String: Any] = [
63+
"projects": [
64+
projectRoot.path: [
65+
"mcpServers": [
66+
"docs-server": [
67+
"command": "npx",
68+
"args": ["-y", "docs-server"],
69+
],
70+
],
71+
],
72+
],
73+
]
74+
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
75+
try data.write(to: env.claudeJSON)
76+
77+
let discovery = ConfigurationDiscovery(environment: env, output: CLIOutput())
78+
let config = discovery.discover(scope: ConfigurationDiscovery.Scope.project(projectRoot))
79+
80+
#expect(config.mcpServers.count == 1)
81+
#expect(config.mcpServers.first?.name == "docs-server")
82+
}
83+
84+
@Test("returns empty when no MCP servers match subdirectory project")
85+
func noMCPServersWhenBoundaryBlocks() throws {
86+
let home = try makeGlobalTmpDir(label: "discovery-boundary")
87+
defer { try? FileManager.default.removeItem(at: home) }
88+
let env = Environment(home: home)
89+
90+
let outerRepo = home.appendingPathComponent("outer")
91+
let innerRepo = outerRepo.appendingPathComponent("inner")
92+
try FileManager.default.createDirectory(
93+
at: outerRepo.appendingPathComponent(".git"),
94+
withIntermediateDirectories: true
95+
)
96+
try FileManager.default.createDirectory(
97+
at: innerRepo.appendingPathComponent(".git"),
98+
withIntermediateDirectories: true
99+
)
100+
try FileManager.default.createDirectory(
101+
at: innerRepo.appendingPathComponent(Constants.FileNames.claudeDirectory),
102+
withIntermediateDirectories: true
103+
)
104+
105+
// Server is keyed at outer repo, but inner repo has its own .git boundary
106+
let claudeJSON: [String: Any] = [
107+
"projects": [
108+
outerRepo.path: [
109+
"mcpServers": [
110+
"docs-server": [
111+
"command": "npx",
112+
"args": ["-y", "docs-server"],
113+
],
114+
],
115+
],
116+
],
117+
]
118+
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
119+
try data.write(to: env.claudeJSON)
120+
121+
let discovery = ConfigurationDiscovery(environment: env, output: CLIOutput())
122+
let config = discovery.discover(scope: ConfigurationDiscovery.Scope.project(innerRepo))
123+
124+
#expect(config.mcpServers.isEmpty)
125+
}
126+
}

Tests/MCSTests/CoreDoctorCheckSandboxTests.swift

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,118 @@ struct MCPServerCheckSandboxTests {
108108
return
109109
}
110110
}
111+
112+
@Test("pass when subdirectory project root walks up to find server at git root")
113+
func passWalkUpToGitRoot() throws {
114+
let home = try makeGlobalTmpDir(label: "mcp-walkup")
115+
defer { try? FileManager.default.removeItem(at: home) }
116+
let env = Environment(home: home)
117+
118+
let gitRoot = home.appendingPathComponent("my-project")
119+
let subProject = gitRoot.appendingPathComponent("packages/lib")
120+
try FileManager.default.createDirectory(
121+
at: gitRoot.appendingPathComponent(".git"),
122+
withIntermediateDirectories: true
123+
)
124+
try FileManager.default.createDirectory(at: subProject, withIntermediateDirectories: true)
125+
126+
let claudeJSON: [String: Any] = [
127+
"projects": [
128+
gitRoot.path: [
129+
"mcpServers": [
130+
"serena": ["command": "npx", "args": ["-y", "serena"]],
131+
],
132+
],
133+
],
134+
]
135+
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
136+
try data.write(to: env.claudeJSON)
137+
138+
let check = MCPServerCheck(
139+
name: "Serena", serverName: "serena",
140+
projectRoot: subProject, environment: env
141+
)
142+
let result = check.check()
143+
guard case .pass = result else {
144+
Issue.record("Expected .pass (walk-up), got \(result)")
145+
return
146+
}
147+
}
148+
149+
@Test("walk-up stops at .git boundary and does not escape repo")
150+
func walkUpStopsAtGitBoundary() throws {
151+
let home = try makeGlobalTmpDir(label: "mcp-boundary")
152+
defer { try? FileManager.default.removeItem(at: home) }
153+
let env = Environment(home: home)
154+
155+
let outerRepo = home.appendingPathComponent("outer")
156+
let innerRepo = outerRepo.appendingPathComponent("inner")
157+
try FileManager.default.createDirectory(
158+
at: outerRepo.appendingPathComponent(".git"),
159+
withIntermediateDirectories: true
160+
)
161+
try FileManager.default.createDirectory(
162+
at: innerRepo.appendingPathComponent(".git"),
163+
withIntermediateDirectories: true
164+
)
165+
166+
let claudeJSON: [String: Any] = [
167+
"projects": [
168+
outerRepo.path: [
169+
"mcpServers": [
170+
"serena": ["command": "npx", "args": ["-y", "serena"]],
171+
],
172+
],
173+
],
174+
]
175+
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
176+
try data.write(to: env.claudeJSON)
177+
178+
let check = MCPServerCheck(
179+
name: "Serena", serverName: "serena",
180+
projectRoot: innerRepo, environment: env
181+
)
182+
let result = check.check()
183+
guard case .fail = result else {
184+
Issue.record("Expected .fail (should not escape git boundary), got \(result)")
185+
return
186+
}
187+
}
188+
189+
@Test("pass when projectRoot equals gitRoot (regression)")
190+
func passExactMatchRegression() throws {
191+
let home = try makeGlobalTmpDir(label: "mcp-exact")
192+
defer { try? FileManager.default.removeItem(at: home) }
193+
let env = Environment(home: home)
194+
195+
let projectRoot = home.appendingPathComponent("my-project")
196+
try FileManager.default.createDirectory(
197+
at: projectRoot.appendingPathComponent(".git"),
198+
withIntermediateDirectories: true
199+
)
200+
201+
let claudeJSON: [String: Any] = [
202+
"projects": [
203+
projectRoot.path: [
204+
"mcpServers": [
205+
"serena": ["command": "npx", "args": ["-y", "serena"]],
206+
],
207+
],
208+
],
209+
]
210+
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
211+
try data.write(to: env.claudeJSON)
212+
213+
let check = MCPServerCheck(
214+
name: "Serena", serverName: "serena",
215+
projectRoot: projectRoot, environment: env
216+
)
217+
let result = check.check()
218+
guard case .pass = result else {
219+
Issue.record("Expected .pass (exact match regression), got \(result)")
220+
return
221+
}
222+
}
111223
}
112224

113225
// MARK: - PluginCheck Sandbox Tests

Tests/MCSTests/DoctorRunnerIntegrationTests.swift

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,4 +162,69 @@ struct DoctorRunnerIntegrationTests {
162162
// Should detect the pack from project state
163163
try runner.run()
164164
}
165+
166+
@Test("MCPServerCheck passes via walk-up when project root is a subdirectory of git root")
167+
func mcpCheckWalksUpToGitRoot() throws {
168+
let home = try makeGlobalTmpDir(label: "runner-walkup")
169+
defer { try? FileManager.default.removeItem(at: home) }
170+
let env = Environment(home: home)
171+
172+
// Git root at home/my-repo, project root at home/my-repo/packages/lib
173+
let gitRoot = home.appendingPathComponent("my-repo")
174+
let subProject = gitRoot.appendingPathComponent("packages/lib")
175+
try FileManager.default.createDirectory(
176+
at: gitRoot.appendingPathComponent(".git"),
177+
withIntermediateDirectories: true
178+
)
179+
try FileManager.default.createDirectory(
180+
at: subProject.appendingPathComponent(Constants.FileNames.claudeDirectory),
181+
withIntermediateDirectories: true
182+
)
183+
184+
// Pack with an MCP component
185+
let mcpComponent = ComponentDefinition(
186+
id: "test-pack.my-mcp",
187+
displayName: "My MCP",
188+
description: "Test MCP server",
189+
type: .mcpServer,
190+
packIdentifier: "test-pack",
191+
dependencies: [],
192+
isRequired: true,
193+
installAction: .mcpServer(MCPServerConfig(
194+
name: "my-mcp", command: "npx", args: ["-y", "my-mcp"], env: [:]
195+
))
196+
)
197+
let pack = MockTechPack(
198+
identifier: "test-pack",
199+
displayName: "Test Pack",
200+
components: [mcpComponent]
201+
)
202+
let registry = TechPackRegistry(packs: [pack])
203+
204+
// Record pack in project state at the subdirectory root
205+
var state = try ProjectState(projectRoot: subProject)
206+
state.recordPack("test-pack")
207+
state.setArtifacts(
208+
PackArtifactRecord(mcpServers: [MCPServerRef(name: "my-mcp", scope: "local")]),
209+
for: "test-pack"
210+
)
211+
try state.save()
212+
213+
// Write ~/.claude.json with the server keyed at the git root (as Claude CLI does)
214+
let claudeJSON: [String: Any] = [
215+
"projects": [
216+
gitRoot.path: [
217+
"mcpServers": [
218+
"my-mcp": ["command": "npx", "args": ["-y", "my-mcp"]],
219+
],
220+
],
221+
],
222+
]
223+
let data = try JSONSerialization.data(withJSONObject: claudeJSON)
224+
try data.write(to: env.claudeJSON)
225+
226+
// DoctorRunner with projectRoot at the subdirectory
227+
var runner = makeRunner(home: home, projectRoot: subProject, registry: registry)
228+
try runner.run()
229+
}
165230
}

0 commit comments

Comments
 (0)