roslyn-mcp-server 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/LspClient.js +202 -36
- package/dist/index.js +19 -6
- package/package.json +2 -1
package/dist/LspClient.js
CHANGED
|
@@ -2,7 +2,9 @@ import { spawn } from 'child_process';
|
|
|
2
2
|
import * as rpc from 'vscode-jsonrpc/node.js';
|
|
3
3
|
import * as lsp from 'vscode-languageserver-protocol';
|
|
4
4
|
import { pathToFileURL } from 'url';
|
|
5
|
-
import { readFileSync } from 'fs';
|
|
5
|
+
import { readFileSync, readdirSync } from 'fs';
|
|
6
|
+
import * as chokidar from 'chokidar';
|
|
7
|
+
import * as path from 'path';
|
|
6
8
|
export class LspClient {
|
|
7
9
|
workspacePath;
|
|
8
10
|
command;
|
|
@@ -11,60 +13,144 @@ export class LspClient {
|
|
|
11
13
|
connection;
|
|
12
14
|
diagnostics = new Map();
|
|
13
15
|
openedDocuments = new Set();
|
|
16
|
+
documentVersions = new Map();
|
|
17
|
+
watcher = null;
|
|
18
|
+
closed = false;
|
|
14
19
|
constructor(workspacePath, command = 'C:\\Users\\PC2\\.dotnet\\tools\\roslyn-language-server.cmd', args = ['--stdio', '--logLevel=Information']) {
|
|
15
20
|
this.workspacePath = workspacePath;
|
|
16
21
|
this.command = command;
|
|
17
22
|
this.args = args;
|
|
18
|
-
// Start the LSP process
|
|
19
23
|
this.process = spawn(this.command, this.args, { shell: true });
|
|
20
24
|
if (!this.process.stdin || !this.process.stdout) {
|
|
21
25
|
throw new Error("LSP process stdio could not be started.");
|
|
22
26
|
}
|
|
23
|
-
// Log stderr output from LSP for debugging
|
|
24
27
|
this.process.stderr?.on('data', (data) => {
|
|
25
28
|
console.error(`[LSP STDERR]: ${data.toString()}`);
|
|
26
29
|
});
|
|
27
|
-
// Create the JSON-RPC connection
|
|
28
30
|
this.connection = rpc.createMessageConnection(new rpc.StreamMessageReader(this.process.stdout), new rpc.StreamMessageWriter(this.process.stdin));
|
|
29
|
-
//
|
|
31
|
+
// Diagnostic push events
|
|
30
32
|
this.connection.onNotification(lsp.PublishDiagnosticsNotification.method, (params) => {
|
|
31
33
|
this.diagnostics.set(params.uri, params.diagnostics);
|
|
32
34
|
});
|
|
35
|
+
// Progress support
|
|
36
|
+
this.connection.onRequest('window/workDoneProgress/create', (_params) => null);
|
|
37
|
+
this.connection.onNotification('$/progress', (params) => {
|
|
38
|
+
const { value } = params;
|
|
39
|
+
if (value.kind === 'begin') {
|
|
40
|
+
console.error(`[Roslyn Progress] ${value.title} başladı...`);
|
|
41
|
+
}
|
|
42
|
+
else if (value.kind === 'end') {
|
|
43
|
+
console.error(`[Roslyn Progress] Tamamlandı.`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
// Handle connection close gracefully
|
|
47
|
+
this.connection.onClose(() => { this.closed = true; });
|
|
48
|
+
this.process.on('exit', () => { this.closed = true; });
|
|
33
49
|
this.connection.listen();
|
|
34
50
|
}
|
|
51
|
+
// ─── FILE WATCHER ──────────────────────────────────────────────
|
|
52
|
+
setupFileWatcher() {
|
|
53
|
+
this.watcher = chokidar.watch(this.workspacePath, {
|
|
54
|
+
ignored: [/node_modules/, /\.git/, /bin/, /obj/],
|
|
55
|
+
ignoreInitial: true
|
|
56
|
+
});
|
|
57
|
+
const isValid = (p) => p.endsWith('.cs') || p.endsWith('.csproj') || p.endsWith('.sln') || p.endsWith('.slnx');
|
|
58
|
+
this.watcher.on('add', (absPath) => {
|
|
59
|
+
if (!isValid(absPath) || this.closed)
|
|
60
|
+
return;
|
|
61
|
+
this.sendWatchedFileChange(absPath, 1);
|
|
62
|
+
this.ensureDocumentOpen(absPath);
|
|
63
|
+
});
|
|
64
|
+
this.watcher.on('change', (absPath) => {
|
|
65
|
+
if (!isValid(absPath) || this.closed)
|
|
66
|
+
return;
|
|
67
|
+
this.sendWatchedFileChange(absPath, 2);
|
|
68
|
+
// If not open yet, open it first; otherwise push didChange
|
|
69
|
+
const uri = this.pathToUri(absPath);
|
|
70
|
+
try {
|
|
71
|
+
const content = readFileSync(absPath, 'utf8');
|
|
72
|
+
if (!this.openedDocuments.has(uri)) {
|
|
73
|
+
// First time seeing this file — open it
|
|
74
|
+
this.documentVersions.set(uri, 1);
|
|
75
|
+
this.connection.sendNotification(lsp.DidOpenTextDocumentNotification.method, {
|
|
76
|
+
textDocument: { uri, languageId: 'csharp', version: 1, text: content }
|
|
77
|
+
});
|
|
78
|
+
this.openedDocuments.add(uri);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
// Already open — send incremental change
|
|
82
|
+
const newVersion = (this.documentVersions.get(uri) || 1) + 1;
|
|
83
|
+
this.documentVersions.set(uri, newVersion);
|
|
84
|
+
this.connection.sendNotification(lsp.DidChangeTextDocumentNotification.method, {
|
|
85
|
+
textDocument: { uri, version: newVersion },
|
|
86
|
+
contentChanges: [{ text: content }]
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
console.error(`ERROR: Failed to sync change for ${absPath}`, e);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
this.watcher.on('unlink', (absPath) => {
|
|
95
|
+
if (!isValid(absPath) || this.closed)
|
|
96
|
+
return;
|
|
97
|
+
try {
|
|
98
|
+
this.sendWatchedFileChange(absPath, 3);
|
|
99
|
+
}
|
|
100
|
+
catch (e) { /* connection may be closed */ }
|
|
101
|
+
const uri = this.pathToUri(absPath);
|
|
102
|
+
if (this.openedDocuments.has(uri)) {
|
|
103
|
+
try {
|
|
104
|
+
this.connection.sendNotification(lsp.DidCloseTextDocumentNotification.method, {
|
|
105
|
+
textDocument: { uri }
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch (e) { /* connection may be closed */ }
|
|
109
|
+
this.openedDocuments.delete(uri);
|
|
110
|
+
this.documentVersions.delete(uri);
|
|
111
|
+
this.diagnostics.delete(uri);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
sendWatchedFileChange(filePath, changeType) {
|
|
116
|
+
if (this.closed)
|
|
117
|
+
return;
|
|
118
|
+
this.connection.sendNotification(lsp.DidChangeWatchedFilesNotification.method, {
|
|
119
|
+
changes: [{ uri: this.pathToUri(filePath), type: changeType }]
|
|
120
|
+
});
|
|
121
|
+
}
|
|
35
122
|
ensureDocumentOpen(filePath) {
|
|
36
123
|
const uri = this.pathToUri(filePath);
|
|
37
124
|
if (!this.openedDocuments.has(uri)) {
|
|
38
125
|
try {
|
|
39
126
|
const content = readFileSync(filePath, 'utf8');
|
|
127
|
+
this.documentVersions.set(uri, 1);
|
|
40
128
|
this.connection.sendNotification(lsp.DidOpenTextDocumentNotification.method, {
|
|
41
|
-
textDocument: {
|
|
42
|
-
uri: uri,
|
|
43
|
-
languageId: 'csharp',
|
|
44
|
-
version: 1,
|
|
45
|
-
text: content
|
|
46
|
-
}
|
|
129
|
+
textDocument: { uri, languageId: 'csharp', version: 1, text: content }
|
|
47
130
|
});
|
|
48
131
|
this.openedDocuments.add(uri);
|
|
49
132
|
}
|
|
50
133
|
catch (e) {
|
|
51
|
-
console.error(`ERROR: Failed to open document ${filePath}
|
|
134
|
+
console.error(`ERROR: Failed to open document ${filePath}:`, e);
|
|
52
135
|
}
|
|
53
136
|
}
|
|
54
137
|
}
|
|
138
|
+
// ─── INITIALIZE ────────────────────────────────────────────────
|
|
55
139
|
async initialize() {
|
|
56
140
|
const rootUri = pathToFileURL(this.workspacePath).href;
|
|
57
141
|
const initParams = {
|
|
58
142
|
processId: process.pid,
|
|
59
143
|
rootUri: rootUri,
|
|
60
144
|
capabilities: {
|
|
145
|
+
window: {
|
|
146
|
+
workDoneProgress: true
|
|
147
|
+
},
|
|
61
148
|
workspace: {
|
|
62
|
-
symbol: {
|
|
63
|
-
|
|
64
|
-
}
|
|
149
|
+
symbol: { dynamicRegistration: true },
|
|
150
|
+
didChangeWatchedFiles: { dynamicRegistration: true }
|
|
65
151
|
},
|
|
66
152
|
textDocument: {
|
|
67
|
-
hover: { dynamicRegistration: true },
|
|
153
|
+
hover: { dynamicRegistration: true, contentFormat: ['markdown', 'plaintext'] },
|
|
68
154
|
definition: { dynamicRegistration: true },
|
|
69
155
|
references: { dynamicRegistration: true },
|
|
70
156
|
publishDiagnostics: { relatedInformation: true },
|
|
@@ -76,36 +162,67 @@ export class LspClient {
|
|
|
76
162
|
}
|
|
77
163
|
}
|
|
78
164
|
},
|
|
79
|
-
workspaceFolders: [
|
|
80
|
-
{
|
|
81
|
-
uri: rootUri,
|
|
82
|
-
name: 'workspace'
|
|
83
|
-
}
|
|
84
|
-
]
|
|
165
|
+
workspaceFolders: [{ uri: rootUri, name: 'workspace' }]
|
|
85
166
|
};
|
|
86
|
-
// Send initialize request
|
|
87
167
|
await this.connection.sendRequest(lsp.InitializeRequest.method, initParams);
|
|
88
|
-
// Send initialized notification to signal readiness
|
|
89
168
|
await this.connection.sendNotification(lsp.InitializedNotification.method, {});
|
|
169
|
+
// CRITICAL: Tell Roslyn which solution/projects to load
|
|
170
|
+
await this.openSolutionOrProjects();
|
|
171
|
+
// Start File Watcher
|
|
172
|
+
this.setupFileWatcher();
|
|
173
|
+
}
|
|
174
|
+
async openSolutionOrProjects() {
|
|
175
|
+
const rootFiles = readdirSync(this.workspacePath);
|
|
176
|
+
const slnFile = rootFiles.find(f => f.endsWith('.sln') || f.endsWith('.slnx'));
|
|
177
|
+
if (slnFile) {
|
|
178
|
+
const slnUri = pathToFileURL(path.join(this.workspacePath, slnFile)).href;
|
|
179
|
+
console.error(`[LSP] Opening solution: ${slnFile}`);
|
|
180
|
+
await this.connection.sendNotification('solution/open', { solution: slnUri });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const csprojFiles = this.findFiles(this.workspacePath, '.csproj');
|
|
184
|
+
if (csprojFiles.length > 0) {
|
|
185
|
+
const projectUris = csprojFiles.map(f => pathToFileURL(f).href);
|
|
186
|
+
console.error(`[LSP] Opening ${csprojFiles.length} project(s)`);
|
|
187
|
+
await this.connection.sendNotification('project/open', { projects: projectUris });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
console.error('[LSP] WARNING: No .sln, .slnx, or .csproj files found.');
|
|
191
|
+
}
|
|
192
|
+
findFiles(dir, ext) {
|
|
193
|
+
const results = [];
|
|
194
|
+
try {
|
|
195
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
196
|
+
for (const entry of entries) {
|
|
197
|
+
const fullPath = path.join(dir, entry.name);
|
|
198
|
+
if (entry.isDirectory() && !['node_modules', '.git', 'bin', 'obj'].includes(entry.name)) {
|
|
199
|
+
results.push(...this.findFiles(fullPath, ext));
|
|
200
|
+
}
|
|
201
|
+
else if (entry.isFile() && entry.name.endsWith(ext)) {
|
|
202
|
+
results.push(fullPath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (e) { /* skip */ }
|
|
207
|
+
return results;
|
|
90
208
|
}
|
|
209
|
+
// ─── LSP TOOLS ─────────────────────────────────────────────────
|
|
91
210
|
async hover(filePath, line, column) {
|
|
92
|
-
this.
|
|
211
|
+
await this.ensureDocumentOpenAsync(filePath);
|
|
93
212
|
return this.connection.sendRequest(lsp.HoverRequest.method, {
|
|
94
213
|
textDocument: { uri: this.pathToUri(filePath) },
|
|
95
|
-
// LSP positions are 0-based.
|
|
96
214
|
position: { line, character: column }
|
|
97
215
|
});
|
|
98
216
|
}
|
|
99
217
|
async definition(filePath, line, column) {
|
|
100
|
-
this.
|
|
218
|
+
await this.ensureDocumentOpenAsync(filePath);
|
|
101
219
|
return this.connection.sendRequest(lsp.DefinitionRequest.method, {
|
|
102
220
|
textDocument: { uri: this.pathToUri(filePath) },
|
|
103
221
|
position: { line, character: column }
|
|
104
222
|
});
|
|
105
223
|
}
|
|
106
224
|
async references(filePath, line, column) {
|
|
107
|
-
this.
|
|
108
|
-
// CRITICAL FIX: The 'context' object must not be omitted to prevent Roslyn crashes
|
|
225
|
+
await this.ensureDocumentOpenAsync(filePath);
|
|
109
226
|
return this.connection.sendRequest(lsp.ReferencesRequest.method, {
|
|
110
227
|
textDocument: { uri: this.pathToUri(filePath) },
|
|
111
228
|
position: { line, character: column },
|
|
@@ -113,19 +230,68 @@ export class LspClient {
|
|
|
113
230
|
});
|
|
114
231
|
}
|
|
115
232
|
async workspaceSymbol(query) {
|
|
116
|
-
return this.connection.sendRequest(lsp.WorkspaceSymbolRequest.method, {
|
|
117
|
-
query
|
|
118
|
-
});
|
|
233
|
+
return this.connection.sendRequest(lsp.WorkspaceSymbolRequest.method, { query });
|
|
119
234
|
}
|
|
120
|
-
getDiagnostics(filePath) {
|
|
121
|
-
this.
|
|
235
|
+
async getDiagnostics(filePath) {
|
|
236
|
+
await this.ensureDocumentOpenAsync(filePath);
|
|
237
|
+
// Diagnostics are pushed asynchronously by Roslyn via publishDiagnostics
|
|
238
|
+
// The watcher handles file changes via didChange. Just return from cache.
|
|
122
239
|
return this.diagnostics.get(this.pathToUri(filePath)) || [];
|
|
123
240
|
}
|
|
241
|
+
// ─── HELPERS ───────────────────────────────────────────────────
|
|
242
|
+
async ensureDocumentOpenAsync(filePath) {
|
|
243
|
+
const uri = this.pathToUri(filePath);
|
|
244
|
+
if (!this.openedDocuments.has(uri)) {
|
|
245
|
+
try {
|
|
246
|
+
const content = readFileSync(filePath, 'utf8');
|
|
247
|
+
this.documentVersions.set(uri, 1);
|
|
248
|
+
this.connection.sendNotification(lsp.DidOpenTextDocumentNotification.method, {
|
|
249
|
+
textDocument: { uri, languageId: 'csharp', version: 1, text: content }
|
|
250
|
+
});
|
|
251
|
+
this.openedDocuments.add(uri);
|
|
252
|
+
// Wait for Roslyn to parse the opened document
|
|
253
|
+
await new Promise(r => setTimeout(r, 300));
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
console.error(`ERROR: Failed to open ${filePath}:`, e);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async refreshDocument(filePath) {
|
|
261
|
+
const uri = this.pathToUri(filePath);
|
|
262
|
+
try {
|
|
263
|
+
const content = readFileSync(filePath, 'utf8');
|
|
264
|
+
if (!this.openedDocuments.has(uri)) {
|
|
265
|
+
this.documentVersions.set(uri, 1);
|
|
266
|
+
this.connection.sendNotification(lsp.DidOpenTextDocumentNotification.method, {
|
|
267
|
+
textDocument: { uri, languageId: 'csharp', version: 1, text: content }
|
|
268
|
+
});
|
|
269
|
+
this.openedDocuments.add(uri);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
const newVersion = (this.documentVersions.get(uri) || 1) + 1;
|
|
273
|
+
this.documentVersions.set(uri, newVersion);
|
|
274
|
+
this.connection.sendNotification(lsp.DidChangeTextDocumentNotification.method, {
|
|
275
|
+
textDocument: { uri, version: newVersion },
|
|
276
|
+
contentChanges: [{ text: content }]
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch (e) { /* file might not exist */ }
|
|
281
|
+
}
|
|
124
282
|
pathToUri(filePath) {
|
|
125
283
|
return pathToFileURL(filePath).href;
|
|
126
284
|
}
|
|
127
285
|
shutdown() {
|
|
128
|
-
this.
|
|
129
|
-
this.
|
|
286
|
+
this.closed = true;
|
|
287
|
+
this.watcher?.close();
|
|
288
|
+
try {
|
|
289
|
+
this.connection.end();
|
|
290
|
+
}
|
|
291
|
+
catch (e) { }
|
|
292
|
+
try {
|
|
293
|
+
this.process.kill();
|
|
294
|
+
}
|
|
295
|
+
catch (e) { }
|
|
130
296
|
}
|
|
131
297
|
}
|
package/dist/index.js
CHANGED
|
@@ -63,19 +63,32 @@ mcpServer.tool("csharp_references", "Finds all usages of the specified variable,
|
|
|
63
63
|
const references = await lspClient.references(filePath, Math.max(0, line - 1), Math.max(0, column - 1));
|
|
64
64
|
return { content: [{ type: "text", text: JSON.stringify(references, null, 2) }] };
|
|
65
65
|
});
|
|
66
|
-
|
|
67
|
-
mcpServer.tool("csharp_diagnostics", "Retrieves the current syntax errors, build issues, and warnings for the requested file", {
|
|
66
|
+
mcpServer.tool("csharp_diagnostics", "Retrieves the current syntax errors, build issues, and warnings for the requested file. The tool re-reads the file from disk and waits briefly for Roslyn to process changes before returning results.", {
|
|
68
67
|
filePath: z.string().describe("Absolute path to the C# file")
|
|
69
68
|
}, async ({ filePath }) => {
|
|
70
|
-
const diagnostics = lspClient.getDiagnostics(filePath);
|
|
69
|
+
const diagnostics = await lspClient.getDiagnostics(filePath);
|
|
71
70
|
return { content: [{ type: "text", text: JSON.stringify(diagnostics, null, 2) }] };
|
|
72
71
|
});
|
|
73
72
|
// TOOL 5: Workspace Symbol (Project-wide Search)
|
|
74
|
-
mcpServer.tool("csharp_workspace_symbol", "Searches for methods, classes, or interfaces by name or partial name across the entire C# project", {
|
|
73
|
+
mcpServer.tool("csharp_workspace_symbol", "Searches for methods, classes, or interfaces by name or partial name across the entire C# project. IMPORTANT: The C# Language Server might take 5-10 seconds to fully index large workspaces perfectly. If your prompt expects results but you receive empty [], please wait a few seconds and call this tool again.", {
|
|
75
74
|
query: z.string().describe("The search query (e.g. method or class name)")
|
|
76
75
|
}, async ({ query }) => {
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
try {
|
|
77
|
+
const result = await lspClient.workspaceSymbol(query);
|
|
78
|
+
let responseText = JSON.stringify(result, null, 2);
|
|
79
|
+
if (!result || result.length === 0) {
|
|
80
|
+
responseText += "\n\n[Note for AI: The Roslyn indexer might still be processing the workspace. If you confidently expect a result here, please wait 3-5 seconds and try calling this tool again.]";
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
content: [{ type: "text", text: responseText }]
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
return {
|
|
88
|
+
isError: true,
|
|
89
|
+
content: [{ type: "text", text: error.message }]
|
|
90
|
+
};
|
|
91
|
+
}
|
|
79
92
|
});
|
|
80
93
|
async function main() {
|
|
81
94
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roslyn-mcp-server",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"description": "An unconditionally robust MCP server for Microsoft Roslyn Language Server (C#)",
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
33
|
+
"chokidar": "^5.0.0",
|
|
33
34
|
"vscode-jsonrpc": "^8.2.1",
|
|
34
35
|
"vscode-languageserver-protocol": "^3.17.5",
|
|
35
36
|
"zod": "^4.3.6"
|