2026-04-28 10:56:09 +00:00
//! MCP JSON-RPC POST/GET handlers and gateway tool dispatch.
use super ::jsonrpc ::{ JsonRpcRequest , JsonRpcResponse , to_json_response } ;
use crate ::service ::gateway ::{ self , GatewayState } ;
use poem ::handler ;
use poem ::http ::StatusCode ;
use poem ::web ::Data ;
2026-05-12 14:57:53 +00:00
use poem ::web ::sse ::{ Event , SSE } ;
use poem ::{ Body , IntoResponse , Request , Response } ;
2026-04-28 10:56:09 +00:00
use serde_json ::{ Value , json } ;
use std ::collections ::BTreeMap ;
use std ::sync ::Arc ;
2026-05-12 14:57:53 +00:00
use std ::time ::Duration ;
2026-04-28 10:56:09 +00:00
// ── MCP tool definitions ─────────────────────────────────────────────────────
/// Gateway-specific MCP tools exposed alongside the proxied tools.
const GATEWAY_TOOLS : & [ & str ] = & [
" switch_project " ,
" gateway_status " ,
" gateway_health " ,
" init_project " ,
" aggregate_pipeline_status " ,
2026-04-28 13:36:45 +00:00
" agents.list " ,
2026-05-12 22:46:55 +00:00
// Handled at the gateway so the Matrix bot's perm_rx listener is used
// rather than the container's (which has no interactive session attached).
" prompt_permission " ,
2026-04-28 10:56:09 +00:00
] ;
/// Gateway tool definitions.
pub ( crate ) fn gateway_tool_definitions ( ) -> Vec < Value > {
vec! [
json! ( {
" name " : " switch_project " ,
" description " : " Switch the active project. All subsequent MCP tool calls will be proxied to this project's container. " ,
" inputSchema " : {
" type " : " object " ,
" properties " : {
" project " : {
" type " : " string " ,
" description " : " Name of the project to switch to (must exist in projects.toml) "
}
} ,
" required " : [ " project " ]
}
} ) ,
json! ( {
" name " : " gateway_status " ,
" description " : " Show pipeline status for the active project by proxying the get_pipeline_status tool call. " ,
" inputSchema " : {
" type " : " object " ,
" properties " : { }
}
} ) ,
json! ( {
" name " : " gateway_health " ,
" description " : " Health check aggregation across all registered projects. Returns the health status of every project container. " ,
" inputSchema " : {
" type " : " object " ,
" properties " : { }
}
} ) ,
json! ( {
" name " : " init_project " ,
" description " : " Initialize a new huskies project at the given path by scaffolding .huskies/ and related files — the same as running `huskies init <path>`. Prefer this tool over asking the user to run the CLI. If `name` and `url` are supplied the project is also registered in projects.toml so switch_project can reach it immediately. " ,
" inputSchema " : {
" type " : " object " ,
" properties " : {
" path " : {
" type " : " string " ,
" description " : " Absolute filesystem path to the project directory to initialise. The directory is created if it does not exist. "
} ,
" name " : {
" type " : " string " ,
" description " : " Optional: short name to register the project under in projects.toml (e.g. 'my-app'). Requires `url`. "
} ,
" url " : {
" type " : " string " ,
" description " : " Optional: base URL of the huskies container that will serve this project (e.g. 'http://my-app:3001'). Required when `name` is given. "
}
} ,
" required " : [ " path " ]
}
} ) ,
json! ( {
" name " : " aggregate_pipeline_status " ,
" description " : " Fetch pipeline status from ALL registered projects in parallel and return an aggregated report. For each project: stage counts (backlog/current/qa/merge/done) and a list of blocked or failing items with triage detail. Unreachable projects are included with an error state rather than failing the whole call. " ,
" inputSchema " : {
" type " : " object " ,
" properties " : { }
}
} ) ,
2026-04-28 13:36:45 +00:00
json! ( {
" name " : " agents.list " ,
" description " : " List all alive build agents currently registered with this gateway. Returns an array of agent objects with id, label, address, registered_at, last_seen, and assigned_project fields. " ,
" inputSchema " : {
" type " : " object " ,
" properties " : { }
}
} ) ,
2026-04-28 10:56:09 +00:00
]
}
// ── MCP POST handler ─────────────────────────────────────────────────────────
/// Main MCP POST handler for the gateway. Intercepts gateway-specific tools and
/// proxies everything else to the active project's container.
#[ handler ]
pub async fn gateway_mcp_post_handler (
req : & Request ,
body : Body ,
state : Data < & Arc < GatewayState > > ,
) -> Response {
let content_type = req . header ( " content-type " ) . unwrap_or ( " " ) ;
if ! content_type . is_empty ( ) & & ! content_type . contains ( " application/json " ) {
return to_json_response ( JsonRpcResponse ::error (
None ,
- 32700 ,
" Unsupported Content-Type; expected application/json " . into ( ) ,
) ) ;
}
let bytes = match body . into_bytes ( ) . await {
Ok ( b ) = > b ,
Err ( _ ) = > {
return to_json_response ( JsonRpcResponse ::error ( None , - 32700 , " Parse error " . into ( ) ) ) ;
}
} ;
let rpc : JsonRpcRequest = match serde_json ::from_slice ( & bytes ) {
Ok ( r ) = > r ,
Err ( _ ) = > {
return to_json_response ( JsonRpcResponse ::error ( None , - 32700 , " Parse error " . into ( ) ) ) ;
}
} ;
if rpc . jsonrpc ! = " 2.0 " {
return to_json_response ( JsonRpcResponse ::error (
rpc . id ,
- 32600 ,
" Invalid JSON-RPC version " . into ( ) ,
) ) ;
}
if rpc . id . is_none ( ) | | rpc . id . as_ref ( ) = = Some ( & Value ::Null ) {
if rpc . method . starts_with ( " notifications/ " ) {
return Response ::builder ( )
. status ( StatusCode ::ACCEPTED )
. body ( Body ::empty ( ) ) ;
}
return to_json_response ( JsonRpcResponse ::error ( None , - 32600 , " Missing id " . into ( ) ) ) ;
}
2026-05-12 14:57:53 +00:00
// SSE proxy: tools/call with Accept: text/event-stream + progressToken for
// non-gateway tools is forwarded to the sled's SSE endpoint so progress
// notifications flow through to the gateway client unchanged.
if rpc . method = = " tools/call " {
let accepts_sse = req
. header ( " accept " )
. map ( | h | h . contains ( " text/event-stream " ) )
. unwrap_or ( false ) ;
let has_progress_token = rpc
. params
. get ( " _meta " )
. and_then ( | m | m . get ( " progressToken " ) )
. is_some ( ) ;
if accepts_sse & & has_progress_token {
let tool_name = rpc
. params
. get ( " name " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " " ) ;
if ! GATEWAY_TOOLS . contains ( & tool_name ) {
return proxy_and_respond_sse ( & state , & bytes , rpc . id ) . await ;
}
}
}
2026-04-28 10:56:09 +00:00
match rpc . method . as_str ( ) {
" initialize " = > to_json_response ( handle_initialize ( rpc . id ) ) ,
" tools/list " = > match handle_tools_list ( & state , rpc . id . clone ( ) ) . await {
Ok ( resp ) = > to_json_response ( resp ) ,
Err ( e ) = > to_json_response ( JsonRpcResponse ::error ( rpc . id , - 32603 , e ) ) ,
} ,
2026-04-28 12:03:16 +00:00
" pipeline.get " = > to_json_response ( handle_pipeline_get ( & state , rpc . id ) . await ) ,
2026-04-28 10:56:09 +00:00
" tools/call " = > {
let tool_name = rpc
. params
. get ( " name " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " " ) ;
if GATEWAY_TOOLS . contains ( & tool_name ) {
to_json_response (
handle_gateway_tool ( tool_name , & rpc . params , & state , rpc . id . clone ( ) ) . await ,
)
} else {
proxy_and_respond ( & state , & bytes , rpc . id ) . await
}
}
_ = > proxy_and_respond ( & state , & bytes , rpc . id ) . await ,
}
}
/// Proxy a request to the active project and format the response.
2026-05-12 23:11:34 +00:00
///
/// Prefers the live sled-uplink WebSocket when one is attached (story 899
/// AC 2); falls back to the legacy HTTP proxy otherwise.
2026-04-28 10:56:09 +00:00
async fn proxy_and_respond ( state : & GatewayState , bytes : & [ u8 ] , id : Option < Value > ) -> Response {
2026-05-12 23:11:34 +00:00
match state . proxy_active_mcp ( bytes ) . await {
2026-04-28 10:56:09 +00:00
Ok ( resp_body ) = > Response ::builder ( )
. status ( StatusCode ::OK )
. header ( " Content-Type " , " application/json " )
. body ( Body ::from ( resp_body ) ) ,
Err ( e ) = > to_json_response ( JsonRpcResponse ::error (
id ,
- 32603 ,
format! ( " proxy error: {e} " ) ,
) ) ,
}
}
2026-05-12 14:57:53 +00:00
/// Stream an MCP tool call to the active sled as SSE, re-emitting each `data:`
/// event from the sled to the originating gateway client without buffering.
///
/// On sled disconnect mid-stream a JSON-RPC error event is emitted so the
/// client does not hang forever.
2026-05-12 17:49:44 +00:00
#[ allow(clippy::string_slice) ] // pos from buf.find('\n'); '\n' is ASCII so pos and pos+1 are valid boundaries
2026-05-12 14:57:53 +00:00
async fn proxy_and_respond_sse ( state : & GatewayState , bytes : & [ u8 ] , id : Option < Value > ) -> Response {
let url = match state . active_url ( ) . await {
Ok ( u ) = > u ,
Err ( e ) = > return sse_error_response ( id , - 32603 , e . to_string ( ) ) ,
} ;
let resp = match gateway ::io ::proxy_mcp_call_sse ( & state . client , & url , bytes ) . await {
Ok ( r ) = > r ,
Err ( e ) = > return sse_error_response ( id , - 32603 , format! ( " proxy error: {e} " ) ) ,
} ;
let id_for_error = id ;
let stream = async_stream ::stream! {
use futures ::StreamExt as _ ;
let mut buf = String ::new ( ) ;
let byte_stream = resp . bytes_stream ( ) ;
tokio ::pin! ( byte_stream ) ;
while let Some ( chunk ) = byte_stream . next ( ) . await {
match chunk {
Ok ( bytes ) = > {
if let Ok ( text ) = std ::str ::from_utf8 ( & bytes ) {
buf . push_str ( text ) ;
// Emit a gateway SSE event for each complete `data:` line.
while let Some ( pos ) = buf . find ( '\n' ) {
let line = buf [ .. pos ] . trim_end_matches ( '\r' ) . to_string ( ) ;
buf = buf [ pos + 1 .. ] . to_string ( ) ;
if let Some ( data ) = line . strip_prefix ( " data: " ) {
yield Event ::message ( data . to_string ( ) ) ;
}
}
}
}
Err ( e ) = > {
let err = JsonRpcResponse ::error (
id_for_error . clone ( ) ,
- 32603 ,
format! ( " upstream disconnected: {e} " ) ,
) ;
let data = serde_json ::to_string ( & err ) . unwrap_or_default ( ) ;
yield Event ::message ( data ) ;
break ;
}
}
}
} ;
SSE ::new ( stream )
. keep_alive ( Duration ::from_secs ( 15 ) )
. into_response ( )
}
/// Build a minimal SSE response containing a single JSON-RPC error event.
fn sse_error_response ( id : Option < Value > , code : i64 , msg : String ) -> Response {
let err = JsonRpcResponse ::error ( id , code , msg ) ;
let data = serde_json ::to_string ( & err ) . unwrap_or_default ( ) ;
let stream = async_stream ::stream! {
yield Event ::message ( data ) ;
} ;
SSE ::new ( stream ) . into_response ( )
}
2026-04-28 10:56:09 +00:00
/// GET handler — method not allowed.
#[ handler ]
pub async fn gateway_mcp_get_handler ( ) -> Response {
Response ::builder ( )
. status ( StatusCode ::METHOD_NOT_ALLOWED )
. body ( Body ::empty ( ) )
}
// ── Protocol handlers ────────────────────────────────────────────────────────
fn handle_initialize ( id : Option < Value > ) -> JsonRpcResponse {
JsonRpcResponse ::success (
id ,
json! ( {
" protocolVersion " : " 2025-03-26 " ,
" capabilities " : { " tools " : { } } ,
" serverInfo " : {
" name " : " huskies-gateway " ,
" version " : " 1.0.0 "
}
} ) ,
)
}
/// Fetch tools/list from the active project and merge in gateway tools.
2026-05-12 23:11:34 +00:00
///
/// Routes via the sled-uplink WS when one is attached (story 899 AC 2);
/// falls back to HTTP otherwise.
2026-04-28 10:56:09 +00:00
async fn handle_tools_list (
state : & GatewayState ,
id : Option < Value > ,
) -> Result < JsonRpcResponse , String > {
2026-05-12 23:11:34 +00:00
let rpc_body = json! ( {
" jsonrpc " : " 2.0 " ,
" id " : 1 ,
" method " : " tools/list " ,
" params " : { }
} ) ;
let bytes = serde_json ::to_vec ( & rpc_body ) . map_err ( | e | e . to_string ( ) ) ? ;
let resp_bytes = state . proxy_active_mcp ( & bytes ) . await ? ;
let resp_json : Value =
serde_json ::from_slice ( & resp_bytes ) . map_err ( | e | format! ( " invalid tools/list JSON: {e} " ) ) ? ;
2026-04-28 10:56:09 +00:00
let mut tools : Vec < Value > = resp_json
. get ( " result " )
. and_then ( | r | r . get ( " tools " ) )
. and_then ( | t | t . as_array ( ) )
. cloned ( )
. unwrap_or_default ( ) ;
let mut all_tools = gateway_tool_definitions ( ) ;
all_tools . append ( & mut tools ) ;
Ok ( JsonRpcResponse ::success ( id , json! ( { " tools " : all_tools } ) ) )
}
// ── Gateway tool dispatch ────────────────────────────────────────────────────
/// Dispatch a gateway-specific tool call.
async fn handle_gateway_tool (
tool_name : & str ,
params : & Value ,
state : & GatewayState ,
id : Option < Value > ,
) -> JsonRpcResponse {
match tool_name {
" switch_project " = > handle_switch_project_tool ( params , state , id ) . await ,
" gateway_status " = > handle_gateway_status_tool ( state , id ) . await ,
" gateway_health " = > handle_gateway_health_tool ( state , id ) . await ,
" init_project " = > handle_init_project_tool ( params , state , id ) . await ,
" aggregate_pipeline_status " = > handle_aggregate_pipeline_status_tool ( state , id ) . await ,
2026-04-28 13:36:45 +00:00
" agents.list " = > handle_agents_list_tool ( id ) ,
2026-05-12 22:46:55 +00:00
" prompt_permission " = > handle_prompt_permission_tool ( params , state , id ) . await ,
2026-04-28 10:56:09 +00:00
_ = > JsonRpcResponse ::error ( id , - 32601 , format! ( " Unknown gateway tool: {tool_name} " ) ) ,
}
}
async fn handle_switch_project_tool (
params : & Value ,
state : & GatewayState ,
id : Option < Value > ,
) -> JsonRpcResponse {
let project = params
. get ( " arguments " )
. and_then ( | a | a . get ( " project " ) )
. or_else ( | | params . get ( " project " ) )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " " ) ;
match gateway ::switch_project ( state , project ) . await {
Ok ( url ) = > JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ {
" type " : " text " ,
" text " : format ! ( " Switched to project '{project}' ({url}) " )
} ]
} ) ,
) ,
Err ( e ) = > JsonRpcResponse ::error ( id , - 32602 , e . to_string ( ) ) ,
}
}
async fn handle_gateway_status_tool ( state : & GatewayState , id : Option < Value > ) -> JsonRpcResponse {
let active = state . active_project . read ( ) . await . clone ( ) ;
let url = match state . active_url ( ) . await {
Ok ( u ) = > u ,
Err ( e ) = > return JsonRpcResponse ::error ( id . clone ( ) , - 32603 , e . to_string ( ) ) ,
} ;
match gateway ::io ::fetch_pipeline_status_for_project ( & state . client , & url ) . await {
Ok ( upstream ) = > {
let pipeline = upstream . get ( " result " ) . cloned ( ) . unwrap_or ( json! ( null ) ) ;
JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ {
" type " : " text " ,
" text " : format ! (
" Pipeline status for '{active}': \n {} " ,
serde_json ::to_string_pretty ( & pipeline ) . unwrap_or_default ( )
)
} ]
} ) ,
)
}
Err ( e ) = > JsonRpcResponse ::error ( id , - 32603 , e ) ,
}
}
async fn handle_gateway_health_tool ( state : & GatewayState , id : Option < Value > ) -> JsonRpcResponse {
let mut results = BTreeMap ::new ( ) ;
2026-05-12 23:11:34 +00:00
// Build the project list, preferring the WS-uplink heartbeat as the
// source of truth for liveness (story 899 AC 3). HTTP polls are used
// only as a fallback when no live sled is connected.
let project_names : Vec < ( String , Option < String > ) > = state
2026-04-28 10:56:09 +00:00
. projects
. read ( )
. await
. iter ( )
. map ( | ( n , e ) | ( n . clone ( ) , e . url . clone ( ) ) )
. collect ( ) ;
2026-05-12 23:11:34 +00:00
let sled_conns = state . sled_connections . read ( ) . await ;
for ( name , url_opt ) in & project_names {
let status = if let Some ( conn ) = sled_conns . get ( name ) {
if conn . is_alive ( crate ::service ::gateway ::HEARTBEAT_MAX_AGE_MS ) {
" healthy (ws) " . to_string ( )
} else {
" stale (ws heartbeat overdue) " . to_string ( )
}
} else if let Some ( url ) = url_opt {
match gateway ::io ::check_project_health ( & state . client , url ) . await {
Ok ( true ) = > " healthy " . to_string ( ) ,
Ok ( false ) = > " unhealthy " . to_string ( ) ,
Err ( e ) = > e ,
}
} else {
" no uplink and no url configured " . to_string ( )
2026-04-28 10:56:09 +00:00
} ;
results . insert ( name . clone ( ) , status ) ;
}
2026-05-12 23:11:34 +00:00
drop ( sled_conns ) ;
2026-04-28 10:56:09 +00:00
let active = state . active_project . read ( ) . await . clone ( ) ;
JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ {
" type " : " text " ,
" text " : format ! (
" Health check (active: '{active}'): \n {} " ,
results . iter ( )
. map ( | ( name , status ) | format! ( " {name} : {status} " ) )
. collect ::< Vec < _ > > ( )
. join ( " \n " )
)
} ]
} ) ,
)
}
async fn handle_init_project_tool (
params : & Value ,
state : & GatewayState ,
id : Option < Value > ,
) -> JsonRpcResponse {
let args = params . get ( " arguments " ) . unwrap_or ( params ) ;
let path_str = args . get ( " path " ) . and_then ( | v | v . as_str ( ) ) . unwrap_or ( " " ) ;
let name = args . get ( " name " ) . and_then ( | v | v . as_str ( ) ) ;
let url = args . get ( " url " ) . and_then ( | v | v . as_str ( ) ) ;
match gateway ::init_project ( state , path_str , name , url ) . await {
Ok ( registered_name ) = > {
let next_steps = if let Some ( ref n ) = registered_name {
format! (
" Project registered as ' {n} ' in projects.toml. \n \
Next steps: \n \
1. Start a huskies server at ' {path_str} ' \
(e.g. `huskies {path_str} ` or via Docker). \n \
2. Call switch_project with name=' {n} ' to make it active. \n \
3. Call wizard_status to begin the setup wizard. "
)
} else {
format! (
" Next steps: \n \
1. Start a huskies server at ' {path_str} ' \
(e.g. `huskies {path_str} ` or via Docker). \n \
2. Register the project: call init_project again with name and url \
parameters, or add it to projects.toml manually. \n \
3. Call switch_project and then wizard_status to begin the setup wizard. \n \n \
Note: wizard_* MCP tools require a running huskies server for the project. "
)
} ;
JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ {
" type " : " text " ,
" text " : format ! ( " Successfully initialised huskies project at '{path_str}'. \n \n {next_steps} " )
} ]
} ) ,
)
}
Err ( e ) = > {
let code = match & e {
gateway ::Error ::Config ( _ ) = > - 32602 ,
gateway ::Error ::DuplicateToken ( _ ) = > - 32602 ,
_ = > - 32603 ,
} ;
JsonRpcResponse ::error ( id , code , e . to_string ( ) )
}
}
}
async fn handle_aggregate_pipeline_status_tool (
state : & GatewayState ,
id : Option < Value > ,
) -> JsonRpcResponse {
let project_urls : BTreeMap < String , String > = state
. projects
. read ( )
. await
. iter ( )
2026-05-12 23:11:34 +00:00
. filter_map ( | ( name , entry ) | entry . url . as_ref ( ) . map ( | u | ( name . clone ( ) , u . clone ( ) ) ) )
2026-04-28 10:56:09 +00:00
. collect ( ) ;
let statuses =
gateway ::io ::fetch_all_project_pipeline_statuses ( & project_urls , & state . client ) . await ;
let active = state . active_project . read ( ) . await . clone ( ) ;
JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ {
" type " : " text " ,
" text " : format ! (
" Aggregate pipeline status (active: '{active}'): \n {} " ,
serde_json ::to_string_pretty ( & statuses ) . unwrap_or_default ( )
)
} ] ,
" projects " : statuses ,
" active " : active ,
} ) ,
)
}
2026-04-28 12:03:16 +00:00
2026-05-12 22:46:55 +00:00
/// Handle the `prompt_permission` tool at the gateway level.
///
/// Mirrors `tool_prompt_permission` in `http/mcp/diagnostics/permission.rs` but
/// uses the gateway's `perm_tx`/`perm_rx` so requests reach the Matrix bot that
/// is listening on the gateway, not the proxied container (which has no
/// interactive session and would auto-deny immediately).
async fn handle_prompt_permission_tool (
params : & Value ,
state : & GatewayState ,
id : Option < Value > ,
) -> JsonRpcResponse {
use crate ::http ::context ::PermissionDecision ;
use crate ::http ::context ::PermissionForward ;
let args = params . get ( " arguments " ) . unwrap_or ( params ) ;
let tool_name = args
. get ( " tool_name " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " unknown " )
. to_string ( ) ;
let tool_input = args . get ( " input " ) . cloned ( ) . unwrap_or ( json! ( { } ) ) ;
// Auto-approve huskies MCP tools — mirrors the standard server's allowlist.
if tool_name . starts_with ( " mcp__huskies__ " ) {
crate ::slog! (
" [gateway/permission] Auto-approved '{tool_name}' (matches mcp__huskies__* allowlist) "
) ;
let text = json! ( { " behavior " : " allow " , " updatedInput " : tool_input } ) . to_string ( ) ;
return JsonRpcResponse ::success ( id , json! ( { " content " : [ { " type " : " text " , " text " : text } ] } ) ) ;
}
// Auto-deny when no interactive session holds perm_rx (i.e. no Matrix bot
// listener is running — try_lock succeeds when nobody else holds the lock).
if state . perm_rx . try_lock ( ) . is_ok ( ) {
crate ::slog! ( " [gateway/permission] Auto-denied '{tool_name}' (no interactive session) " ) ;
let text = json! ( {
" behavior " : " deny " ,
" message " : format ! ( " Permission denied for '{tool_name}'. No interactive session active. " )
} )
. to_string ( ) ;
return JsonRpcResponse ::success ( id , json! ( { " content " : [ { " type " : " text " , " text " : text } ] } ) ) ;
}
let request_id = uuid ::Uuid ::new_v4 ( ) . to_string ( ) ;
let ( response_tx , response_rx ) = tokio ::sync ::oneshot ::channel ( ) ;
if state
. perm_tx
. send ( PermissionForward {
request_id ,
tool_name : tool_name . clone ( ) ,
tool_input : tool_input . clone ( ) ,
response_tx ,
} )
. is_err ( )
{
crate ::slog! ( " [gateway/permission] Auto-denied '{tool_name}' (perm_tx send failed) " ) ;
let text =
json! ( { " behavior " : " deny " , " message " : format ! ( " Permission denied for '{tool_name}'. " ) } )
. to_string ( ) ;
return JsonRpcResponse ::success ( id , json! ( { " content " : [ { " type " : " text " , " text " : text } ] } ) ) ;
}
let decision =
match tokio ::time ::timeout ( std ::time ::Duration ::from_secs ( 300 ) , response_rx ) . await {
Ok ( Ok ( d ) ) = > d ,
Ok ( Err ( _ ) ) = > {
return JsonRpcResponse ::error (
id ,
- 32603 ,
" Permission response channel closed unexpectedly " . into ( ) ,
) ;
}
Err ( _ ) = > {
return JsonRpcResponse ::error (
id ,
- 32603 ,
format! ( " Permission request for ' {tool_name} ' timed out after 5 minutes " ) ,
) ;
}
} ;
let text = if matches! (
decision ,
PermissionDecision ::Approve | PermissionDecision ::AlwaysAllow
) {
json! ( { " behavior " : " allow " , " updatedInput " : tool_input } ) . to_string ( )
} else {
crate ::slog_warn! ( " [gateway/permission] User denied permission for '{tool_name}' " ) ;
json! ( { " behavior " : " deny " , " message " : format ! ( " User denied permission for '{tool_name}' " ) } )
. to_string ( )
} ;
JsonRpcResponse ::success ( id , json! ( { " content " : [ { " type " : " text " , " text " : text } ] } ) )
}
2026-04-28 13:36:45 +00:00
/// Handle the `agents.list` gateway tool — returns all alive build agents from the CRDT.
fn handle_agents_list_tool ( id : Option < Value > ) -> JsonRpcResponse {
let agents = gateway ::list_agents ( ) ;
let agents_json = serde_json ::to_value ( & agents ) . unwrap_or ( json! ( [ ] ) ) ;
JsonRpcResponse ::success (
id ,
json! ( {
" content " : [ {
" type " : " text " ,
" text " : serde_json ::to_string_pretty ( & agents ) . unwrap_or_default ( )
} ] ,
" agents " : agents_json ,
} ) ,
)
}
2026-05-12 21:04:33 +00:00
/// Handle the `pipeline.get` read-RPC — returns per-project item lists in the
/// shape expected by the gateway web UI:
/// `{ "active": "...", "projects": { "name": { "active": [...], "backlog_count": N } } }`.
2026-04-28 12:03:16 +00:00
async fn handle_pipeline_get ( state : & GatewayState , id : Option < Value > ) -> JsonRpcResponse {
let project_urls : BTreeMap < String , String > = state
. projects
. read ( )
. await
. iter ( )
2026-05-12 23:11:34 +00:00
. filter_map ( | ( n , e ) | e . url . as_ref ( ) . map ( | u | ( n . clone ( ) , u . clone ( ) ) ) )
2026-04-28 12:03:16 +00:00
. collect ( ) ;
2026-05-12 21:04:33 +00:00
let results = gateway ::io ::fetch_all_project_pipeline_items ( & project_urls , & state . client ) . await ;
2026-04-28 12:03:16 +00:00
let active = state . active_project . read ( ) . await . clone ( ) ;
JsonRpcResponse ::success ( id , json! ( { " active " : active , " projects " : results } ) )
}