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 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
- // Listener (Push Event) to catch and store diagnostic reports
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} natively:`, e);
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
- dynamicRegistration: true
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.ensureDocumentOpen(filePath);
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.ensureDocumentOpen(filePath);
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.ensureDocumentOpen(filePath);
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.ensureDocumentOpen(filePath);
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.connection.end();
129
- this.process.kill();
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
- // TOOL 4: C# Diagnostics (Build/Syntax errors)
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
- const symbols = await lspClient.workspaceSymbol(query);
78
- return { content: [{ type: "text", text: JSON.stringify(symbols, null, 2) }] };
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.1",
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"