feat: add MCP and LSP tooling support

Add global MCP/LSP settings, per-session Activity toggles, sidecar runtime integration, tests, and documentation updates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-23 22:51:03 +01:00
co-authored by Copilot
parent 50a8e5dfbe
commit efa5c44e07
26 changed files with 3196 additions and 47 deletions
@@ -113,6 +113,36 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
public string WorkspaceKind { get; init; } = "project";
public PatternDefinitionDto Pattern { get; init; } = new();
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
public RunTurnToolingConfigDto? Tooling { get; init; }
}
public sealed class RunTurnToolingConfigDto
{
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
public IReadOnlyList<RunTurnLspProfileConfigDto> LspProfiles { get; init; } = [];
}
public sealed class RunTurnMcpServerConfigDto
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Transport { get; init; } = "local";
public IReadOnlyList<string> Tools { get; init; } = [];
public int? TimeoutMs { get; init; }
public string? Command { get; init; }
public IReadOnlyList<string>? Args { get; init; }
public string? Cwd { get; init; }
public string? Url { get; init; }
}
public sealed class RunTurnLspProfileConfigDto
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Command { get; init; } = string.Empty;
public IReadOnlyList<string> Args { get; init; } = [];
public string LanguageId { get; init; } = string.Empty;
public IReadOnlyList<string> FileExtensions { get; init; } = [];
}
public abstract class SidecarEventDto
@@ -507,6 +507,15 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
List<AIAgent> agents = [];
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
bool isScratchpad = string.Equals(command.WorkspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase);
SessionToolingBundle? toolingBundle = isScratchpad
? null
: await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken)
.ConfigureAwait(false);
if (toolingBundle is not null)
{
disposables.Add(toolingBundle);
}
foreach ((PatternAgentDefinitionDto definition, int agentIndex) in command.Pattern.Agents.Select((definition, index) => (definition, index)))
{
@@ -530,6 +539,18 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
{
sessionConfig.AvailableTools = [];
}
else if (toolingBundle is not null)
{
if (toolingBundle.McpServers.Count > 0)
{
sessionConfig.McpServers = toolingBundle.McpServers;
}
if (toolingBundle.Tools.Count > 0)
{
sessionConfig.Tools = toolingBundle.Tools.ToList();
}
}
GitHubCopilotAgent agent = new(
client,
@@ -0,0 +1,681 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Eryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
namespace Eryx.AgentHost.Services;
internal sealed class LspToolSession : IAsyncDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
};
private readonly RunTurnLspProfileConfigDto _profile;
private readonly string _projectPath;
private readonly Process _process;
private readonly Stream _stdin;
private readonly Stream _stdout;
private readonly CancellationTokenSource _cts = new();
private readonly ConcurrentDictionary<int, TaskCompletionSource<JsonElement?>> _pending =
new();
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly SemaphoreSlim _documentLock = new(1, 1);
private readonly HashSet<string> _openedDocumentUris = new(StringComparer.OrdinalIgnoreCase);
private readonly Task _stdoutReaderTask;
private readonly Task _stderrReaderTask;
private int _nextRequestId;
private LspToolSession(
RunTurnLspProfileConfigDto profile,
string projectPath,
Process process)
{
_profile = profile;
_projectPath = Path.GetFullPath(projectPath);
_process = process;
_stdin = process.StandardInput.BaseStream;
_stdout = process.StandardOutput.BaseStream;
_stdoutReaderTask = Task.Run(() => ReadLoopAsync(_cts.Token));
_stderrReaderTask = Task.Run(() => ReadErrorLoopAsync(_cts.Token));
Tools =
[
CreateTool(
methodName: nameof(WorkspaceSymbolsToolAsync),
toolName: $"{BuildToolPrefix(_profile.Id)}_workspace_symbols",
description: $"Search workspace symbols using the {_profile.Name} language server. Use this when you know a symbol name or partial symbol name."),
CreateTool(
methodName: nameof(DocumentSymbolsToolAsync),
toolName: $"{BuildToolPrefix(_profile.Id)}_document_symbols",
description: $"List document symbols for a file using the {_profile.Name} language server. Pass a path relative to the current project root."),
CreateTool(
methodName: nameof(DefinitionToolAsync),
toolName: $"{BuildToolPrefix(_profile.Id)}_definition",
description: $"Resolve the definition location for a symbol using the {_profile.Name} language server. Paths are relative to the project root. Line and character are 1-based."),
CreateTool(
methodName: nameof(HoverToolAsync),
toolName: $"{BuildToolPrefix(_profile.Id)}_hover",
description: $"Fetch hover information for a symbol using the {_profile.Name} language server. Paths are relative to the project root. Line and character are 1-based."),
CreateTool(
methodName: nameof(ReferencesToolAsync),
toolName: $"{BuildToolPrefix(_profile.Id)}_references",
description: $"Find references for a symbol using the {_profile.Name} language server. Paths are relative to the project root. Line and character are 1-based."),
];
}
public IReadOnlyList<AIFunction> Tools { get; }
public static async Task<LspToolSession> StartAsync(
RunTurnLspProfileConfigDto profile,
string projectPath,
CancellationToken cancellationToken)
{
ProcessStartInfo startInfo = new()
{
FileName = profile.Command,
WorkingDirectory = projectPath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (string arg in profile.Args)
{
startInfo.ArgumentList.Add(arg);
}
Process? process;
try
{
process = Process.Start(startInfo);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Could not start LSP profile \"{profile.Name}\" using command \"{profile.Command}\".",
ex);
}
if (process is null)
{
throw new InvalidOperationException(
$"Could not start LSP profile \"{profile.Name}\" using command \"{profile.Command}\".");
}
LspToolSession session = new(profile, projectPath, process);
await session.InitializeAsync(cancellationToken).ConfigureAwait(false);
return session;
}
public async ValueTask DisposeAsync()
{
_cts.Cancel();
try
{
if (!_process.HasExited)
{
_process.Kill(entireProcessTree: true);
}
}
catch
{
}
try
{
await _stdoutReaderTask.ConfigureAwait(false);
}
catch
{
}
try
{
await _stderrReaderTask.ConfigureAwait(false);
}
catch
{
}
_writeLock.Dispose();
_documentLock.Dispose();
_stdin.Dispose();
_stdout.Dispose();
_process.Dispose();
_cts.Dispose();
}
private async Task InitializeAsync(CancellationToken cancellationToken)
{
string rootUri = ToFileUri(_projectPath);
await SendRequestAsync(
"initialize",
new
{
processId = Environment.ProcessId,
rootUri,
capabilities = new { },
workspaceFolders = new[]
{
new
{
uri = rootUri,
name = Path.GetFileName(_projectPath),
},
},
clientInfo = new
{
name = "Eryx",
version = "1.0.0",
},
},
cancellationToken).ConfigureAwait(false);
await SendNotificationAsync("initialized", new { }, cancellationToken).ConfigureAwait(false);
}
private AIFunction CreateTool(string methodName, string toolName, string description)
{
MethodInfo method = GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"LSP tool method \"{methodName}\" was not found.");
return AIFunctionFactory.Create(
method,
this,
new AIFunctionFactoryOptions
{
Name = toolName,
Description = description,
SerializerOptions = JsonOptions,
});
}
private async Task<string> WorkspaceSymbolsToolAsync(string query, int limit = 20)
{
JsonElement? result = await SendRequestAsync(
"workspace/symbol",
new
{
query,
},
CancellationToken.None).ConfigureAwait(false);
return SerializeResult(result, limit);
}
private async Task<string> DocumentSymbolsToolAsync(string relativePath)
{
string documentPath = ResolveProjectPath(relativePath);
string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None)
.ConfigureAwait(false);
JsonElement? result = await SendRequestAsync(
"textDocument/documentSymbol",
new
{
textDocument = new
{
uri = documentUri,
},
},
CancellationToken.None).ConfigureAwait(false);
return SerializeResult(result);
}
private async Task<string> DefinitionToolAsync(string relativePath, int line, int character)
{
string documentPath = ResolveProjectPath(relativePath);
string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None)
.ConfigureAwait(false);
JsonElement? result = await SendRequestAsync(
"textDocument/definition",
new
{
textDocument = new
{
uri = documentUri,
},
position = new
{
line = NormalizePosition(line),
character = NormalizePosition(character),
},
},
CancellationToken.None).ConfigureAwait(false);
return SerializeResult(result);
}
private async Task<string> HoverToolAsync(string relativePath, int line, int character)
{
string documentPath = ResolveProjectPath(relativePath);
string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None)
.ConfigureAwait(false);
JsonElement? result = await SendRequestAsync(
"textDocument/hover",
new
{
textDocument = new
{
uri = documentUri,
},
position = new
{
line = NormalizePosition(line),
character = NormalizePosition(character),
},
},
CancellationToken.None).ConfigureAwait(false);
return SerializeResult(result);
}
private async Task<string> ReferencesToolAsync(string relativePath, int line, int character)
{
string documentPath = ResolveProjectPath(relativePath);
string documentUri = await EnsureDocumentOpenedAsync(documentPath, CancellationToken.None)
.ConfigureAwait(false);
JsonElement? result = await SendRequestAsync(
"textDocument/references",
new
{
textDocument = new
{
uri = documentUri,
},
position = new
{
line = NormalizePosition(line),
character = NormalizePosition(character),
},
context = new
{
includeDeclaration = true,
},
},
CancellationToken.None).ConfigureAwait(false);
return SerializeResult(result);
}
private async Task<string> EnsureDocumentOpenedAsync(string documentPath, CancellationToken cancellationToken)
{
string documentUri = ToFileUri(documentPath);
if (_openedDocumentUris.Contains(documentUri))
{
return documentUri;
}
await _documentLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_openedDocumentUris.Contains(documentUri))
{
return documentUri;
}
string text = await File.ReadAllTextAsync(documentPath, cancellationToken).ConfigureAwait(false);
await SendNotificationAsync(
"textDocument/didOpen",
new
{
textDocument = new
{
uri = documentUri,
languageId = ResolveLanguageId(documentPath),
version = 1,
text,
},
},
cancellationToken).ConfigureAwait(false);
_openedDocumentUris.Add(documentUri);
return documentUri;
}
finally
{
_documentLock.Release();
}
}
private async Task<JsonElement?> SendRequestAsync(
string method,
object? parameters,
CancellationToken cancellationToken)
{
int requestId = Interlocked.Increment(ref _nextRequestId);
TaskCompletionSource<JsonElement?> tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[requestId] = tcs;
await WriteMessageAsync(
new
{
jsonrpc = "2.0",
id = requestId,
method,
@params = parameters,
},
cancellationToken).ConfigureAwait(false);
using CancellationTokenRegistration registration = cancellationToken.Register(
static state =>
{
((TaskCompletionSource<JsonElement?>)state!).TrySetCanceled();
},
tcs);
try
{
return await tcs.Task.ConfigureAwait(false);
}
finally
{
_pending.TryRemove(requestId, out _);
}
}
private Task SendNotificationAsync(string method, object? parameters, CancellationToken cancellationToken)
{
return WriteMessageAsync(
new
{
jsonrpc = "2.0",
method,
@params = parameters,
},
cancellationToken);
}
private async Task WriteMessageAsync(object payload, CancellationToken cancellationToken)
{
string json = JsonSerializer.Serialize(payload, JsonOptions);
byte[] body = Encoding.UTF8.GetBytes(json);
byte[] header = Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n");
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await _stdin.WriteAsync(header, cancellationToken).ConfigureAwait(false);
await _stdin.WriteAsync(body, cancellationToken).ConfigureAwait(false);
await _stdin.FlushAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_writeLock.Release();
}
}
private async Task ReadLoopAsync(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
Dictionary<string, string>? headers = await ReadHeadersAsync(_stdout, cancellationToken)
.ConfigureAwait(false);
if (headers is null)
{
break;
}
if (!headers.TryGetValue("Content-Length", out string? contentLengthHeader)
|| !int.TryParse(contentLengthHeader, out int contentLength))
{
throw new InvalidOperationException("LSP response was missing a valid Content-Length header.");
}
byte[] body = await ReadExactAsync(_stdout, contentLength, cancellationToken).ConfigureAwait(false);
using JsonDocument document = JsonDocument.Parse(body);
JsonElement root = document.RootElement;
if (root.TryGetProperty("id", out JsonElement idElement)
&& TryReadRequestId(idElement, out int requestId)
&& _pending.TryGetValue(requestId, out TaskCompletionSource<JsonElement?>? tcs))
{
if (root.TryGetProperty("error", out JsonElement errorElement))
{
string message = errorElement.TryGetProperty("message", out JsonElement messageElement)
? messageElement.GetString() ?? "Unknown LSP error."
: "Unknown LSP error.";
tcs.TrySetException(new InvalidOperationException(message));
}
else if (root.TryGetProperty("result", out JsonElement resultElement))
{
tcs.TrySetResult(resultElement.Clone());
}
else
{
tcs.TrySetResult(null);
}
}
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
FailPendingRequests(ex);
}
finally
{
FailPendingRequests(new InvalidOperationException(
$"LSP profile \"{_profile.Name}\" stopped before a pending request completed."));
}
}
private async Task ReadErrorLoopAsync(CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
string? line = await _process.StandardError.ReadLineAsync(cancellationToken).ConfigureAwait(false);
if (line is null)
{
break;
}
if (!string.IsNullOrWhiteSpace(line))
{
Console.Error.WriteLine($"[eryx lsp:{_profile.Id}] {line}");
}
}
}
catch (OperationCanceledException)
{
}
}
private static async Task<Dictionary<string, string>?> ReadHeadersAsync(
Stream stream,
CancellationToken cancellationToken)
{
List<byte> bytes = [];
byte[] buffer = new byte[1];
while (true)
{
int read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read == 0)
{
return bytes.Count == 0
? null
: throw new EndOfStreamException("LSP stream ended mid-header.");
}
bytes.Add(buffer[0]);
int count = bytes.Count;
if (count >= 4
&& bytes[count - 4] == '\r'
&& bytes[count - 3] == '\n'
&& bytes[count - 2] == '\r'
&& bytes[count - 1] == '\n')
{
break;
}
}
string headerText = Encoding.ASCII.GetString(bytes.Take(bytes.Count - 4).ToArray());
Dictionary<string, string> headers = new(StringComparer.OrdinalIgnoreCase);
foreach (string line in headerText.Split("\r\n", StringSplitOptions.RemoveEmptyEntries))
{
int separatorIndex = line.IndexOf(':');
if (separatorIndex < 0)
{
continue;
}
string key = line[..separatorIndex].Trim();
string value = line[(separatorIndex + 1)..].Trim();
headers[key] = value;
}
return headers;
}
private static async Task<byte[]> ReadExactAsync(
Stream stream,
int length,
CancellationToken cancellationToken)
{
byte[] buffer = new byte[length];
int offset = 0;
while (offset < length)
{
int read = await stream.ReadAsync(buffer.AsMemory(offset, length - offset), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
throw new EndOfStreamException("LSP stream ended mid-message.");
}
offset += read;
}
return buffer;
}
private static bool TryReadRequestId(JsonElement idElement, out int requestId)
{
requestId = default;
return idElement.ValueKind switch
{
JsonValueKind.Number => idElement.TryGetInt32(out requestId),
JsonValueKind.String => int.TryParse(idElement.GetString(), out requestId),
_ => false,
};
}
private void FailPendingRequests(Exception exception)
{
foreach ((_, TaskCompletionSource<JsonElement?> tcs) in _pending.ToArray())
{
tcs.TrySetException(exception);
}
_pending.Clear();
}
private string ResolveProjectPath(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
{
throw new InvalidOperationException("A project-relative path is required.");
}
string fullPath = Path.IsPathRooted(relativePath)
? Path.GetFullPath(relativePath)
: Path.GetFullPath(Path.Combine(_projectPath, relativePath));
if (!File.Exists(fullPath))
{
throw new FileNotFoundException($"Could not find \"{relativePath}\" in the current project.", fullPath);
}
string normalizedProjectPath = Path.TrimEndingDirectorySeparator(_projectPath);
if (!fullPath.StartsWith(normalizedProjectPath, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("LSP tools can only access files inside the current project.");
}
return fullPath;
}
private string ResolveLanguageId(string documentPath)
{
string extension = Path.GetExtension(documentPath);
if (_profile.FileExtensions.Any(candidate =>
string.Equals(candidate, extension, StringComparison.OrdinalIgnoreCase)))
{
return _profile.LanguageId;
}
return _profile.LanguageId;
}
private static string BuildToolPrefix(string value)
{
StringBuilder builder = new();
foreach (char ch in value)
{
if (char.IsLetterOrDigit(ch))
{
builder.Append(char.ToLowerInvariant(ch));
continue;
}
if (builder.Length == 0 || builder[^1] == '_')
{
continue;
}
builder.Append('_');
}
string prefix = builder.ToString().Trim('_');
return string.IsNullOrWhiteSpace(prefix) ? "lsp" : $"lsp_{prefix}";
}
private static int NormalizePosition(int value)
{
return Math.Max(value - 1, 0);
}
private static string ToFileUri(string path)
{
return new Uri(path).AbsoluteUri;
}
private static string SerializeResult(JsonElement? result, int? maxItems = null)
{
if (result is null)
{
return "null";
}
JsonElement output = result.Value;
if (maxItems.HasValue && output.ValueKind == JsonValueKind.Array)
{
JsonElement[] limited = output.EnumerateArray().Take(Math.Max(maxItems.Value, 0)).ToArray();
return JsonSerializer.Serialize(limited, JsonOptions);
}
return JsonSerializer.Serialize(output, JsonOptions);
}
}
@@ -0,0 +1,98 @@
using GitHub.Copilot.SDK;
using Eryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
namespace Eryx.AgentHost.Services;
internal sealed class SessionToolingBundle : IAsyncDisposable
{
private readonly List<IAsyncDisposable> _disposables = [];
private SessionToolingBundle(
Dictionary<string, object> mcpServers,
IReadOnlyList<AIFunction> tools)
{
McpServers = mcpServers;
Tools = tools;
}
public Dictionary<string, object> McpServers { get; }
public IReadOnlyList<AIFunction> Tools { get; }
public static async Task<SessionToolingBundle> CreateAsync(
RunTurnToolingConfigDto? tooling,
string projectPath,
CancellationToken cancellationToken)
{
Dictionary<string, object> mcpServers = BuildMcpServerConfigurations(tooling?.McpServers ?? []);
List<IAsyncDisposable> disposables = [];
List<AIFunction> tools = [];
foreach (RunTurnLspProfileConfigDto profile in tooling?.LspProfiles ?? [])
{
LspToolSession lspSession = await LspToolSession.StartAsync(profile, projectPath, cancellationToken)
.ConfigureAwait(false);
disposables.Add(lspSession);
tools.AddRange(lspSession.Tools);
}
SessionToolingBundle bundle = new(mcpServers, tools);
bundle._disposables.AddRange(disposables);
return bundle;
}
public async ValueTask DisposeAsync()
{
foreach (IAsyncDisposable disposable in _disposables)
{
await disposable.DisposeAsync().ConfigureAwait(false);
}
}
internal static Dictionary<string, object> BuildMcpServerConfigurations(
IReadOnlyList<RunTurnMcpServerConfigDto> servers)
{
Dictionary<string, object> configurations = new(StringComparer.OrdinalIgnoreCase);
foreach (RunTurnMcpServerConfigDto server in servers)
{
string serverName = string.IsNullOrWhiteSpace(server.Name) ? server.Id : server.Name.Trim();
List<string> tools = server.Tools.Count == 0 ? ["*"] : server.Tools.ToList();
if (string.Equals(server.Transport, "local", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(server.Command))
{
throw new InvalidOperationException($"MCP server \"{serverName}\" is missing a command.");
}
configurations[serverName] = new McpLocalServerConfig
{
Type = "local",
Timeout = server.TimeoutMs,
Command = server.Command,
Args = server.Args?.ToList() ?? [],
Cwd = string.IsNullOrWhiteSpace(server.Cwd) ? null : server.Cwd,
Tools = tools,
};
continue;
}
if (string.IsNullOrWhiteSpace(server.Url))
{
throw new InvalidOperationException($"MCP server \"{serverName}\" is missing a URL.");
}
configurations[serverName] = new McpRemoteServerConfig
{
Type = server.Transport,
Timeout = server.TimeoutMs,
Url = server.Url,
Tools = tools,
};
}
return configurations;
}
}