| | | 1 | | using System.Net.Http.Headers; |
| | | 2 | | using System.Text; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | |
| | | 5 | | namespace 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> |
| | | 11 | | internal 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 | | |
| | 7 | 19 | | public CopilotMcpToolClient( |
| | 7 | 20 | | IGitHubOAuthTokenProvider oauthProvider, |
| | 7 | 21 | | CopilotChatClientOptions? options = null, |
| | 7 | 22 | | HttpClient? httpClient = null) |
| | | 23 | | { |
| | 7 | 24 | | _oauthProvider = oauthProvider ?? throw new ArgumentNullException(nameof(oauthProvider)); |
| | 7 | 25 | | _options = options ?? new CopilotChatClientOptions(); |
| | 7 | 26 | | _ownsHttpClient = httpClient is null; |
| | 7 | 27 | | _httpClient = httpClient ?? new HttpClient(); |
| | 7 | 28 | | } |
| | | 29 | | |
| | | 30 | | public async Task<string> CallToolAsync( |
| | | 31 | | string toolName, |
| | | 32 | | Dictionary<string, string> arguments, |
| | | 33 | | string toolset, |
| | | 34 | | CancellationToken cancellationToken = default) |
| | | 35 | | { |
| | 6 | 36 | | var requestId = Interlocked.Increment(ref _nextId); |
| | 6 | 37 | | var rpcRequest = new McpJsonRpcRequest |
| | 6 | 38 | | { |
| | 6 | 39 | | Id = requestId, |
| | 6 | 40 | | Method = "tools/call", |
| | 6 | 41 | | Params = new McpCallParams |
| | 6 | 42 | | { |
| | 6 | 43 | | Name = toolName, |
| | 6 | 44 | | Arguments = arguments, |
| | 6 | 45 | | }, |
| | 6 | 46 | | }; |
| | | 47 | | |
| | 6 | 48 | | var oauthToken = _oauthProvider.GetOAuthToken(); |
| | 6 | 49 | | var url = $"{_options.CopilotApiBaseUrl.TrimEnd('/')}/mcp/readonly"; |
| | | 50 | | |
| | 6 | 51 | | var jsonBody = JsonSerializer.Serialize(rpcRequest, McpJsonContext.Default.McpJsonRpcRequest); |
| | | 52 | | |
| | 9 | 53 | | for (int attempt = 0; ; attempt++) |
| | | 54 | | { |
| | 9 | 55 | | var content = new StringContent(jsonBody, Encoding.UTF8); |
| | 9 | 56 | | content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); |
| | 9 | 57 | | using var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) |
| | 9 | 58 | | { |
| | 9 | 59 | | Content = content, |
| | 9 | 60 | | }; |
| | | 61 | | |
| | 9 | 62 | | httpRequest.Headers.Add("Authorization", $"Bearer {oauthToken}"); |
| | 9 | 63 | | httpRequest.Headers.Add("Accept", "application/json, text/event-stream"); |
| | 9 | 64 | | httpRequest.Headers.Add("X-MCP-Toolsets", toolset); |
| | 9 | 65 | | httpRequest.Headers.Add("X-MCP-Host", "github-coding-agent"); |
| | 9 | 66 | | httpRequest.Headers.Add("Copilot-Integration-Id", _options.IntegrationId); |
| | | 67 | | |
| | 9 | 68 | | using var httpResponse = await _httpClient.SendAsync(httpRequest, cancellationToken) |
| | 9 | 69 | | .ConfigureAwait(false); |
| | | 70 | | |
| | 9 | 71 | | if (httpResponse.IsSuccessStatusCode) |
| | | 72 | | { |
| | 2 | 73 | | var responseText = await httpResponse.Content.ReadAsStringAsync(cancellationToken) |
| | 2 | 74 | | .ConfigureAwait(false); |
| | 2 | 75 | | return ParseSseResponse(responseText); |
| | | 76 | | } |
| | | 77 | | |
| | 7 | 78 | | if (httpResponse.StatusCode == System.Net.HttpStatusCode.TooManyRequests |
| | 7 | 79 | | && attempt < _options.MaxRetries) |
| | | 80 | | { |
| | 3 | 81 | | var delay = GetRetryDelay(httpResponse, attempt); |
| | 3 | 82 | | await Task.Delay(delay, cancellationToken).ConfigureAwait(false); |
| | 3 | 83 | | continue; |
| | | 84 | | } |
| | | 85 | | |
| | 4 | 86 | | if (httpResponse.StatusCode == System.Net.HttpStatusCode.TooManyRequests) |
| | | 87 | | { |
| | 3 | 88 | | var retryAfter = GetRetryAfterFromHeaders(httpResponse); |
| | 3 | 89 | | var errorBody = await httpResponse.Content.ReadAsStringAsync(cancellationToken) |
| | 3 | 90 | | .ConfigureAwait(false); |
| | 3 | 91 | | throw new CopilotRateLimitException( |
| | 3 | 92 | | $"Copilot web search rate limited after {_options.MaxRetries} retries: {errorBody}", |
| | 3 | 93 | | retryAfter); |
| | | 94 | | } |
| | | 95 | | |
| | 1 | 96 | | var errorContent = await httpResponse.Content.ReadAsStringAsync(cancellationToken) |
| | 1 | 97 | | .ConfigureAwait(false); |
| | 1 | 98 | | throw new HttpRequestException( |
| | 1 | 99 | | $"MCP tool call failed ({httpResponse.StatusCode}): {errorContent}"); |
| | | 100 | | } |
| | 2 | 101 | | } |
| | | 102 | | |
| | | 103 | | private TimeSpan GetRetryDelay(HttpResponseMessage response, int attempt) |
| | | 104 | | { |
| | 3 | 105 | | if (response.Headers.RetryAfter?.Delta is { } delta) |
| | 0 | 106 | | return delta; |
| | | 107 | | |
| | 3 | 108 | | if (response.Headers.RetryAfter?.Date is { } date) |
| | | 109 | | { |
| | 0 | 110 | | var wait = date - DateTimeOffset.UtcNow; |
| | 0 | 111 | | if (wait > TimeSpan.Zero) |
| | 0 | 112 | | return wait; |
| | | 113 | | } |
| | | 114 | | |
| | 3 | 115 | | var ms = _options.RetryBaseDelayMs * (1 << attempt); |
| | 3 | 116 | | return TimeSpan.FromMilliseconds(ms); |
| | | 117 | | } |
| | | 118 | | |
| | | 119 | | private static TimeSpan? GetRetryAfterFromHeaders(HttpResponseMessage response) |
| | | 120 | | { |
| | 3 | 121 | | if (response.Headers.RetryAfter?.Delta is { } delta) |
| | 1 | 122 | | return delta; |
| | | 123 | | |
| | 2 | 124 | | if (response.Headers.RetryAfter?.Date is { } date) |
| | | 125 | | { |
| | 0 | 126 | | var wait = date - DateTimeOffset.UtcNow; |
| | 0 | 127 | | if (wait > TimeSpan.Zero) |
| | 0 | 128 | | return wait; |
| | | 129 | | } |
| | | 130 | | |
| | 2 | 131 | | return null; |
| | | 132 | | } |
| | | 133 | | |
| | | 134 | | private static string ParseSseResponse(string sseText) |
| | | 135 | | { |
| | 10 | 136 | | foreach (var line in sseText.Split('\n')) |
| | | 137 | | { |
| | 4 | 138 | | if (!line.StartsWith("data: ", StringComparison.Ordinal)) |
| | | 139 | | { |
| | | 140 | | continue; |
| | | 141 | | } |
| | | 142 | | |
| | 2 | 143 | | var json = line["data: ".Length..]; |
| | 2 | 144 | | if (json is "[DONE]") |
| | | 145 | | { |
| | | 146 | | continue; |
| | | 147 | | } |
| | | 148 | | |
| | | 149 | | McpJsonRpcResponse? rpcResponse; |
| | | 150 | | try |
| | | 151 | | { |
| | 2 | 152 | | rpcResponse = JsonSerializer.Deserialize(json, McpJsonContext.Default.McpJsonRpcResponse); |
| | 2 | 153 | | } |
| | 0 | 154 | | catch (JsonException) |
| | | 155 | | { |
| | 0 | 156 | | continue; |
| | | 157 | | } |
| | | 158 | | |
| | 2 | 159 | | if (rpcResponse?.Error is { } error) |
| | | 160 | | { |
| | 0 | 161 | | throw new InvalidOperationException( |
| | 0 | 162 | | $"MCP tool returned error ({error.Code}): {error.Message}"); |
| | | 163 | | } |
| | | 164 | | |
| | 2 | 165 | | if (rpcResponse?.Result?.Content is { Count: > 0 } contents) |
| | | 166 | | { |
| | 2 | 167 | | var textParts = contents |
| | 2 | 168 | | .Where(c => !string.IsNullOrEmpty(c.Text)) |
| | 4 | 169 | | .Select(c => c.Text!); |
| | 2 | 170 | | return string.Join("\n", textParts); |
| | | 171 | | } |
| | | 172 | | } |
| | | 173 | | |
| | 0 | 174 | | throw new InvalidOperationException("MCP response contained no data."); |
| | | 175 | | } |
| | | 176 | | |
| | | 177 | | public void Dispose() |
| | | 178 | | { |
| | 6 | 179 | | if (_ownsHttpClient) |
| | | 180 | | { |
| | 0 | 181 | | _httpClient.Dispose(); |
| | | 182 | | } |
| | 6 | 183 | | } |
| | | 184 | | } |