1use std::collections::HashMap;
19use std::sync::Arc;
20
21use crate::Error;
22use crate::control::{
23 AllowBehavior, ControlRequest, ControlRequestKind, ControlResponse, ControlResponseKind,
24 ControlResponseType,
25};
26use crate::hooks::callback::ErasedHookCallback;
27use crate::hooks::{HookContext, HookDecision, HookKind};
28use crate::mcp::orchestration::{Dispatched, McpHosts};
29use crate::mcp::protocol::JsonRpcRequest;
30use crate::permissions::CanUseToolCallback;
31use crate::transport::process::SharedWriter;
32use forge_primitives::hooks::outputs::encode_updated_input_wrapper;
33use forge_primitives::{PermissionDecision, ToolPermissionContext};
34
35#[derive(Clone)]
54pub(crate) struct ControlDispatchHandle {
55 writer: Arc<SharedWriter>,
56 can_use_tool: Option<Arc<dyn CanUseToolCallback>>,
57 auto_approve_tool: Option<crate::options::AutoApproveToolPredicate>,
58 mcp_hosts: McpHosts,
59 hook_callbacks: HashMap<String, Arc<dyn ErasedHookCallback>>,
60 session_id: crate::client::runtime::SharedSessionId,
61}
62
63impl std::fmt::Debug for ControlDispatchHandle {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct("ControlDispatchHandle")
66 .field("writer", &self.writer)
67 .field("can_use_tool", &self.can_use_tool.as_ref().map(|_| "<callback>"))
68 .field("auto_approve_tool", &self.auto_approve_tool.as_ref().map(|_| "<predicate>"))
69 .field("mcp_hosts", &self.mcp_hosts)
70 .field("hook_callbacks", &format!("<{} hooks>", self.hook_callbacks.len()))
71 .field("session_id", &"<shared>")
72 .finish()
73 }
74}
75
76fn control_subtype(kind: &ControlRequestKind) -> std::borrow::Cow<'_, str> {
83 match kind {
84 ControlRequestKind::CanUseTool { .. } => std::borrow::Cow::Borrowed("can_use_tool"),
85 ControlRequestKind::McpMessage { .. } => std::borrow::Cow::Borrowed("mcp_message"),
86 ControlRequestKind::HookCallback { .. } => std::borrow::Cow::Borrowed("hook_callback"),
87 ControlRequestKind::Unknown { subtype, .. } => std::borrow::Cow::Borrowed(subtype.as_str()),
88 }
89}
90
91impl ControlDispatchHandle {
92 pub(crate) fn new(
93 writer: Arc<SharedWriter>,
94 can_use_tool: Option<Arc<dyn CanUseToolCallback>>,
95 auto_approve_tool: Option<crate::options::AutoApproveToolPredicate>,
96 mcp_hosts: McpHosts,
97 hook_callbacks: HashMap<String, Arc<dyn ErasedHookCallback>>,
98 session_id: crate::client::runtime::SharedSessionId,
99 ) -> Self {
100 Self { writer, can_use_tool, auto_approve_tool, mcp_hosts, hook_callbacks, session_id }
101 }
102
103 pub(crate) fn capture_session_id_from(&self, msg: &forge_primitives::Message) {
107 if let Some(id) = msg.session_id()
108 && !id.is_empty()
109 {
110 let mut current = self.session_id.write();
111 if current.is_empty() {
112 *current = id.to_string();
113 tracing::debug!(session_id = %*current, "client session_id bound");
114 }
115 }
116 }
117
118 pub(crate) async fn dispatch(&self, req: ControlRequest) -> Result<(), Error> {
134 let request_id = req.request_id.clone();
135 let subtype = control_subtype(&req.request).into_owned();
138 let result = self.dispatch_inner(req).await;
139 if let Err(err) = &result {
140 tracing::warn!(
145 target: crate::logging::targets::BRIDGE_LIFECYCLE,
146 event_name = "control_dispatch_failed",
147 session_id = %self.session_id.read(),
148 %request_id,
149 subtype = %subtype,
150 error = %err,
151 "a control request failed; the error response is the CLI's only answer",
152 );
153 self.write_error_response(&request_id, &err.to_string()).await;
154 }
155 result
156 }
157
158 pub(crate) async fn write_error_response(&self, request_id: &str, error: &str) {
162 let resp = ControlResponse {
163 ty: ControlResponseType::ControlResponse,
164 response: ControlResponseKind::Error {
165 request_id: request_id.to_string(),
166 error: error.to_string(),
167 },
168 };
169 match serde_json::to_string(&resp) {
170 Ok(mut line) => {
171 line.push('\n');
172 if let Err(write_err) = self.writer.write_line(&line).await {
173 tracing::warn!(
177 target: crate::logging::targets::BRIDGE_LIFECYCLE,
178 event_name = "control_error_response_failed",
179 session_id = %self.session_id.read(),
180 %request_id,
181 error = %write_err,
182 "the error control_response could not be written; the CLI is waiting on it",
183 );
184 }
185 }
186 Err(e) => {
187 tracing::debug!(error = %e, "error control_response encode failed");
188 }
189 }
190 }
191
192 async fn dispatch_inner(&self, req: ControlRequest) -> Result<(), Error> {
193 let original_input = req.original_tool_input().cloned();
194
195 if let ControlRequestKind::Unknown { subtype, raw } = &req.request {
196 tracing::warn!(
197 %subtype,
198 raw = %raw,
199 request_id = %req.request_id,
200 "unknown control_request subtype - responding with error, session continues"
201 );
202 return self.write_unsupported(&req).await;
203 }
204
205 if let ControlRequestKind::McpMessage { server_name, message } = &req.request {
206 return self.handle_mcp(&req, server_name, message).await;
207 }
208
209 if let ControlRequestKind::HookCallback { callback_id, input, tool_use_id } = &req.request {
210 return self.handle_hook(&req, callback_id, input, tool_use_id.as_deref()).await;
211 }
212
213 let decision = match (&self.can_use_tool, &req.request) {
214 (
215 Some(cb),
216 ControlRequestKind::CanUseTool {
217 tool_name,
218 input,
219 tool_use_id,
220 agent_id,
221 permission_suggestions,
222 blocked_path,
223 decision_reason,
224 title,
225 display_name,
226 description,
227 },
228 ) => {
229 if self.auto_approve_tool.as_ref().is_some_and(|p| p(tool_name)) {
238 tracing::trace!(
239 target: "forge_sdk::control_dispatch",
240 tool_name = %tool_name,
241 "auto-approving via Options::auto_approve_tool predicate"
242 );
243 PermissionDecision::allow()
244 } else {
245 let suggestions: Vec<forge_primitives::PermissionUpdate> =
246 permission_suggestions
247 .iter()
248 .filter_map(|v| match serde_json::from_value(v.clone()) {
249 Ok(s) => Some(s),
250 Err(e) => {
251 tracing::warn!(
252 error = %e,
253 raw = %v,
254 "permission_suggestion failed to decode; dropping (CLI schema drift?)"
255 );
256 None
257 }
258 })
259 .collect();
260 let ctx = ToolPermissionContext::new(
261 tool_name.clone(),
262 input.clone(),
263 tool_use_id.clone(),
264 agent_id.clone(),
265 )
266 .with_suggestions(suggestions)
267 .with_display(
268 blocked_path.clone(),
269 decision_reason.clone(),
270 title.clone(),
271 display_name.clone(),
272 description.clone(),
273 );
274 cb.call(ctx).await
275 }
276 }
277 (None, ControlRequestKind::CanUseTool { .. }) => {
278 PermissionDecision::deny("no permission callback registered")
279 }
280 _ => {
281 return self.write_unsupported(&req).await;
282 }
283 };
284
285 let behavior = if decision.is_allow() {
286 let perms = decision.updated_permissions();
287 let updated_permissions = if perms.is_empty() {
288 None
289 } else {
290 Some(
291 serde_json::to_value(perms)
292 .map_err(|e| Error::encode("updated_permissions", e))?,
293 )
294 };
295 AllowBehavior::Allow {
296 updated_input: decision
297 .updated_input()
298 .cloned()
299 .or(original_input)
300 .unwrap_or(serde_json::Value::Null),
301 updated_permissions,
302 }
303 } else {
304 AllowBehavior::Deny {
305 message: decision.reason().unwrap_or("denied").to_string(),
306 interrupt: false,
307 }
308 };
309
310 let resp = req
311 .build_response(behavior)
312 .map_err(|e| Error::message_parse(format!("could not build control response: {e}")))?;
313 let mut line = serde_json::to_string(&resp).map_err(|e| {
314 Error::message_parse(format!("could not serialise control response: {e}"))
315 })?;
316 line.push('\n');
317 self.writer.write_line(&line).await
318 }
319
320 async fn write_unsupported(&self, req: &ControlRequest) -> Result<(), Error> {
321 let resp = ControlResponse {
322 ty: ControlResponseType::ControlResponse,
323 response: ControlResponseKind::Error {
324 request_id: req.request_id.clone(),
325 error: "unsupported control-request subtype".into(),
326 },
327 };
328 let mut line = serde_json::to_string(&resp)
329 .map_err(|e| Error::message_parse(format!("error response serialise: {e}")))?;
330 line.push('\n');
331 self.writer.write_line(&line).await
332 }
333
334 async fn handle_mcp(
335 &self,
336 req: &ControlRequest,
337 server_name: &str,
338 message: &serde_json::Value,
339 ) -> Result<(), Error> {
340 let jsonrpc: JsonRpcRequest = serde_json::from_value(message.clone())
341 .map_err(|e| Error::message_parse(format!("bad JSON-RPC envelope: {e}")))?;
342
343 let jsonrpc_response = match self.mcp_hosts.dispatch(server_name, &jsonrpc).await {
344 Dispatched::Response(r) => serde_json::to_value(&r)
345 .map_err(|e| Error::message_parse(format!("mcp response serialise: {e}")))?,
346 Dispatched::Notification => serde_json::json!({"jsonrpc": "2.0", "result": {}}),
347 Dispatched::UnknownServer => {
348 let resp = ControlResponse {
349 ty: ControlResponseType::ControlResponse,
350 response: ControlResponseKind::Error {
351 request_id: req.request_id.clone(),
352 error: format!("unknown MCP server: {server_name}"),
353 },
354 };
355 let mut line = serde_json::to_string(&resp)
356 .map_err(|e| Error::message_parse(format!("error response serialise: {e}")))?;
357 line.push('\n');
358 return self.writer.write_line(&line).await;
359 }
360 };
361
362 let wrapper = serde_json::json!({"mcp_response": jsonrpc_response});
363 let resp = ControlResponse {
364 ty: ControlResponseType::ControlResponse,
365 response: ControlResponseKind::Success {
366 request_id: req.request_id.clone(),
367 response: wrapper,
368 },
369 };
370 let mut line = serde_json::to_string(&resp)
371 .map_err(|e| Error::message_parse(format!("mcp control response serialise: {e}")))?;
372 line.push('\n');
373 self.writer.write_line(&line).await
374 }
375
376 async fn handle_hook(
377 &self,
378 req: &ControlRequest,
379 callback_id: &str,
380 input: &serde_json::Value,
381 tool_use_id: Option<&str>,
382 ) -> Result<(), Error> {
383 let received_at = std::time::Instant::now();
384 let event_name = input
385 .get("hook_event_name")
386 .and_then(serde_json::Value::as_str)
387 .unwrap_or("Unknown")
388 .to_string();
389 let kind = HookKind::from_wire(&event_name);
390 let session_id = self.session_id.read().clone();
391
392 tracing::debug!(
397 target: crate::logging::targets::BRIDGE_LIFECYCLE,
398 session_id = %session_id,
399 hook = %event_name,
400 %callback_id,
401 request_id = %req.request_id,
402 "hook dispatch-received",
403 );
404
405 let ctx = HookContext {
406 kind,
407 tool_name: input
408 .get("tool_name")
409 .and_then(serde_json::Value::as_str)
410 .map(str::to_string),
411 session_id,
412 tool_use_id: tool_use_id.map(str::to_string),
413 };
414
415 let decision = if let Some(cb) = self.hook_callbacks.get(callback_id) {
416 let decision = cb.call_erased(input.clone(), ctx).await;
417 tracing::debug!(
418 target: crate::logging::targets::BRIDGE_LIFECYCLE,
419 session_id = %self.session_id.read(),
420 hook = %event_name,
421 %callback_id,
422 elapsed_ms = u64::try_from(received_at.elapsed().as_millis()).unwrap_or(u64::MAX),
423 "hook callback-done",
424 );
425 decision
426 } else {
427 tracing::warn!(%callback_id, "hook_callback for unknown id; passthrough");
428 HookDecision::passthrough()
429 };
430
431 let ctrl = if decision.is_deferred() {
432 let mut defer_body = serde_json::Map::new();
433 defer_body.insert("async".into(), serde_json::Value::Bool(true));
434 if let Some(timeout) = decision.defer_timeout_ms() {
435 defer_body.insert("asyncTimeout".into(), serde_json::json!(timeout));
436 }
437 ControlResponse {
438 ty: ControlResponseType::ControlResponse,
439 response: ControlResponseKind::Success {
440 request_id: req.request_id.clone(),
441 response: serde_json::Value::Object(defer_body),
442 },
443 }
444 } else {
445 let mut response_body = match (decision.is_allow(), decision.reason()) {
446 (true, _) => serde_json::json!({}),
447 (false, Some(reason)) => serde_json::json!({"decision": "block", "reason": reason}),
448 (false, None) => serde_json::json!({"decision": "block"}),
449 };
450
451 if let Some(updated) = decision.updated_input()
452 && let Some(wrapper) = encode_updated_input_wrapper(kind, updated)
453 && let Some(map) = response_body.as_object_mut()
454 {
455 map.insert("hookSpecificOutput".into(), wrapper);
456 }
457
458 ControlResponse {
459 ty: ControlResponseType::ControlResponse,
460 response: ControlResponseKind::Success {
461 request_id: req.request_id.clone(),
462 response: response_body,
463 },
464 }
465 };
466
467 let encode_label =
468 if decision.is_deferred() { "deferred hook payload" } else { "hook response" };
469 let mut line = serde_json::to_string(&ctrl).map_err(|e| Error::encode(encode_label, e))?;
470 line.push('\n');
471 self.writer.write_line(&line).await?;
472 tracing::debug!(
473 target: crate::logging::targets::BRIDGE_LIFECYCLE,
474 session_id = %self.session_id.read(),
475 hook = %event_name,
476 %callback_id,
477 elapsed_ms = u64::try_from(received_at.elapsed().as_millis()).unwrap_or(u64::MAX),
478 "hook response-written",
479 );
480 Ok(())
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487 use crate::control::ControlRequestType;
488 use crate::mcp::McpServerBuilder;
489
490 #[tokio::test]
495 async fn dispatch_answers_with_error_response_on_handler_failure() {
496 let (writer, mut lines) = SharedWriter::test_stub();
497 let hosts = McpHosts::new(
498 vec![("forge".to_owned(), McpServerBuilder::new("forge", "1.0").build())],
499 HashMap::new(),
500 );
501 let handle = ControlDispatchHandle::new(
502 Arc::new(writer),
503 None,
504 None,
505 hosts,
506 HashMap::new(),
507 Arc::new(parking_lot::RwLock::new(String::new())),
508 );
509
510 let req = ControlRequest {
511 ty: ControlRequestType::ControlRequest,
512 request_id: "req-err-1".to_owned(),
513 request: ControlRequestKind::McpMessage {
514 server_name: "forge".to_owned(),
515 message: serde_json::Value::String("not a jsonrpc envelope".to_owned()),
516 },
517 };
518
519 let result = handle.dispatch(req).await;
520 assert!(result.is_err(), "parse failure still surfaces to the caller");
521
522 let line = lines.recv().await.expect("an error control_response was written");
523 let v: serde_json::Value = serde_json::from_str(&line).expect("valid json line");
524 assert_eq!(v["type"], "control_response");
525 assert_eq!(v["response"]["subtype"], "error");
526 assert_eq!(v["response"]["request_id"], "req-err-1");
527 }
528
529 #[tokio::test]
533 async fn unknown_server_answers_error_not_empty_success() {
534 let (writer, mut lines) = SharedWriter::test_stub();
535 let handle = ControlDispatchHandle::new(
536 Arc::new(writer),
537 None,
538 None,
539 McpHosts::new(Vec::new(), HashMap::new()),
540 HashMap::new(),
541 Arc::new(parking_lot::RwLock::new(String::new())),
542 );
543
544 let req = ControlRequest {
545 ty: ControlRequestType::ControlRequest,
546 request_id: "req-unknown-1".to_owned(),
547 request: ControlRequestKind::McpMessage {
548 server_name: "nope".to_owned(),
549 message: serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}),
550 },
551 };
552 handle.dispatch(req).await.expect("unknown server is answered, not an Err");
553
554 let line = lines.recv().await.expect("a control_response was written");
555 let v: serde_json::Value = serde_json::from_str(&line).expect("valid json line");
556 assert_eq!(v["response"]["subtype"], "error");
557 assert_eq!(v["response"]["request_id"], "req-unknown-1");
558 assert!(v["response"]["response"]["mcp_response"].is_null(), "must not be a success body");
559 }
560
561 #[tokio::test]
566 async fn unknown_subtype_answers_error_and_continues() {
567 let (writer, mut lines) = SharedWriter::test_stub();
568 let handle = ControlDispatchHandle::new(
569 Arc::new(writer),
570 None,
571 None,
572 McpHosts::new(Vec::new(), HashMap::new()),
573 HashMap::new(),
574 Arc::new(parking_lot::RwLock::new(String::new())),
575 );
576
577 let req = ControlRequest {
578 ty: ControlRequestType::ControlRequest,
579 request_id: "req-unknown-subtype-1".to_owned(),
580 request: ControlRequestKind::Unknown {
581 subtype: "hologram_project".to_owned(),
582 raw: serde_json::json!({"subtype": "hologram_project"}),
583 },
584 };
585 handle.dispatch(req).await.expect("unknown subtype is answered, dispatch continues");
586
587 let line = lines.recv().await.expect("an error control_response was written");
588 let v: serde_json::Value = serde_json::from_str(&line).expect("valid json line");
589 assert_eq!(v["type"], "control_response");
590 assert_eq!(v["response"]["subtype"], "error");
591 assert_eq!(v["response"]["request_id"], "req-unknown-subtype-1");
592 assert!(
593 v["response"]["error"].as_str().is_some_and(|e| e.contains("unsupported")),
594 "the error names the unsupported subtype: {v}"
595 );
596 }
597}