1use crate::runtime::VerificationOutcome;
2use crate::runtime::binding::{
3 validate_envelope_freshness, validate_lease, validate_receipt, validate_reservation,
4};
5use crate::{
6 ActionEnvelope, ActionPreview, ComputerUseError, ComputerUseRuntime, ControlLease,
7 ExecutionReceipt, SessionDeletionResult, SessionFollowUp, SessionFollowUpPage,
8 TargetReservation,
9};
10use adk_tool::McpToolset;
11use async_trait::async_trait;
12use rmcp::{RoleClient, service::Service};
13use serde_json::{Map, Value, json};
14use std::collections::HashMap;
15use std::sync::Arc;
16use tokio::sync::Mutex;
17use tracing::Instrument;
18
19#[derive(Debug, Clone)]
21pub struct ComputerUseMcpConfig {
22 pub session_id: String,
24 pub expected_principal_id: String,
26 pub capability_tool: String,
28 pub target_app: Option<String>,
30 pub target_window_id: Option<u64>,
32 pub correlation: TraceCorrelation,
34}
35
36#[derive(Debug, Clone, Default)]
38pub struct TraceCorrelation {
39 pub adk_session_id: Option<String>,
41 pub adk_invocation_id: Option<String>,
43 pub adk_graph_thread_id: Option<String>,
45 pub trace_id: Option<String>,
47}
48
49pub struct ComputerUseMcpRuntime<S>
63where
64 S: Service<RoleClient> + Send + Sync + 'static,
65{
66 toolset: Arc<McpToolset<S>>,
67 config: ComputerUseMcpConfig,
68 proposed: Mutex<HashMap<String, ProposedAction>>,
69}
70
71#[derive(Clone)]
72struct ProposedAction {
73 arguments: Map<String, Value>,
74 preview: ActionPreview,
75}
76
77impl<S> ComputerUseMcpRuntime<S>
78where
79 S: Service<RoleClient> + Send + Sync + 'static,
80{
81 pub fn new(toolset: Arc<McpToolset<S>>, config: ComputerUseMcpConfig) -> Self {
83 Self { toolset, config, proposed: Mutex::new(HashMap::new()) }
84 }
85
86 pub async fn observe_tool(
94 &self,
95 tool: &str,
96 arguments: Value,
97 ) -> Result<Value, ComputerUseError> {
98 self.observe_through_shadow(tool, arguments).await
99 }
100
101 pub async fn delete_terminal_session(&self) -> Result<SessionDeletionResult, ComputerUseError> {
109 let value = output(
110 self.call(
111 "delete_session",
112 object(json!({
113 "session_id": self.config.session_id,
114 "confirm": true,
115 }))?,
116 )
117 .await?,
118 );
119 Ok(serde_json::from_value(value.get("deletion").cloned().unwrap_or(value))?)
120 }
121
122 pub async fn prune_terminal_sessions(
130 &self,
131 older_than: &str,
132 limit: u32,
133 ) -> Result<Vec<SessionDeletionResult>, ComputerUseError> {
134 let value = output(
135 self.call(
136 "prune_sessions",
137 object(json!({
138 "older_than": older_than,
139 "limit": limit,
140 "confirm": true,
141 }))?,
142 )
143 .await?,
144 );
145 Ok(serde_json::from_value(value.get("deletions").cloned().unwrap_or_else(|| json!([])))?)
146 }
147
148 pub async fn get_follow_ups(
158 &self,
159 after_sequence: u64,
160 limit: u32,
161 ) -> Result<SessionFollowUpPage, ComputerUseError> {
162 if limit == 0 || limit > 1000 {
163 return Err(ComputerUseError::InvalidRequest(
164 "follow-up limit must be between 1 and 1000".into(),
165 ));
166 }
167 let value = output(
168 self.call(
169 "get_follow_ups",
170 object(json!({
171 "session_id": self.config.session_id,
172 "after_sequence": after_sequence,
173 "limit": limit,
174 }))?,
175 )
176 .await?,
177 );
178 let page: SessionFollowUpPage = serde_json::from_value(value)?;
179 if page.follow_ups.iter().any(|item| {
180 item.session_id != self.config.session_id
181 || item.principal_id != self.config.expected_principal_id
182 }) {
183 return Err(ComputerUseError::IdentityMismatch(
184 "follow-up identity does not match authenticated ADK context".into(),
185 ));
186 }
187 Ok(page)
188 }
189
190 pub async fn submit_follow_up(
199 &self,
200 instruction: &str,
201 ) -> Result<SessionFollowUp, ComputerUseError> {
202 let value = output(
203 self.call(
204 "submit_follow_up",
205 object(json!({
206 "session_id": self.config.session_id,
207 "instruction": instruction,
208 }))?,
209 )
210 .await?,
211 );
212 let follow_up: SessionFollowUp =
213 serde_json::from_value(value.get("follow_up").cloned().unwrap_or(value))?;
214 if follow_up.session_id != self.config.session_id
215 || follow_up.principal_id != self.config.expected_principal_id
216 {
217 return Err(ComputerUseError::IdentityMismatch(
218 "follow-up identity does not match authenticated ADK context".into(),
219 ));
220 }
221 Ok(follow_up)
222 }
223
224 async fn call(
225 &self,
226 name: &str,
227 arguments: Map<String, Value>,
228 ) -> Result<Value, ComputerUseError> {
229 let span = tracing::info_span!(
230 "computer_use.mcp",
231 mcp.tool = name,
232 runtime.session_id = %self.config.session_id,
233 adk.session_id = ?self.config.correlation.adk_session_id,
234 adk.invocation_id = ?self.config.correlation.adk_invocation_id,
235 adk.graph_thread_id = ?self.config.correlation.adk_graph_thread_id,
236 trace_id = ?self.config.correlation.trace_id,
237 );
238 self.toolset
239 .call_tool_value(name, arguments)
240 .instrument(span)
241 .await
242 .map_err(|error| ComputerUseError::Mcp(error.to_string()))
243 }
244
245 async fn observe_through_shadow(
246 &self,
247 tool: &str,
248 arguments: Value,
249 ) -> Result<Value, ComputerUseError> {
250 self.call(
251 "execute_action",
252 object(json!({
253 "session_id": self.config.session_id,
254 "action_id": uuid::Uuid::new_v4().to_string(),
255 "tool": tool,
256 "arguments": arguments,
257 "mode": "shadow",
258 "data_labels": ["private"],
259 }))?,
260 )
261 .await
262 }
263}
264
265fn output(value: Value) -> Value {
266 value
267 .get("response")
268 .and_then(|value| value.get("output"))
269 .or_else(|| value.get("output"))
270 .cloned()
271 .unwrap_or(value)
272}
273
274fn object(value: Value) -> Result<Map<String, Value>, ComputerUseError> {
275 let mut value = value.as_object().cloned().ok_or_else(|| {
276 ComputerUseError::InvalidRequest("proposed action must be an object".into())
277 })?;
278 value.retain(|_, entry| !entry.is_null());
279 Ok(value)
280}
281
282fn evaluate_postcondition_evidence(
289 receipt: &ExecutionReceipt,
290 postcondition: &crate::ActionPostcondition,
291) -> VerificationOutcome {
292 let expected = expected_digest(postcondition);
293
294 let Some(verification) = receipt.result.as_ref().and_then(|result| result.get("verification"))
295 else {
296 return VerificationOutcome::CommittedUnverified {
297 reason: "the receipt carried no verification evidence for the declared postcondition"
298 .to_string(),
299 };
300 };
301
302 if verification.get("satisfied").and_then(Value::as_bool) == Some(false) {
304 return VerificationOutcome::Failed {
305 reason: "the runtime reported the postcondition was not satisfied".to_string(),
306 };
307 }
308
309 let observed = verification.get("observedDigest").and_then(Value::as_str);
310
311 match (expected, observed) {
312 (Some(expected), Some(observed)) if expected == observed => VerificationOutcome::Verified,
313 (Some(expected), Some(observed)) => VerificationOutcome::Failed {
314 reason: format!(
315 "observed digest {observed:?} does not match the expected postcondition digest \
316 {expected:?}"
317 ),
318 },
319 (Some(_), None) => VerificationOutcome::CommittedUnverified {
320 reason: "verification evidence carried no observed digest to compare".to_string(),
321 },
322 (None, _) => match verification.get("satisfied").and_then(Value::as_bool) {
325 Some(true) => VerificationOutcome::Verified,
326 _ => VerificationOutcome::CommittedUnverified {
327 reason: "the postcondition declares no digest and the evidence made no explicit \
328 satisfied claim"
329 .to_string(),
330 },
331 },
332 }
333}
334
335fn expected_digest(postcondition: &crate::ActionPostcondition) -> Option<&str> {
337 use crate::ActionPostcondition;
338 match postcondition {
339 ActionPostcondition::UiElement { value_digest, .. } => value_digest.as_deref(),
340 ActionPostcondition::Filesystem { content_digest, .. } => content_digest.as_deref(),
341 ActionPostcondition::Registry { value_digest, .. } => value_digest.as_deref(),
342 _ => None,
343 }
344}
345
346#[async_trait]
347impl<S> ComputerUseRuntime for ComputerUseMcpRuntime<S>
348where
349 S: Service<RoleClient> + Send + Sync + 'static,
350{
351 async fn discover_capabilities(&self) -> Result<Value, ComputerUseError> {
352 self.call(
353 "get_execution_capabilities",
354 object(json!({
355 "tool": self.config.capability_tool,
356 "app_id": self.config.target_app,
357 }))?,
358 )
359 .await
360 }
361
362 async fn observe_visual(&self) -> Result<Value, ComputerUseError> {
363 self.observe_through_shadow(
364 "snapshot",
365 json!({
366 "use_vision": true,
367 "use_annotation": true,
368 "target_app": self.config.target_app,
369 }),
370 )
371 .await
372 }
373
374 async fn observe_semantic(&self) -> Result<Value, ComputerUseError> {
375 let (name, args) = match self.config.target_window_id {
376 Some(window_id) => ("get_ui_tree", json!({ "window_id": window_id })),
377 None => ("list_windows", json!({ "bundle_id": self.config.target_app })),
378 };
379 self.observe_through_shadow(name, args).await
380 }
381
382 async fn preview_action(
383 &self,
384 proposed_action: Value,
385 ) -> Result<ActionPreview, ComputerUseError> {
386 let mut args = object(proposed_action)?;
387 args.insert("session_id".into(), json!(self.config.session_id));
388 let preview: ActionPreview =
389 serde_json::from_value(output(self.call("preview_action", args.clone()).await?))?;
390 if preview.envelope.session_id != self.config.session_id {
391 return Err(ComputerUseError::IdentityMismatch(
392 "preview returned a different session identity".into(),
393 ));
394 }
395 if preview.envelope.principal_id != self.config.expected_principal_id {
396 return Err(ComputerUseError::IdentityMismatch(
397 "preview principal does not match authenticated ADK identity".into(),
398 ));
399 }
400 self.proposed.lock().await.insert(
401 preview.envelope.action_id.clone(),
402 ProposedAction { arguments: args, preview: preview.clone() },
403 );
404 Ok(preview)
405 }
406
407 async fn acquire_lease(
408 &self,
409 envelope: &ActionEnvelope,
410 ) -> Result<ControlLease, ComputerUseError> {
411 let kind = if envelope.requested_mode == crate::ExecutionMode::Foreground {
412 "exclusive"
413 } else {
414 "cooperative"
415 };
416 let value = output(
417 self.call(
418 "acquire_control_lease",
419 object(json!({
420 "session_id": envelope.session_id,
421 "agent_id": envelope.agent_id,
422 "kind": kind,
423 "mode": envelope.requested_mode,
424 "ttl_ms": 30_000,
425 "action_budget": 1,
426 "app_ids": envelope.target.as_ref().map(|target| vec![target.app_id.clone()]),
427 "window_ids": envelope.target.as_ref().and_then(|target| target.window_id.clone()).map(|id| vec![id]),
428 }))?,
429 )
430 .await?,
431 );
432 let lease: ControlLease =
433 serde_json::from_value(value.get("lease").cloned().unwrap_or(value))?;
434 validate_lease(&lease, envelope)?;
437 Ok(lease)
438 }
439
440 async fn reserve_target(
441 &self,
442 envelope: &ActionEnvelope,
443 ) -> Result<Option<TargetReservation>, ComputerUseError> {
444 let Some(target) = envelope.target.as_ref() else {
445 return Ok(None);
446 };
447 let value = output(
448 self.call(
449 "reserve_target",
450 object(json!({
451 "session_id": envelope.session_id,
452 "intent_id": envelope.action_id,
453 "execution_group_id": envelope.execution_group_id,
454 "agent_id": envelope.agent_id,
455 "app_id": target.app_id,
456 "window_id": target.window_id,
457 "ttl_ms": 30_000,
458 }))?,
459 )
460 .await?,
461 );
462 let reservation: TargetReservation =
463 serde_json::from_value(value.get("reservation").cloned().unwrap_or(value))?;
464 validate_reservation(&reservation, envelope)?;
465 Ok(Some(reservation))
466 }
467
468 async fn release_target(
469 &self,
470 reservation: &TargetReservation,
471 ) -> Result<(), ComputerUseError> {
472 self.call(
473 "release_target_reservation",
474 object(json!({
475 "session_id": reservation.session_id,
476 "reservation_id": reservation.reservation_id,
477 }))?,
478 )
479 .await?;
480 Ok(())
481 }
482
483 async fn execute_action(
484 &self,
485 envelope: &ActionEnvelope,
486 lease: &ControlLease,
487 approval_grant_id: Option<&str>,
488 ) -> Result<ExecutionReceipt, ComputerUseError> {
489 validate_envelope_freshness(envelope)?;
490 validate_lease(lease, envelope)?;
491 let proposed =
492 self.proposed.lock().await.get(&envelope.action_id).cloned().ok_or_else(|| {
493 ComputerUseError::Runtime("missing exact proposed action for execution".into())
494 })?;
495 if proposed.preview.envelope != *envelope {
496 return Err(ComputerUseError::IdentityMismatch(format!(
497 "action envelope {} changed after preview; exact preview binding is required",
498 envelope.action_id
499 )));
500 }
501 let mut args = proposed.arguments;
502 args.insert("action_id".into(), json!(envelope.action_id));
503 args.insert("lease_id".into(), json!(lease.lease_id));
504 if let Some(grant) = approval_grant_id {
505 args.insert("approval_grant_id".into(), json!(grant));
506 }
507 let value = output(self.call("execute_action", args).await?);
508 let receipt: ExecutionReceipt =
509 serde_json::from_value(value.get("receipt").cloned().unwrap_or(value))?;
510 tracing::info!(
511 runtime.session_id = %receipt.session_id,
512 runtime.action_id = %receipt.action_id,
513 runtime.receipt_id = %receipt.receipt_id,
514 runtime.action_digest = %receipt.action_digest,
515 "computer-use action receipt"
516 );
517 validate_receipt(&receipt, envelope, &envelope.args_digest)?;
520 Ok(receipt)
521 }
522
523 async fn verify(
524 &self,
525 receipt: &ExecutionReceipt,
526 postcondition: Option<&crate::ActionPostcondition>,
527 ) -> Result<VerificationOutcome, ComputerUseError> {
528 if receipt.status != crate::ReceiptStatus::Committed {
532 return Ok(VerificationOutcome::Failed {
533 reason: format!("receipt status is {:?}, not committed", receipt.status),
534 });
535 }
536
537 let Some(postcondition) = postcondition else {
538 return Ok(VerificationOutcome::CommittedUnverified {
539 reason: "the action declared no postcondition, so there is nothing to verify"
540 .to_string(),
541 });
542 };
543
544 Ok(evaluate_postcondition_evidence(receipt, postcondition))
548 }
549
550 async fn pause_session(&self, session_id: &str, reason: &str) -> Result<(), ComputerUseError> {
551 self.call("pause_session", object(json!({ "session_id": session_id, "reason": reason }))?)
552 .await?;
553 Ok(())
554 }
555
556 async fn stop_session(&self, session_id: &str, reason: &str) -> Result<(), ComputerUseError> {
557 self.call("stop_session", object(json!({ "session_id": session_id, "reason": reason }))?)
558 .await?;
559 Ok(())
560 }
561
562 async fn emergency_stop(&self, reason: &str) -> Result<(), ComputerUseError> {
563 self.call("emergency_stop", object(json!({ "reason": reason }))?).await?;
564 Ok(())
565 }
566}