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