Skip to content

Commit c282e31

Browse files
committed
feat: ensuring tool success
1 parent 14bae03 commit c282e31

45 files changed

Lines changed: 1673 additions & 957 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

assets/logo.png

945 KB
Loading

resources/publish.manifest.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"$schema": "http://json.schemastore.org/vsix-publish",
3+
"categories": [
4+
"coding",
5+
"other"
6+
],
7+
"identity": {
8+
"internalName": "VS-VSMCP"
9+
},
10+
"overview": "../README.md",
11+
"publisher": "CodingWithCalvin",
12+
"qna": true,
13+
"repo": "https://www.github.com/CodingWithCalvin/VS-VSMCP"
14+
}

src/CodingWithCalvin.VSMCP.Server/CodingWithCalvin.VSMCP.Server.csproj renamed to src/CodingWithCalvin.MCPServer.Server/CodingWithCalvin.MCPServer.Server.csproj

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
<LangVersion>latest</LangVersion>
77
<Nullable>enable</Nullable>
88
<ImplicitUsings>disable</ImplicitUsings>
9-
<RootNamespace>CodingWithCalvin.VSMCP.Server</RootNamespace>
10-
<AssemblyName>CodingWithCalvin.VSMCP.Server</AssemblyName>
9+
<RootNamespace>CodingWithCalvin.MCPServer.Server</RootNamespace>
10+
<AssemblyName>CodingWithCalvin.MCPServer.Server</AssemblyName>
11+
<PublishSingleFile>true</PublishSingleFile>
12+
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
13+
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
1114
</PropertyGroup>
1215

1316
<ItemGroup>
@@ -18,7 +21,7 @@
1821
</ItemGroup>
1922

2023
<ItemGroup>
21-
<ProjectReference Include="..\CodingWithCalvin.VSMCP.Shared\CodingWithCalvin.VSMCP.Shared.csproj" />
24+
<ProjectReference Include="..\CodingWithCalvin.MCPServer.Shared\CodingWithCalvin.MCPServer.Shared.csproj" />
2225
</ItemGroup>
2326

2427
</Project>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using System;
2+
using System.CommandLine;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using CodingWithCalvin.MCPServer.Server;
6+
using CodingWithCalvin.MCPServer.Server.Tools;
7+
using Microsoft.AspNetCore.Builder;
8+
using Microsoft.Extensions.DependencyInjection;
9+
using Microsoft.Extensions.Hosting;
10+
using Microsoft.Extensions.Logging;
11+
using ModelContextProtocol.AspNetCore;
12+
using ModelContextProtocol.Protocol;
13+
using ModelContextProtocol.Server;
14+
15+
var pipeOption = new Option<string>(
16+
name: "--pipe",
17+
description: "Named pipe name for connecting to Visual Studio")
18+
{
19+
IsRequired = true
20+
};
21+
22+
var hostOption = new Option<string>(
23+
name: "--host",
24+
getDefaultValue: () => "localhost",
25+
description: "Host address to bind the HTTP server to (e.g., localhost, 0.0.0.0, *)");
26+
27+
var portOption = new Option<int>(
28+
name: "--port",
29+
getDefaultValue: () => 5050,
30+
description: "HTTP port for the MCP server");
31+
32+
var nameOption = new Option<string>(
33+
name: "--name",
34+
getDefaultValue: () => "Visual Studio MCP",
35+
description: "Server name displayed to MCP clients");
36+
37+
var logLevelOption = new Option<string>(
38+
name: "--log-level",
39+
getDefaultValue: () => "Information",
40+
description: "Minimum log level (Error, Warning, Information, Debug)");
41+
42+
var rootCommand = new RootCommand("Visual Studio MCP Server")
43+
{
44+
pipeOption,
45+
hostOption,
46+
portOption,
47+
nameOption,
48+
logLevelOption
49+
};
50+
51+
rootCommand.SetHandler(async (string pipeName, string host, int port, string serverName, string logLevel) =>
52+
{
53+
await RunServerAsync(pipeName, host, port, serverName, logLevel);
54+
}, pipeOption, hostOption, portOption, nameOption, logLevelOption);
55+
56+
return await rootCommand.InvokeAsync(args);
57+
58+
static async Task RunServerAsync(string pipeName, string host, int port, string serverName, string logLevel)
59+
{
60+
// Parse log level
61+
var msLogLevel = logLevel switch
62+
{
63+
"Error" => LogLevel.Error,
64+
"Warning" => LogLevel.Warning,
65+
"Debug" => LogLevel.Debug,
66+
_ => LogLevel.Information
67+
};
68+
69+
// Create shutdown token for graceful shutdown
70+
using var shutdownCts = new CancellationTokenSource();
71+
72+
// Connect to Visual Studio via named pipe
73+
var rpcClient = new RpcClient(shutdownCts);
74+
await rpcClient.ConnectAsync(pipeName);
75+
76+
Console.Error.WriteLine($"Connected to Visual Studio via pipe: {pipeName}");
77+
78+
// Build the web application
79+
var builder = WebApplication.CreateBuilder();
80+
81+
// Configure logging
82+
builder.Logging.SetMinimumLevel(msLogLevel);
83+
builder.Logging.AddFilter("Microsoft.AspNetCore", msLogLevel);
84+
builder.Logging.AddFilter("ModelContextProtocol", msLogLevel);
85+
86+
builder.Services.AddSingleton(rpcClient);
87+
88+
builder.Services.AddMcpServer(options =>
89+
{
90+
options.ServerInfo = new Implementation
91+
{
92+
Name = serverName,
93+
Version = "1.0.0"
94+
};
95+
})
96+
.WithHttpTransport()
97+
.WithTools<SolutionTools>()
98+
.WithTools<DocumentTools>()
99+
.WithTools<BuildTools>();
100+
101+
var app = builder.Build();
102+
103+
app.MapMcp();
104+
105+
var bindingUrl = $"http://{host}:{port}";
106+
app.Urls.Add(bindingUrl);
107+
108+
// Register shutdown token to stop the application
109+
var lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
110+
shutdownCts.Token.Register(() => lifetime.StopApplication());
111+
112+
Console.Error.WriteLine($"MCP Server listening on {bindingUrl} (LogLevel: {logLevel})");
113+
114+
await app.RunAsync();
115+
116+
Console.Error.WriteLine("Server shutdown complete");
117+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"profiles": {
3+
"CodingWithCalvin.MCPServer.Server": {
4+
"commandName": "Project",
5+
"launchBrowser": true,
6+
"environmentVariables": {
7+
"ASPNETCORE_ENVIRONMENT": "Development"
8+
},
9+
"applicationUrl": "https://localhost:57573;http://localhost:57574"
10+
}
11+
}
12+
}

src/CodingWithCalvin.VSMCP.Server/RpcClient.cs renamed to src/CodingWithCalvin.MCPServer.Server/RpcClient.cs

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,34 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.ComponentModel;
34
using System.IO.Pipes;
5+
using System.Linq;
6+
using System.Reflection;
7+
using System.Threading;
48
using System.Threading.Tasks;
5-
using CodingWithCalvin.VSMCP.Shared;
6-
using CodingWithCalvin.VSMCP.Shared.Models;
9+
using CodingWithCalvin.MCPServer.Shared;
10+
using CodingWithCalvin.MCPServer.Shared.Models;
11+
using ModelContextProtocol.Server;
712
using StreamJsonRpc;
813

9-
namespace CodingWithCalvin.VSMCP.Server;
14+
namespace CodingWithCalvin.MCPServer.Server;
1015

11-
public class RpcClient : IVisualStudioRpc, IDisposable
16+
public class RpcClient : IVisualStudioRpc, IServerRpc, IDisposable
1217
{
18+
private readonly CancellationTokenSource _shutdownCts;
1319
private NamedPipeClientStream? _pipeClient;
1420
private JsonRpc? _jsonRpc;
1521
private IVisualStudioRpc? _proxy;
1622
private bool _disposed;
23+
private List<ToolInfo>? _cachedTools;
1724

1825
public bool IsConnected => _pipeClient?.IsConnected ?? false;
1926

27+
public RpcClient(CancellationTokenSource shutdownCts)
28+
{
29+
_shutdownCts = shutdownCts;
30+
}
31+
2032
public async Task ConnectAsync(string pipeName, int timeoutMs = 10000)
2133
{
2234
_pipeClient = new NamedPipeClientStream(
@@ -27,7 +39,7 @@ public async Task ConnectAsync(string pipeName, int timeoutMs = 10000)
2739

2840
await _pipeClient.ConnectAsync(timeoutMs);
2941

30-
_jsonRpc = JsonRpc.Attach(_pipeClient);
42+
_jsonRpc = JsonRpc.Attach(_pipeClient, this);
3143
_proxy = _jsonRpc.Attach<IVisualStudioRpc>();
3244
}
3345

@@ -45,7 +57,49 @@ public void Dispose()
4557

4658
private IVisualStudioRpc Proxy => _proxy ?? throw new InvalidOperationException("Not connected to Visual Studio");
4759

48-
#region IVisualStudioRpc Implementation
60+
public Task<List<ToolInfo>> GetAvailableToolsAsync()
61+
{
62+
if (_cachedTools != null)
63+
{
64+
return Task.FromResult(_cachedTools);
65+
}
66+
67+
var tools = new List<ToolInfo>();
68+
var toolTypes = new[] { typeof(Tools.SolutionTools), typeof(Tools.DocumentTools), typeof(Tools.BuildTools) };
69+
70+
foreach (var toolType in toolTypes)
71+
{
72+
var category = toolType.Name.Replace("Tools", "");
73+
74+
foreach (var method in toolType.GetMethods(BindingFlags.Public | BindingFlags.Instance))
75+
{
76+
var toolAttr = method.GetCustomAttribute<McpServerToolAttribute>();
77+
if (toolAttr == null)
78+
{
79+
continue;
80+
}
81+
82+
var descAttr = method.GetCustomAttribute<DescriptionAttribute>();
83+
84+
tools.Add(new ToolInfo
85+
{
86+
Name = toolAttr.Name ?? method.Name,
87+
Description = descAttr?.Description ?? string.Empty,
88+
Category = category
89+
});
90+
}
91+
}
92+
93+
_cachedTools = tools;
94+
return Task.FromResult(tools);
95+
}
96+
97+
public Task ShutdownAsync()
98+
{
99+
Console.Error.WriteLine("Shutdown requested via RPC");
100+
_shutdownCts.Cancel();
101+
return Task.CompletedTask;
102+
}
49103

50104
public Task<SolutionInfo?> GetSolutionInfoAsync() => Proxy.GetSolutionInfoAsync();
51105
public Task<bool> OpenSolutionAsync(string path) => Proxy.OpenSolutionAsync(path);
@@ -68,8 +122,6 @@ public Task<List<FindResult>> FindAsync(string searchText, bool matchCase, bool
68122
public Task<bool> BuildSolutionAsync() => Proxy.BuildSolutionAsync();
69123
public Task<bool> BuildProjectAsync(string projectName) => Proxy.BuildProjectAsync(projectName);
70124
public Task<bool> CleanSolutionAsync() => Proxy.CleanSolutionAsync();
71-
public Task CancelBuildAsync() => Proxy.CancelBuildAsync();
125+
public Task<bool> CancelBuildAsync() => Proxy.CancelBuildAsync();
72126
public Task<BuildStatus> GetBuildStatusAsync() => Proxy.GetBuildStatusAsync();
73-
74-
#endregion
75127
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using System.ComponentModel;
2+
using System.Text.Json;
3+
using System.Threading.Tasks;
4+
using ModelContextProtocol.Server;
5+
6+
namespace CodingWithCalvin.MCPServer.Server.Tools;
7+
8+
[McpServerToolType]
9+
public class BuildTools
10+
{
11+
private readonly RpcClient _rpcClient;
12+
private readonly JsonSerializerOptions _jsonOptions;
13+
14+
public BuildTools(RpcClient rpcClient)
15+
{
16+
_rpcClient = rpcClient;
17+
_jsonOptions = new JsonSerializerOptions { WriteIndented = true };
18+
}
19+
20+
[McpServerTool(Name = "build_solution", Destructive = false)]
21+
[Description("Build the entire solution. The build runs asynchronously; use build_status to check progress. Returns immediately after starting the build.")]
22+
public async Task<string> BuildSolutionAsync()
23+
{
24+
var success = await _rpcClient.BuildSolutionAsync();
25+
return success ? "Build started" : "Failed to start build (is a solution open?)";
26+
}
27+
28+
[McpServerTool(Name = "build_project", Destructive = false)]
29+
[Description("Build a specific project. The build runs asynchronously; use build_status to check progress. IMPORTANT: Requires the full path to the .csproj file, not just the project name. Use project_list first to get the correct path.")]
30+
public async Task<string> BuildProjectAsync(
31+
[Description("The full absolute path to the project file (.csproj). Get this from project_list. Supports forward slashes (/) or backslashes (\\).")] string projectName)
32+
{
33+
var success = await _rpcClient.BuildProjectAsync(projectName);
34+
return success ? $"Build started for project: {projectName}" : $"Failed to build project: {projectName}";
35+
}
36+
37+
[McpServerTool(Name = "clean_solution", Destructive = true, Idempotent = true)]
38+
[Description("Clean the entire solution by removing all build outputs (bin/obj folders). The clean runs asynchronously; use build_status to check progress.")]
39+
public async Task<string> CleanSolutionAsync()
40+
{
41+
var success = await _rpcClient.CleanSolutionAsync();
42+
return success ? "Clean started" : "Failed to start clean (is a solution open?)";
43+
}
44+
45+
[McpServerTool(Name = "build_cancel", Destructive = false, Idempotent = true)]
46+
[Description("Cancel the current build or clean operation if one is in progress.")]
47+
public async Task<string> CancelBuildAsync()
48+
{
49+
var cancelled = await _rpcClient.CancelBuildAsync();
50+
return cancelled ? "Build cancelled" : "No build is currently in progress";
51+
}
52+
53+
[McpServerTool(Name = "build_status", ReadOnly = true)]
54+
[Description("Get the current build status. Returns State ('NoBuildPerformed', 'InProgress', or 'Done') and FailedProjects count. Use this to poll for build completion after starting a build.")]
55+
public async Task<string> GetBuildStatusAsync()
56+
{
57+
var status = await _rpcClient.GetBuildStatusAsync();
58+
return JsonSerializer.Serialize(status, _jsonOptions);
59+
}
60+
}

0 commit comments

Comments
 (0)