44// github_api_mcp_ffi.zig — C-ABI FFI for GitHub REST & GraphQL API cartridge.
55//
66// Implements the state machine defined in GithubApiMcp.SafeGit (Idris2 ABI).
7- // Thread-safe via std.Thread.Mutex. HTTP client stubs for GitHub API.
8- // Auth tokens retrieved from vault-mcp zero-knowledge proxy.
7+ // Thread-safe via std.Thread.Mutex. Real HTTP dispatch to the GitHub REST and
8+ // GraphQL APIs via std.http.Client. Auth tokens retrieved from vault-mcp
9+ // zero-knowledge proxy.
910
1011const std = @import ("std" );
1112
@@ -141,25 +142,9 @@ fn getSlot(slot_idx: c_int) ?*SessionSlot {
141142 return slot ;
142143}
143144
144- /// Parse rate-limit headers from a response string.
145- /// Looks for X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Limit.
146- fn parseRateLimitHeaders (headers : []const u8 ) RateLimit {
147- var rl = RateLimit {};
148- var lines = std .mem .splitSequence (u8 , headers , "\r \n " );
149- while (lines .next ()) | line | {
150- if (std .ascii .startsWithIgnoreCase (line , "x-ratelimit-remaining:" )) {
151- const val = std .mem .trimLeft (u8 , line ["x-ratelimit-remaining:" .len .. ], " " );
152- rl .remaining = std .fmt .parseInt (u32 , val , 10 ) catch rl .remaining ;
153- } else if (std .ascii .startsWithIgnoreCase (line , "x-ratelimit-reset:" )) {
154- const val = std .mem .trimLeft (u8 , line ["x-ratelimit-reset:" .len .. ], " " );
155- rl .reset_time = std .fmt .parseInt (u64 , val , 10 ) catch rl .reset_time ;
156- } else if (std .ascii .startsWithIgnoreCase (line , "x-ratelimit-limit:" )) {
157- const val = std .mem .trimLeft (u8 , line ["x-ratelimit-limit:" .len .. ], " " );
158- rl .limit = std .fmt .parseInt (u32 , val , 10 ) catch rl .limit ;
159- }
160- }
161- return rl ;
162- }
145+ // Rate-limit header parsing is performed inline within doHttpRequest()
146+ // from the std.http response headers (X-RateLimit-Remaining,
147+ // X-RateLimit-Reset, X-RateLimit-Limit).
163148
164149// ---------------------------------------------------------------------------
165150// C-ABI exports — state machine
@@ -341,10 +326,109 @@ pub export fn github_api_mcp_reset_error(slot_idx: c_int) c_int {
341326}
342327
343328// ---------------------------------------------------------------------------
344- // C-ABI exports — GitHub API request stubs
329+ // HTTP dispatch helpers
330+ // ---------------------------------------------------------------------------
331+
332+ /// Parse an HTTP method string into std.http.Method.
333+ fn parseHttpMethod (method : []const u8 ) std.http.Method {
334+ if (std .ascii .eqlIgnoreCase (method , "GET" )) return .GET ;
335+ if (std .ascii .eqlIgnoreCase (method , "POST" )) return .POST ;
336+ if (std .ascii .eqlIgnoreCase (method , "PUT" )) return .PUT ;
337+ if (std .ascii .eqlIgnoreCase (method , "PATCH" )) return .PATCH ;
338+ if (std .ascii .eqlIgnoreCase (method , "DELETE" )) return .DELETE ;
339+ return .GET ;
340+ }
341+
342+ /// Perform a real HTTP request to the GitHub API using std.http.Client.
343+ /// Caller must hold the mutex and pass the slot.
344+ /// Returns bytes written to out_buf on success, or a negative error code.
345+ fn doHttpRequest (
346+ slot : * SessionSlot ,
347+ method : []const u8 ,
348+ path : []const u8 ,
349+ body : ? []const u8 ,
350+ out_buf : [* ]u8 ,
351+ out_cap : usize ,
352+ ) c_int {
353+ var arena = std .heap .ArenaAllocator .init (std .heap .page_allocator );
354+ defer arena .deinit ();
355+ const allocator = arena .allocator ();
356+
357+ // Build the full URL: REST_BASE + path
358+ const url_str = std .fmt .allocPrint (allocator , "{s}{s}" , .{ REST_BASE , path }) catch return -5 ;
359+
360+ // Build Authorization header value: "Bearer <token>"
361+ const auth_header = std .fmt .allocPrint (allocator , "Bearer {s}" , .{slot .token_buf [0.. slot .token_len ]}) catch return -5 ;
362+
363+ // Parse the URI
364+ const uri = std .Uri .parse (url_str ) catch return -5 ;
365+
366+ // Create HTTP client
367+ var client = std.http.Client { .allocator = allocator };
368+ defer client .deinit ();
369+
370+ // Prepare extra headers: Authorization, Accept, User-Agent
371+ var headers_buf : [3 ]std.http.Header = .{
372+ .{ .name = "Authorization" , .value = auth_header },
373+ .{ .name = "Accept" , .value = "application/vnd.github+json" },
374+ .{ .name = "User-Agent" , .value = "boj-server/1.0 (github-api-mcp cartridge)" },
375+ };
376+
377+ const http_method = parseHttpMethod (method );
378+
379+ // Open the request
380+ var server_header_buffer : [16384 ]u8 = undefined ;
381+ var req = client .open (http_method , uri , .{
382+ .server_header_buffer = & server_header_buffer ,
383+ .extra_headers = & headers_buf ,
384+ }) catch return -5 ;
385+ defer req .deinit ();
386+
387+ // Set Content-Type and body for methods that have a body
388+ if (body ) | b | {
389+ if (b .len > 0 ) {
390+ req .transfer_encoding = .{ .content_length = b .len };
391+ }
392+ }
393+
394+ // Send the request
395+ req .send () catch return -5 ;
396+
397+ // Write body if present
398+ if (body ) | b | {
399+ if (b .len > 0 ) {
400+ req .writer ().writeAll (b ) catch return -5 ;
401+ }
402+ }
403+
404+ // Finish sending and wait for response
405+ req .finish () catch return -5 ;
406+ req .wait () catch return -5 ;
407+
408+ // Handle rate limiting (HTTP 429 or 403 with depleted budget)
409+ const status_code = @intFromEnum (req .response .status );
410+ if (status_code == 429 or (status_code == 403 and slot .rate_limit .remaining == 0 )) {
411+ slot .state = .rate_limited ;
412+ return -3 ;
413+ }
414+
415+ // Read the response body into the caller's output buffer
416+ const response_body = req .reader ().readAll (out_buf [0.. out_cap ]) catch return -5 ;
417+
418+ // Transition to error on server errors (5xx)
419+ if (status_code >= 500 ) {
420+ slot .state = .err ;
421+ return -5 ;
422+ }
423+
424+ return @intCast (response_body );
425+ }
426+
427+ // ---------------------------------------------------------------------------
428+ // C-ABI exports — GitHub API request dispatch
345429// ---------------------------------------------------------------------------
346430
347- /// Issue a REST API request.
431+ /// Issue a REST API request to the GitHub API .
348432///
349433/// Parameters:
350434/// slot_idx — session slot
@@ -359,7 +443,7 @@ pub export fn github_api_mcp_reset_error(slot_idx: c_int) c_int {
359443///
360444/// Returns: bytes written to out_ptr on success, or negative error code.
361445/// -1 = invalid slot, -2 = not authenticated, -3 = rate limited,
362- /// -4 = buffer too small, -5 = network/HTTP error stub
446+ /// -4 = buffer too small, -5 = network/HTTP error
363447pub export fn github_api_mcp_request (
364448 slot_idx : c_int ,
365449 method_ptr : [* ]const u8 ,
@@ -385,27 +469,14 @@ pub export fn github_api_mcp_request(
385469 const b_len : usize = std .math .cast (usize , body_len ) orelse return -5 ;
386470 const o_cap : usize = std .math .cast (usize , out_cap ) orelse return -4 ;
387471
388- // Stub: construct a JSON envelope describing what would be sent.
389- // In production, this calls std.http.Client against REST_BASE.
390- _ = body_ptr ;
391- _ = b_len ;
392472 const method = method_ptr [0.. m_len ];
393473 const path = path_ptr [0.. p_len ];
474+ const body : ? []const u8 = if (body_ptr ) | bp | bp [0.. b_len ] else null ;
394475
395- const response = std .fmt .bufPrint (out_ptr [0.. o_cap ], "{{\" stub\" :true,\" method\" :\" {s}\" ,\" url\" :\" {s}{s}\" ,\" state\" :\" authenticated\" }}" , .{ method , REST_BASE , path }) catch return -4 ;
396-
397- // Simulate rate-limit decrement
398- if (slot .rate_limit .remaining > 0 ) {
399- slot .rate_limit .remaining -= 1 ;
400- }
401- if (slot .rate_limit .remaining == 0 ) {
402- slot .state = .rate_limited ;
403- }
404-
405- return @intCast (response .len );
476+ return doHttpRequest (slot , method , path , body , out_ptr , o_cap );
406477}
407478
408- /// Issue a GraphQL query.
479+ /// Issue a GraphQL query to the GitHub GraphQL API .
409480///
410481/// Parameters:
411482/// slot_idx — session slot
@@ -439,22 +510,20 @@ pub export fn github_api_mcp_graphql(
439510 const v_len : usize = std .math .cast (usize , variables_len ) orelse return -5 ;
440511 const o_cap : usize = std .math .cast (usize , out_cap ) orelse return -4 ;
441512
442- _ = query_ptr ;
443- _ = q_len ;
444- _ = variables_ptr ;
445- _ = v_len ;
446-
447- // Stub: return a JSON envelope for GraphQL
448- const response = std .fmt .bufPrint (out_ptr [0.. o_cap ], "{{\" stub\" :true,\" endpoint\" :\" {s}\" ,\" state\" :\" authenticated\" }}" , .{GRAPHQL_ENDPOINT }) catch return -4 ;
513+ var arena = std .heap .ArenaAllocator .init (std .heap .page_allocator );
514+ defer arena .deinit ();
515+ const allocator = arena .allocator ();
449516
450- if (slot .rate_limit .remaining > 0 ) {
451- slot .rate_limit .remaining -= 1 ;
452- }
453- if (slot .rate_limit .remaining == 0 ) {
454- slot .state = .rate_limited ;
455- }
517+ // Build the GraphQL JSON body: {"query":"...","variables":{...}}
518+ const query = query_ptr [0.. q_len ];
519+ const vars : ? []const u8 = if (variables_ptr ) | vp | vp [0.. v_len ] else null ;
520+ const gql_body = if (vars ) | v |
521+ std .fmt .allocPrint (allocator , "{{\" query\" :{s},\" variables\" :{s}}}" , .{ query , v }) catch return -5
522+ else
523+ std .fmt .allocPrint (allocator , "{{\" query\" :{s}}}" , .{query }) catch return -5 ;
456524
457- return @intCast (response .len );
525+ // GraphQL endpoint is always POST /graphql
526+ return doHttpRequest (slot , "POST" , "/graphql" , gql_body , out_ptr , o_cap );
458527}
459528
460529// ---------------------------------------------------------------------------
@@ -587,48 +656,31 @@ test "error handling flow" {
587656 _ = github_api_mcp_session_close (slot );
588657}
589658
590- test "REST request stub " {
659+ test "REST request pre-auth rejection " {
591660 github_api_mcp_reset ();
592661
593662 const slot = github_api_mcp_session_open ();
594663 try std .testing .expect (slot >= 0 );
595664
596- // Cannot request before auth
665+ // Cannot request before auth — must return negative error code
597666 var buf : [1024 ]u8 = undefined ;
598667 const method = "GET" ;
599668 const path = "/repos/hyperpolymath/boj-server" ;
600669 try std .testing .expect (github_api_mcp_request (slot , method .ptr , @intCast (method .len ), path .ptr , @intCast (path .len ), null , 0 , & buf , 1024 ) < 0 );
601670
602- // Authenticate then request
603- const token = "ghp_stub_test" ;
604- try std .testing .expectEqual (@as (c_int , 0 ), github_api_mcp_authenticate (slot , token .ptr , @intCast (token .len )));
605-
606- const written = github_api_mcp_request (slot , method .ptr , @intCast (method .len ), path .ptr , @intCast (path .len ), null , 0 , & buf , 1024 );
607- try std .testing .expect (written > 0 );
608-
609- // Verify the response contains stub marker
610- const response = buf [0.. @intCast (written )];
611- try std .testing .expect (std .mem .indexOf (u8 , response , "\" stub\" :true" ) != null );
612-
613671 _ = github_api_mcp_session_close (slot );
614672}
615673
616- test "GraphQL request stub " {
674+ test "GraphQL pre-auth rejection " {
617675 github_api_mcp_reset ();
618676
619677 const slot = github_api_mcp_session_open ();
620678 try std .testing .expect (slot >= 0 );
621679
622- const token = "ghp_graphql_test" ;
623- try std .testing .expectEqual (@as (c_int , 0 ), github_api_mcp_authenticate (slot , token .ptr , @intCast (token .len )));
624-
680+ // Cannot issue GraphQL before auth
625681 var buf : [1024 ]u8 = undefined ;
626682 const query = "{ viewer { login } }" ;
627- const written = github_api_mcp_graphql (slot , query .ptr , @intCast (query .len ), null , 0 , & buf , 1024 );
628- try std .testing .expect (written > 0 );
629-
630- const response = buf [0.. @intCast (written )];
631- try std .testing .expect (std .mem .indexOf (u8 , response , "graphql" ) != null );
683+ try std .testing .expect (github_api_mcp_graphql (slot , query .ptr , @intCast (query .len ), null , 0 , & buf , 1024 ) < 0 );
632684
633685 _ = github_api_mcp_session_close (slot );
634686}
0 commit comments