Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Use explicit UTF-8 encoding without BOM on LSP process stdin to prevent corrupted Content-Length headers
- Handle server-to-client requests (e.g. `workspace/configuration`) that require a response, preventing server stalls
- Wait for workspace `$/progress` end notification before declaring the LSP client initialized

## [1.0.0] - 2025-12-11

### Added
Expand Down
76 changes: 69 additions & 7 deletions csharp-lsp-mcp/src/CSharpLspMcp/Lsp/LspClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class LspClient : IAsyncDisposable
private readonly SemaphoreSlim _initLock = new(1, 1);
private readonly SemaphoreSlim _writeLock = new(1, 1);
private string? _filteredWorkspacePath;
private TaskCompletionSource? _workspaceLoaded = new();

private static readonly JsonSerializerOptions JsonOptions = new()
{
Expand Down Expand Up @@ -76,7 +77,9 @@ public async Task<bool> StartAsync(string? workspacePath = null, CancellationTok
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
// Don't set encoding - we write bytes directly to avoid BOM issues
// Use UTF-8 without BOM to prevent StreamWriter.BaseStream from
// writing a 3-byte BOM preamble before our Content-Length headers
StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
};

_lspProcess = new Process { StartInfo = startInfo };
Expand Down Expand Up @@ -124,6 +127,23 @@ public async Task<bool> StartAsync(string? workspacePath = null, CancellationTok
// Send initialized notification
await SendNotificationAsync("initialized", new { }, cancellationToken);

// Wait for the workspace to finish loading before declaring ready
if (_workspaceLoaded != null)
{
_logger.LogInformation("Waiting for workspace to finish loading...");
using var loadCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
loadCts.CancelAfter(TimeSpan.FromMinutes(2));
try
{
await _workspaceLoaded.Task.WaitAsync(loadCts.Token);
_logger.LogInformation("Workspace loading completed");
}
catch (OperationCanceledException)
{
_logger.LogWarning("Workspace loading timed out, proceeding anyway");
}
}

_isInitialized = true;
return true;
}
Expand Down Expand Up @@ -527,7 +547,7 @@ private async Task ReadLoopAsync(CancellationToken cancellationToken)
_logger.LogTrace("Received: {Message}", json);
_logger.LogDebug("ReadLoopAsync: Received message, length={Length}", json.Length);

ProcessMessage(json);
await ProcessMessageAsync(json, cancellationToken);
}
catch (OperationCanceledException)
{
Expand All @@ -542,19 +562,31 @@ private async Task ReadLoopAsync(CancellationToken cancellationToken)
_logger.LogDebug("ReadLoopAsync: Exiting read loop");
}

private void ProcessMessage(string json)
private async Task ProcessMessageAsync(string json, CancellationToken cancellationToken)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;

if (root.TryGetProperty("id", out var idElement))
var hasId = root.TryGetProperty("id", out var idElement);
var hasMethod = root.TryGetProperty("method", out var methodElement);

if (hasId && hasMethod)
{
// Server-to-client request — must respond or the server will stall
var serverReqId = idElement.GetInt32();
var method = methodElement.GetString();
_logger.LogDebug("Server request: {Method} (id={Id})", method, serverReqId);
await SendServerRequestResponseAsync(serverReqId, method, root, cancellationToken);
}
else if (hasId)
{
// Response to a request
// Response to a client request
var id = idElement.GetInt32();
if (_pendingRequests.TryGetValue(id, out var tcs))
{
// Response to a client request
if (root.TryGetProperty("error", out var error))
{
var errorMsg = error.GetProperty("message").GetString();
Expand All @@ -571,9 +603,9 @@ private void ProcessMessage(string json)
}
}
}
else if (root.TryGetProperty("method", out var methodElement))
else if (hasMethod)
{
// Notification from server
// Notification from server (no id, no response needed)
var method = methodElement.GetString();
if (method == "textDocument/publishDiagnostics" && root.TryGetProperty("params", out var @params))
{
Expand All @@ -586,6 +618,13 @@ private void ProcessMessage(string json)
diagnostics.Diagnostics.Length, diagnostics.Uri);
}
}
else if (method == "$/progress" && root.TryGetProperty("params", out var progressParams)
&& progressParams.TryGetProperty("value", out var value)
&& value.TryGetProperty("kind", out var kind)
&& kind.GetString() == "end")
{
_workspaceLoaded?.TrySetResult();
}
}
}
catch (Exception ex)
Expand All @@ -594,6 +633,29 @@ private void ProcessMessage(string json)
}
}

private async Task SendServerRequestResponseAsync(int id, string? method, JsonElement root, CancellationToken cancellationToken)
{
// workspace/configuration expects an array with one item per requested section
object? result = null;
if (method == "workspace/configuration" && root.TryGetProperty("params", out var @params)
&& @params.TryGetProperty("items", out var items))
{
var count = items.GetArrayLength();
var configItems = new object?[count];
// Return empty objects so the server gets valid (default) config
for (var i = 0; i < count; i++)
configItems[i] = new { };
result = configItems;
}

var response = new JsonRpcResponse
{
Id = id,
Result = JsonSerializer.SerializeToElement(result, JsonOptions),
};
await SendMessageAsync(response, cancellationToken);
}

private static async Task<int?> ReadContentLengthAsync(Stream stream, CancellationToken cancellationToken)
{
var headerBytes = new List<byte>();
Expand Down