< Summary

Information
Class: NexusLabs.Needlr.Copilot.CopilotMcpToolClient
Assembly: NexusLabs.Needlr.Copilot
File(s): /home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Copilot/CopilotMcpToolClient.cs
Line coverage
86%
Covered lines: 80
Uncovered lines: 13
Coverable lines: 93
Total lines: 184
Line coverage: 86%
Branch coverage
73%
Covered branches: 41
Total branches: 56
Branch coverage: 73.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)83.33%66100%
CallToolAsync()100%1010100%
GetRetryDelay(...)50%231050%
GetRetryAfterFromHeaders(...)70%181057.14%
ParseSseResponse(...)72.22%261870.58%
Dispose()50%2266.66%

File(s)

/home/runner/work/needlr/needlr/src/NexusLabs.Needlr.Copilot/CopilotMcpToolClient.cs

#LineLine coverage
 1using System.Net.Http.Headers;
 2using System.Text;
 3using System.Text.Json;
 4
 5namespace NexusLabs.Needlr.Copilot;
 6
 7/// <summary>
 8/// Thin JSON-RPC client for calling tools on the GitHub Copilot MCP server
 9/// at <c>api.githubcopilot.com/mcp/readonly</c>. Parses SSE responses.
 10/// </summary>
 11internal sealed class CopilotMcpToolClient : IDisposable
 12{
 13    private readonly IGitHubOAuthTokenProvider _oauthProvider;
 14    private readonly CopilotChatClientOptions _options;
 15    private readonly HttpClient _httpClient;
 16    private readonly bool _ownsHttpClient;
 17    private int _nextId;
 18
 719    public CopilotMcpToolClient(
 720        IGitHubOAuthTokenProvider oauthProvider,
 721        CopilotChatClientOptions? options = null,
 722        HttpClient? httpClient = null)
 23    {
 724        _oauthProvider = oauthProvider ?? throw new ArgumentNullException(nameof(oauthProvider));
 725        _options = options ?? new CopilotChatClientOptions();
 726        _ownsHttpClient = httpClient is null;
 727        _httpClient = httpClient ?? new HttpClient();
 728    }
 29
 30    public async Task<string> CallToolAsync(
 31        string toolName,
 32        Dictionary<string, string> arguments,
 33        string toolset,
 34        CancellationToken cancellationToken = default)
 35    {
 636        var requestId = Interlocked.Increment(ref _nextId);
 637        var rpcRequest = new McpJsonRpcRequest
 638        {
 639            Id = requestId,
 640            Method = "tools/call",
 641            Params = new McpCallParams
 642            {
 643                Name = toolName,
 644                Arguments = arguments,
 645            },
 646        };
 47
 648        var oauthToken = _oauthProvider.GetOAuthToken();
 649        var url = $"{_options.CopilotApiBaseUrl.TrimEnd('/')}/mcp/readonly";
 50
 651        var jsonBody = JsonSerializer.Serialize(rpcRequest, McpJsonContext.Default.McpJsonRpcRequest);
 52
 953        for (int attempt = 0; ; attempt++)
 54        {
 955            var content = new StringContent(jsonBody, Encoding.UTF8);
 956            content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
 957            using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
 958            {
 959                Content = content,
 960            };
 61
 962            httpRequest.Headers.Add("Authorization", $"Bearer {oauthToken}");
 963            httpRequest.Headers.Add("Accept", "application/json, text/event-stream");
 964            httpRequest.Headers.Add("X-MCP-Toolsets", toolset);
 965            httpRequest.Headers.Add("X-MCP-Host", "github-coding-agent");
 966            httpRequest.Headers.Add("Copilot-Integration-Id", _options.IntegrationId);
 67
 968            using var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken)
 969                .ConfigureAwait(false);
 70
 971            if (httpResponse.IsSuccessStatusCode)
 72            {
 273                var responseText = await httpResponse.Content.ReadAsStringAsync(cancellationToken)
 274                    .ConfigureAwait(false);
 275                return ParseSseResponse(responseText);
 76            }
 77
 778            if (httpResponse.StatusCode == System.Net.HttpStatusCode.TooManyRequests
 779                && attempt < _options.MaxRetries)
 80            {
 381                var delay = GetRetryDelay(httpResponse, attempt);
 382                await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
 383                continue;
 84            }
 85
 486            if (httpResponse.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
 87            {
 388                var retryAfter = GetRetryAfterFromHeaders(httpResponse);
 389                var errorBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken)
 390                    .ConfigureAwait(false);
 391                throw new CopilotRateLimitException(
 392                    $"Copilot web search rate limited after {_options.MaxRetries} retries: {errorBody}",
 393                    retryAfter);
 94            }
 95
 196            var errorContent = await httpResponse.Content.ReadAsStringAsync(cancellationToken)
 197                .ConfigureAwait(false);
 198            throw new HttpRequestException(
 199                $"MCP tool call failed ({httpResponse.StatusCode}): {errorContent}");
 100        }
 2101    }
 102
 103    private TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt)
 104    {
 3105        if (response.Headers.RetryAfter?.Delta is { } delta)
 0106            return delta;
 107
 3108        if (response.Headers.RetryAfter?.Date is { } date)
 109        {
 0110            var wait = date - DateTimeOffset.UtcNow;
 0111            if (wait > TimeSpan.Zero)
 0112                return wait;
 113        }
 114
 3115        var ms = _options.RetryBaseDelayMs * (1 << attempt);
 3116        return TimeSpan.FromMilliseconds(ms);
 117    }
 118
 119    private static TimeSpan? GetRetryAfterFromHeaders(HttpResponseMessage response)
 120    {
 3121        if (response.Headers.RetryAfter?.Delta is { } delta)
 1122            return delta;
 123
 2124        if (response.Headers.RetryAfter?.Date is { } date)
 125        {
 0126            var wait = date - DateTimeOffset.UtcNow;
 0127            if (wait > TimeSpan.Zero)
 0128                return wait;
 129        }
 130
 2131        return null;
 132    }
 133
 134    private static string ParseSseResponse(string sseText)
 135    {
 10136        foreach (var line in sseText.Split('\n'))
 137        {
 4138            if (!line.StartsWith("data: ", StringComparison.Ordinal))
 139            {
 140                continue;
 141            }
 142
 2143            var json = line["data: ".Length..];
 2144            if (json is "[DONE]")
 145            {
 146                continue;
 147            }
 148
 149            McpJsonRpcResponse? rpcResponse;
 150            try
 151            {
 2152                rpcResponse = JsonSerializer.Deserialize(json, McpJsonContext.Default.McpJsonRpcResponse);
 2153            }
 0154            catch (JsonException)
 155            {
 0156                continue;
 157            }
 158
 2159            if (rpcResponse?.Error is { } error)
 160            {
 0161                throw new InvalidOperationException(
 0162                    $"MCP tool returned error ({error.Code}): {error.Message}");
 163            }
 164
 2165            if (rpcResponse?.Result?.Content is { Count: > 0 } contents)
 166            {
 2167                var textParts = contents
 2168                    .Where(c => !string.IsNullOrEmpty(c.Text))
 4169                    .Select(c => c.Text!);
 2170                return string.Join("\n", textParts);
 171            }
 172        }
 173
 0174        throw new InvalidOperationException("MCP response contained no data.");
 175    }
 176
 177    public void Dispose()
 178    {
 6179        if (_ownsHttpClient)
 180        {
 0181            _httpClient.Dispose();
 182        }
 6183    }
 184}