Skip to content

Commit 2619635

Browse files
Copilotmitchdenny
andauthored
Optimize MCP init by consolidating git lookups and eliminating recursive searches (#13278)
* Initial plan * Optimize MCP init workspace detection - phase 1 complete Co-authored-by: mitchdenny <[email protected]> * Use "workspace root" terminology instead of "repository root" in scanners Co-authored-by: mitchdenny <[email protected]> * Address code review feedback: fix logic bug, improve comments, and use consistent terminology Co-authored-by: mitchdenny <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: mitchdenny <[email protected]>
1 parent b8947d8 commit 2619635

27 files changed

+417
-130
lines changed

src/Aspire.Cli/Agents/AgentEnvironmentDetector.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ namespace Aspire.Cli.Agents;
99
internal sealed class AgentEnvironmentDetector(IEnumerable<IAgentEnvironmentScanner> scanners) : IAgentEnvironmentDetector
1010
{
1111
/// <inheritdoc />
12-
public async Task<AgentEnvironmentApplicator[]> DetectAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken)
12+
public async Task<AgentEnvironmentApplicator[]> DetectAsync(DirectoryInfo workingDirectory, DirectoryInfo repositoryRoot, CancellationToken cancellationToken)
1313
{
1414
var context = new AgentEnvironmentScanContext
1515
{
16-
WorkingDirectory = workingDirectory
16+
WorkingDirectory = workingDirectory,
17+
RepositoryRoot = repositoryRoot
1718
};
1819

1920
foreach (var scanner in scanners)

src/Aspire.Cli/Agents/AgentEnvironmentScanContext.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ internal sealed class AgentEnvironmentScanContext
1515
/// </summary>
1616
public required DirectoryInfo WorkingDirectory { get; init; }
1717

18+
/// <summary>
19+
/// Gets the root directory of the repository/workspace.
20+
/// This is typically the git repository root if available, otherwise the working directory.
21+
/// Scanners should use this as the boundary for searches instead of searching up the directory tree.
22+
/// </summary>
23+
public required DirectoryInfo RepositoryRoot { get; init; }
24+
1825
/// <summary>
1926
/// Adds an applicator to the collection of detected agent environments.
2027
/// </summary>

src/Aspire.Cli/Agents/ClaudeCode/ClaudeCodeAgentEnvironmentScanner.cs

Lines changed: 23 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using System.Text.Json;
55
using System.Text.Json.Nodes;
6-
using Aspire.Cli.Git;
76
using Aspire.Cli.Resources;
87
using Microsoft.Extensions.Logging;
98

@@ -18,22 +17,18 @@ internal sealed class ClaudeCodeAgentEnvironmentScanner : IAgentEnvironmentScann
1817
private const string McpConfigFileName = ".mcp.json";
1918
private const string AspireServerName = "aspire";
2019

21-
private readonly IGitRepository _gitRepository;
2220
private readonly IClaudeCodeCliRunner _claudeCodeCliRunner;
2321
private readonly ILogger<ClaudeCodeAgentEnvironmentScanner> _logger;
2422

2523
/// <summary>
2624
/// Initializes a new instance of <see cref="ClaudeCodeAgentEnvironmentScanner"/>.
2725
/// </summary>
28-
/// <param name="gitRepository">The Git repository service for finding repository boundaries.</param>
2926
/// <param name="claudeCodeCliRunner">The Claude Code CLI runner for checking if Claude Code is installed.</param>
3027
/// <param name="logger">The logger for diagnostic output.</param>
31-
public ClaudeCodeAgentEnvironmentScanner(IGitRepository gitRepository, IClaudeCodeCliRunner claudeCodeCliRunner, ILogger<ClaudeCodeAgentEnvironmentScanner> logger)
28+
public ClaudeCodeAgentEnvironmentScanner(IClaudeCodeCliRunner claudeCodeCliRunner, ILogger<ClaudeCodeAgentEnvironmentScanner> logger)
3229
{
33-
ArgumentNullException.ThrowIfNull(gitRepository);
3430
ArgumentNullException.ThrowIfNull(claudeCodeCliRunner);
3531
ArgumentNullException.ThrowIfNull(logger);
36-
_gitRepository = gitRepository;
3732
_claudeCodeCliRunner = claudeCodeCliRunner;
3833
_logger = logger;
3934
}
@@ -42,64 +37,52 @@ public ClaudeCodeAgentEnvironmentScanner(IGitRepository gitRepository, IClaudeCo
4237
public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationToken cancellationToken)
4338
{
4439
_logger.LogDebug("Starting Claude Code environment scan in directory: {WorkingDirectory}", context.WorkingDirectory.FullName);
45-
46-
// Get the git root to use as a boundary for searching
47-
_logger.LogDebug("Finding git repository root...");
48-
var gitRoot = await _gitRepository.GetRootAsync(cancellationToken).ConfigureAwait(false);
49-
_logger.LogDebug("Git root: {GitRoot}", gitRoot?.FullName ?? "(none)");
40+
_logger.LogDebug("Workspace root: {RepositoryRoot}", context.RepositoryRoot.FullName);
5041

5142
// Find the .claude folder to determine if Claude Code is being used in this project
5243
_logger.LogDebug("Searching for .claude folder...");
53-
var claudeCodeFolder = FindClaudeCodeFolder(context.WorkingDirectory, gitRoot);
44+
var claudeCodeFolder = FindClaudeCodeFolder(context.WorkingDirectory, context.RepositoryRoot);
5445

55-
// Determine the repo root - use git root, or infer from .claude folder location, or fall back to working directory
56-
DirectoryInfo? repoRoot = gitRoot;
57-
if (repoRoot is null && claudeCodeFolder is not null)
46+
if (claudeCodeFolder is not null)
5847
{
59-
// .claude folder's parent is the repo root
60-
repoRoot = claudeCodeFolder.Parent;
61-
_logger.LogDebug("Inferred repo root from .claude folder parent: {RepoRoot}", repoRoot?.FullName ?? "(none)");
62-
}
63-
64-
if (claudeCodeFolder is not null || repoRoot is not null)
65-
{
66-
var targetRepoRoot = repoRoot ?? context.WorkingDirectory;
67-
_logger.LogDebug("Found .claude folder or repo root at: {RepoRoot}", targetRepoRoot.FullName);
48+
// If .claude folder is found, override the workspace root with its parent directory
49+
var workspaceRoot = claudeCodeFolder.Parent ?? context.RepositoryRoot;
50+
_logger.LogDebug("Inferred workspace root from .claude folder parent: {WorkspaceRoot}", workspaceRoot.FullName);
6851

6952
// Check if the aspire server is already configured in .mcp.json
7053
_logger.LogDebug("Checking if Aspire MCP server is already configured in .mcp.json...");
71-
if (HasAspireServerConfigured(targetRepoRoot))
54+
if (HasAspireServerConfigured(workspaceRoot))
7255
{
7356
_logger.LogDebug("Aspire MCP server is already configured - skipping");
7457
// Already configured, no need to offer an applicator
7558
return;
7659
}
7760

78-
// Found a .claude folder or git repo - add an applicator to configure MCP
79-
_logger.LogDebug("Adding Claude Code applicator for .mcp.json at: {RepoRoot}", targetRepoRoot.FullName);
80-
context.AddApplicator(CreateApplicator(targetRepoRoot));
61+
// Found a .claude folder - add an applicator to configure MCP
62+
_logger.LogDebug("Adding Claude Code applicator for .mcp.json at: {WorkspaceRoot}", workspaceRoot.FullName);
63+
context.AddApplicator(CreateApplicator(workspaceRoot));
8164
}
8265
else
8366
{
84-
// No .claude folder or git repo found - check if Claude Code CLI is installed
85-
_logger.LogDebug("No .claude folder or git repo found, checking for Claude Code CLI installation...");
67+
// No .claude folder found - check if Claude Code CLI is installed
68+
_logger.LogDebug("No .claude folder found, checking for Claude Code CLI installation...");
8669
var claudeCodeVersion = await _claudeCodeCliRunner.GetVersionAsync(cancellationToken).ConfigureAwait(false);
8770

8871
if (claudeCodeVersion is not null)
8972
{
9073
_logger.LogDebug("Found Claude Code CLI version: {Version}", claudeCodeVersion);
9174

9275
// Check if the aspire server is already configured in .mcp.json
93-
if (HasAspireServerConfigured(context.WorkingDirectory))
76+
if (HasAspireServerConfigured(context.RepositoryRoot))
9477
{
9578
_logger.LogDebug("Aspire MCP server is already configured - skipping");
9679
// Already configured, no need to offer an applicator
9780
return;
9881
}
9982

100-
// Claude Code is installed - offer to create config at working directory
101-
_logger.LogDebug("Adding Claude Code applicator for .mcp.json at working directory: {WorkingDirectory}", context.WorkingDirectory.FullName);
102-
context.AddApplicator(CreateApplicator(context.WorkingDirectory));
83+
// Claude Code is installed - offer to create config at workspace root
84+
_logger.LogDebug("Adding Claude Code applicator for .mcp.json at workspace root: {WorkspaceRoot}", context.RepositoryRoot.FullName);
85+
context.AddApplicator(CreateApplicator(context.RepositoryRoot));
10386
}
10487
else
10588
{
@@ -110,12 +93,12 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok
11093

11194
/// <summary>
11295
/// Walks up the directory tree to find a .claude folder.
113-
/// Stops if we go above the git root (if provided).
96+
/// Stops if we go above the workspace root.
11497
/// Ignores the .claude folder in the user's home directory.
11598
/// </summary>
11699
/// <param name="startDirectory">The directory to start searching from.</param>
117-
/// <param name="gitRoot">The git repository root, or null if not in a git repository.</param>
118-
private static DirectoryInfo? FindClaudeCodeFolder(DirectoryInfo startDirectory, DirectoryInfo? gitRoot)
100+
/// <param name="repositoryRoot">The workspace root to use as the boundary for searches.</param>
101+
private static DirectoryInfo? FindClaudeCodeFolder(DirectoryInfo startDirectory, DirectoryInfo repositoryRoot)
119102
{
120103
var currentDirectory = startDirectory;
121104
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
@@ -130,9 +113,9 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok
130113
return new DirectoryInfo(claudeCodePath);
131114
}
132115

133-
// Stop if we've reached the git root without finding .claude
134-
// (don't search above the repository boundary)
135-
if (gitRoot is not null && string.Equals(currentDirectory.FullName, gitRoot.FullName, StringComparison.OrdinalIgnoreCase))
116+
// Stop if we've reached the workspace root without finding .claude
117+
// (don't search above the workspace boundary)
118+
if (string.Equals(currentDirectory.FullName, repositoryRoot.FullName, StringComparison.OrdinalIgnoreCase))
136119
{
137120
return null;
138121
}

src/Aspire.Cli/Agents/IAgentEnvironmentDetector.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ internal interface IAgentEnvironmentDetector
1212
/// Detects available agent environments by running all registered scanners.
1313
/// </summary>
1414
/// <param name="workingDirectory">The working directory to scan.</param>
15+
/// <param name="repositoryRoot">The root directory of the repository/workspace. Scanners use this as the boundary for searches.</param>
1516
/// <param name="cancellationToken">A token to cancel the operation.</param>
1617
/// <returns>An array of applicators for detected agent environments.</returns>
17-
Task<AgentEnvironmentApplicator[]> DetectAsync(DirectoryInfo workingDirectory, CancellationToken cancellationToken);
18+
Task<AgentEnvironmentApplicator[]> DetectAsync(DirectoryInfo workingDirectory, DirectoryInfo repositoryRoot, CancellationToken cancellationToken);
1819
}

src/Aspire.Cli/Agents/OpenCode/OpenCodeAgentEnvironmentScanner.cs

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using System.Text.Json;
55
using System.Text.Json.Nodes;
6-
using Aspire.Cli.Git;
76
using Aspire.Cli.Resources;
87
using Microsoft.Extensions.Logging;
98

@@ -17,22 +16,18 @@ internal sealed class OpenCodeAgentEnvironmentScanner : IAgentEnvironmentScanner
1716
private const string OpenCodeConfigFileName = "opencode.jsonc";
1817
private const string AspireServerName = "aspire";
1918

20-
private readonly IGitRepository _gitRepository;
2119
private readonly IOpenCodeCliRunner _openCodeCliRunner;
2220
private readonly ILogger<OpenCodeAgentEnvironmentScanner> _logger;
2321

2422
/// <summary>
2523
/// Initializes a new instance of <see cref="OpenCodeAgentEnvironmentScanner"/>.
2624
/// </summary>
27-
/// <param name="gitRepository">The Git repository service for finding repository boundaries.</param>
2825
/// <param name="openCodeCliRunner">The OpenCode CLI runner for checking if OpenCode is installed.</param>
2926
/// <param name="logger">The logger for diagnostic output.</param>
30-
public OpenCodeAgentEnvironmentScanner(IGitRepository gitRepository, IOpenCodeCliRunner openCodeCliRunner, ILogger<OpenCodeAgentEnvironmentScanner> logger)
27+
public OpenCodeAgentEnvironmentScanner(IOpenCodeCliRunner openCodeCliRunner, ILogger<OpenCodeAgentEnvironmentScanner> logger)
3128
{
32-
ArgumentNullException.ThrowIfNull(gitRepository);
3329
ArgumentNullException.ThrowIfNull(openCodeCliRunner);
3430
ArgumentNullException.ThrowIfNull(logger);
35-
_gitRepository = gitRepository;
3631
_openCodeCliRunner = openCodeCliRunner;
3732
_logger = logger;
3833
}
@@ -41,14 +36,10 @@ public OpenCodeAgentEnvironmentScanner(IGitRepository gitRepository, IOpenCodeCl
4136
public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationToken cancellationToken)
4237
{
4338
_logger.LogDebug("Starting OpenCode environment scan in directory: {WorkingDirectory}", context.WorkingDirectory.FullName);
44-
45-
// Get the git root - OpenCode config should be at the repo root
46-
_logger.LogDebug("Finding git repository root...");
47-
var gitRoot = await _gitRepository.GetRootAsync(cancellationToken).ConfigureAwait(false);
48-
_logger.LogDebug("Git root: {GitRoot}", gitRoot?.FullName ?? "(none)");
49-
50-
// Look for existing opencode.jsonc file at git root or working directory
51-
var configDirectory = gitRoot ?? context.WorkingDirectory;
39+
_logger.LogDebug("Workspace root: {RepositoryRoot}", context.RepositoryRoot.FullName);
40+
41+
// Look for existing opencode.jsonc file at workspace root
42+
var configDirectory = context.RepositoryRoot;
5243
var configFilePath = Path.Combine(configDirectory.FullName, OpenCodeConfigFileName);
5344
var configFileExists = File.Exists(configFilePath);
5445

src/Aspire.Cli/Agents/VsCode/VsCodeAgentEnvironmentScanner.cs

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using System.Text.Json;
55
using System.Text.Json.Nodes;
6-
using Aspire.Cli.Git;
76
using Aspire.Cli.Resources;
87
using Microsoft.Extensions.Logging;
98

@@ -19,22 +18,18 @@ internal sealed class VsCodeAgentEnvironmentScanner : IAgentEnvironmentScanner
1918
private const string VsCodeEnvironmentVariablePrefix = "VSCODE_";
2019
private const string AspireServerName = "aspire";
2120

22-
private readonly IGitRepository _gitRepository;
2321
private readonly IVsCodeCliRunner _vsCodeCliRunner;
2422
private readonly ILogger<VsCodeAgentEnvironmentScanner> _logger;
2523

2624
/// <summary>
2725
/// Initializes a new instance of <see cref="VsCodeAgentEnvironmentScanner"/>.
2826
/// </summary>
29-
/// <param name="gitRepository">The Git repository service for finding repository boundaries.</param>
3027
/// <param name="vsCodeCliRunner">The VS Code CLI runner for checking if VS Code is installed.</param>
3128
/// <param name="logger">The logger for diagnostic output.</param>
32-
public VsCodeAgentEnvironmentScanner(IGitRepository gitRepository, IVsCodeCliRunner vsCodeCliRunner, ILogger<VsCodeAgentEnvironmentScanner> logger)
29+
public VsCodeAgentEnvironmentScanner(IVsCodeCliRunner vsCodeCliRunner, ILogger<VsCodeAgentEnvironmentScanner> logger)
3330
{
34-
ArgumentNullException.ThrowIfNull(gitRepository);
3531
ArgumentNullException.ThrowIfNull(vsCodeCliRunner);
3632
ArgumentNullException.ThrowIfNull(logger);
37-
_gitRepository = gitRepository;
3833
_vsCodeCliRunner = vsCodeCliRunner;
3934
_logger = logger;
4035
}
@@ -43,14 +38,10 @@ public VsCodeAgentEnvironmentScanner(IGitRepository gitRepository, IVsCodeCliRun
4338
public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationToken cancellationToken)
4439
{
4540
_logger.LogDebug("Starting VS Code environment scan in directory: {WorkingDirectory}", context.WorkingDirectory.FullName);
46-
47-
// Get the git root to use as a boundary for searching
48-
_logger.LogDebug("Finding git repository root...");
49-
var gitRoot = await _gitRepository.GetRootAsync(cancellationToken).ConfigureAwait(false);
50-
_logger.LogDebug("Git root: {GitRoot}", gitRoot?.FullName ?? "(none)");
41+
_logger.LogDebug("Workspace root: {RepositoryRoot}", context.RepositoryRoot.FullName);
5142

5243
_logger.LogDebug("Searching for .vscode folder...");
53-
var vsCodeFolder = FindVsCodeFolder(context.WorkingDirectory, gitRoot);
44+
var vsCodeFolder = FindVsCodeFolder(context.WorkingDirectory, context.RepositoryRoot);
5445

5546
if (vsCodeFolder is not null)
5647
{
@@ -72,9 +63,8 @@ public async Task ScanAsync(AgentEnvironmentScanContext context, CancellationTok
7263
{
7364
_logger.LogDebug("No .vscode folder found, but VS Code is available on the system");
7465
// No .vscode folder found, but VS Code is available
75-
// Use git root if available, otherwise fall back to current working directory
76-
var targetDirectory = gitRoot ?? context.WorkingDirectory;
77-
var targetVsCodeFolder = new DirectoryInfo(Path.Combine(targetDirectory.FullName, VsCodeFolderName));
66+
// Use workspace root for new .vscode folder
67+
var targetVsCodeFolder = new DirectoryInfo(Path.Combine(context.RepositoryRoot.FullName, VsCodeFolderName));
7868
_logger.LogDebug("Adding VS Code applicator for new .vscode folder at: {VsCodeFolder}", targetVsCodeFolder.FullName);
7969
context.AddApplicator(CreateApplicator(targetVsCodeFolder));
8070
}
@@ -125,12 +115,12 @@ private async Task<bool> IsVsCodeAvailableAsync(CancellationToken cancellationTo
125115

126116
/// <summary>
127117
/// Walks up the directory tree to find a .vscode folder.
128-
/// Stops if we go above the git root (if provided).
118+
/// Stops if we go above the workspace root.
129119
/// Ignores the .vscode folder in the user's home directory (used for user settings, not workspace config).
130120
/// </summary>
131121
/// <param name="startDirectory">The directory to start searching from.</param>
132-
/// <param name="gitRoot">The git repository root, or null if not in a git repository.</param>
133-
private static DirectoryInfo? FindVsCodeFolder(DirectoryInfo startDirectory, DirectoryInfo? gitRoot)
122+
/// <param name="repositoryRoot">The workspace root to use as the boundary for searches.</param>
123+
private static DirectoryInfo? FindVsCodeFolder(DirectoryInfo startDirectory, DirectoryInfo repositoryRoot)
134124
{
135125
var currentDirectory = startDirectory;
136126
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
@@ -145,9 +135,9 @@ private async Task<bool> IsVsCodeAvailableAsync(CancellationToken cancellationTo
145135
return new DirectoryInfo(vsCodePath);
146136
}
147137

148-
// Stop if we've reached the git root without finding .vscode
149-
// (don't search above the repository boundary)
150-
if (gitRoot is not null && string.Equals(currentDirectory.FullName, gitRoot.FullName, StringComparison.OrdinalIgnoreCase))
138+
// Stop if we've reached the workspace root without finding .vscode
139+
// (don't search above the workspace boundary)
140+
if (string.Equals(currentDirectory.FullName, repositoryRoot.FullName, StringComparison.OrdinalIgnoreCase))
151141
{
152142
return null;
153143
}

src/Aspire.Cli/Commands/McpCommand.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Aspire.Cli.Agents;
77
using Aspire.Cli.Backchannel;
88
using Aspire.Cli.Configuration;
9+
using Aspire.Cli.Git;
910
using Aspire.Cli.Interaction;
1011
using Aspire.Cli.Resources;
1112
using Aspire.Cli.Utils;
@@ -23,15 +24,16 @@ public McpCommand(
2324
IAuxiliaryBackchannelMonitor auxiliaryBackchannelMonitor,
2425
ILoggerFactory loggerFactory,
2526
ILogger<McpStartCommand> logger,
26-
IAgentEnvironmentDetector agentEnvironmentDetector)
27+
IAgentEnvironmentDetector agentEnvironmentDetector,
28+
IGitRepository gitRepository)
2729
: base("mcp", McpCommandStrings.Description, features, updateNotifier, executionContext, interactionService)
2830
{
2931
ArgumentNullException.ThrowIfNull(interactionService);
3032

3133
var startCommand = new McpStartCommand(interactionService, features, updateNotifier, executionContext, auxiliaryBackchannelMonitor, loggerFactory, logger);
3234
Subcommands.Add(startCommand);
3335

34-
var initCommand = new McpInitCommand(interactionService, features, updateNotifier, executionContext, agentEnvironmentDetector);
36+
var initCommand = new McpInitCommand(interactionService, features, updateNotifier, executionContext, agentEnvironmentDetector, gitRepository);
3537
Subcommands.Add(initCommand);
3638
}
3739

0 commit comments

Comments
 (0)