< 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
87%
Covered lines: 88
Uncovered lines: 13
Coverable lines: 101
Total lines: 195
Line coverage: 87.1%
Branch coverage
75%
Covered branches: 45
Total branches: 60
Branch coverage: 75%
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%1414100%
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
 1119    public CopilotMcpToolClient(
 1120        IGitHubOAuthTokenProvider oauthProvider,
 1121        CopilotChatClientOptions? options = null,
 1122        HttpClient? httpClient = null)
 23    {
 1124        _oauthProvider = oauthProvider ?? throw new ArgumentNullException(nameof(oauthProvider));
 1125        _options = options ?? new CopilotChatClientOptions();
 1126        _ownsHttpClient = httpClient is null;
 1127        _httpClient = httpClient ?? new HttpClient();
 1128    }
 29
 30    public async Task<string> CallToolAsync(
 31        string toolName,
 32        Dictionary<string, string> arguments,
 33        string toolset,
 34        CancellationToken cancellationToken = default)
 35    {
 1036        var requestId = Interlocked.Increment(ref _nextId);
 1037        var rpcRequest = new McpJsonRpcRequest
 1038        {
 1039            Id = requestId,
 1040            Method = "tools/call",
 1041            Params = new McpCallParams
 1042            {
 1043                Name = toolName,
 1044                Arguments = arguments,
 1045            },
 1046        };
 47
 1048        var oauthToken = _oauthProvider.GetOAuthToken();
 949        var url = $"{_options.CopilotApiBaseUrl.TrimEnd('/')}/mcp/readonly";
 50
 951        var jsonBody = JsonSerializer.Serialize(rpcRequest, McpJsonContext.Default.McpJsonRpcRequest);
 52
 1253        for (int attempt = 0; ; attempt++)
 54        {
 1255            var content = new StringContent(jsonBody, Encoding.UTF8);
 1256            content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
 1257            using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url)
 1258            {
 1259                Content = content,
 1260            };
 61
 1262            httpRequest.Headers.Add("Authorization", $"Bearer {oauthToken}");
 1263            httpRequest.Headers.Add("Accept", "application/json, text/event-stream");
 1264            httpRequest.Headers.Add("X-MCP-Toolsets", toolset);
 1265            httpRequest.Headers.Add("X-MCP-Host", "github-coding-agent");
 1266            httpRequest.Headers.Add("Copilot-Integration-Id", _options.IntegrationId);
 67
 1268            using var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken)
 1269                .ConfigureAwait(false);
 70
 1271            if (httpResponse.IsSuccessStatusCode)
 72            {
 273                var responseText = await httpResponse.Content.ReadAsStringAsync(cancellationToken)
 274                    .ConfigureAwait(false);
 275                return ParseSseResponse(responseText);
 76            }
 77
 1078            if (httpResponse.StatusCode == System.Net.HttpStatusCode.TooManyRequests
 1079                && attempt < _options.MaxRetries)
 80            {
 381                var delay = GetRetryDelay(httpResponse, attempt);
 382                await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
 383                continue;
 84            }
 85
 786            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
 496            if (httpResponse.StatusCode == System.Net.HttpStatusCode.Unauthorized
 497                || httpResponse.StatusCode == System.Net.HttpStatusCode.Forbidden)
 98            {
 399                var errorBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken)
 3100                    .ConfigureAwait(false);
 3101                throw new CopilotAuthException(
 3102                    $"Copilot MCP request rejected with HTTP {(int)httpResponse.StatusCode} "
 3103                    + $"{httpResponse.StatusCode}. Verify the GitHub OAuth token is valid and has the "
 3104                    + $"required scopes. Server response: {errorBody}");
 105            }
 106
 1107            var errorContent = await httpResponse.Content.ReadAsStringAsync(cancellationToken)
 1108                .ConfigureAwait(false);
 1109            throw new HttpRequestException(
 1110                $"MCP tool call failed ({httpResponse.StatusCode}): {errorContent}");
 111        }
 2112    }
 113
 114    private TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt)
 115    {
 3116        if (response.Headers.RetryAfter?.Delta is { } delta)
 0117            return delta;
 118
 3119        if (response.Headers.RetryAfter?.Date is { } date)
 120        {
 0121            var wait = date - DateTimeOffset.UtcNow;
 0122            if (wait > TimeSpan.Zero)
 0123                return wait;
 124        }
 125
 3126        var ms = _options.RetryBaseDelayMs * (1 << attempt);
 3127        return TimeSpan.FromMilliseconds(ms);
 128    }
 129
 130    private static TimeSpan? GetRetryAfterFromHeaders(HttpResponseMessage response)
 131    {
 3132        if (response.Headers.RetryAfter?.Delta is { } delta)
 1133            return delta;
 134
 2135        if (response.Headers.RetryAfter?.Date is { } date)
 136        {
 0137            var wait = date - DateTimeOffset.UtcNow;
 0138            if (wait > TimeSpan.Zero)
 0139                return wait;
 140        }
 141
 2142        return null;
 143    }
 144
 145    private static string ParseSseResponse(string sseText)
 146    {
 10147        foreach (var line in sseText.Split('\n'))
 148        {
 4149            if (!line.StartsWith("data: ", StringComparison.Ordinal))
 150            {
 151                continue;
 152            }
 153
 2154            var json = line["data: ".Length..];
 2155            if (json is "[DONE]")
 156            {
 157                continue;
 158            }
 159
 160            McpJsonRpcResponse? rpcResponse;
 161            try
 162            {
 2163                rpcResponse = JsonSerializer.Deserialize(json, McpJsonContext.Default.McpJsonRpcResponse);
 2164            }
 0165            catch (JsonException)
 166            {
 0167                continue;
 168            }
 169
 2170            if (rpcResponse?.Error is { } error)
 171            {
 0172                throw new InvalidOperationException(
 0173                    $"MCP tool returned error ({error.Code}): {error.Message}");
 174            }
 175
 2176            if (rpcResponse?.Result?.Content is { Count: > 0 } contents)
 177            {
 2178                var textParts = contents
 2179                    .Where(c => !string.IsNullOrEmpty(c.Text))
 4180                    .Select(c => c.Text!);
 2181                return string.Join("\n", textParts);
 182            }
 183        }
 184
 0185        throw new InvalidOperationException("MCP response contained no data.");
 186    }
 187
 188    public void Dispose()
 189    {
 8190        if (_ownsHttpClient)
 191        {
 0192            _httpClient.Dispose();
 193        }
 8194    }
 195}