Skip to main content

adk_computer_use/runtime/
mcp.rs

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/// Static configuration binding a [`ComputerUseMcpRuntime`] to one authenticated session.
20#[derive(Debug, Clone)]
21pub struct ComputerUseMcpConfig {
22    /// The runtime session identifier this adapter operates within.
23    pub session_id: String,
24    /// The principal the MCP server authenticated; every payload is checked against it.
25    pub expected_principal_id: String,
26    /// The tool used when discovering execution capabilities.
27    pub capability_tool: String,
28    /// Optional bundle/app identifier scoping observation and capability queries.
29    pub target_app: Option<String>,
30    /// Optional window identifier scoping the semantic observation.
31    pub target_window_id: Option<u64>,
32    /// Trace identifiers correlating ADK spans with runtime session events.
33    pub correlation: TraceCorrelation,
34}
35
36/// ADK ↔ runtime trace correlation identifiers attached to every MCP span.
37#[derive(Debug, Clone, Default)]
38pub struct TraceCorrelation {
39    /// The ADK session identifier, if the graph runs inside a session.
40    pub adk_session_id: Option<String>,
41    /// The ADK invocation identifier for the current run.
42    pub adk_invocation_id: Option<String>,
43    /// The `adk-graph` thread identifier for durable resume correlation.
44    pub adk_graph_thread_id: Option<String>,
45    /// An external distributed-trace identifier, if one is propagated.
46    pub trace_id: Option<String>,
47}
48
49/// [`ComputerUseRuntime`] implementation backed by a live `computer-use-mcp` server.
50///
51/// The adapter is bound to a single authenticated session via
52/// [`ComputerUseMcpConfig`]. It forwards every observation through the governed
53/// `execute_action` shadow facade and re-checks the principal/session identity
54/// on each response so that graph or model state can never substitute a different
55/// authority.
56///
57/// # Errors
58///
59/// Methods return [`ComputerUseError`]: transport faults become
60/// [`ComputerUseError::Mcp`], decode failures become [`ComputerUseError::Decode`],
61/// and identity re-checks become [`ComputerUseError::IdentityMismatch`].
62pub 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    /// Create an adapter bound to the supplied MCP toolset and session config.
82    pub fn new(toolset: Arc<McpToolset<S>>, config: ComputerUseMcpConfig) -> Self {
83        Self { toolset, config, proposed: Mutex::new(HashMap::new()) }
84    }
85
86    /// Run a read-only desktop tool through the governed shadow facade.
87    ///
88    /// Useful for bootstrapping exact target evidence before graph execution.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`ComputerUseError::Mcp`] if the underlying MCP call fails.
93    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    /// Permanently remove this adapter's terminal runtime session through the
102    /// authenticated MCP principal. The server, not graph state, owns identity.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`ComputerUseError::Mcp`] or [`ComputerUseError::Decode`] if the
107    /// call fails or the deletion result cannot be decoded.
108    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    /// Explicitly prune caller-owned terminal sessions before the supplied
123    /// timestamp. The MCP server enforces principal ownership and the limit.
124    ///
125    /// # Errors
126    ///
127    /// Returns [`ComputerUseError::Mcp`] or [`ComputerUseError::Decode`] on
128    /// transport or decoding failure.
129    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    /// Consume remote/local steering with a monotonic cursor. The server binds
149    /// both the session and returned instructions to the authenticated principal.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`ComputerUseError::InvalidRequest`] if `limit` is outside
154    /// `1..=1000`, [`ComputerUseError::IdentityMismatch`] if any returned
155    /// follow-up does not match the authenticated context, or a transport/decode
156    /// error otherwise.
157    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    /// Submit steering through the same principal-bound MCP boundary. Hosts
191    /// normally call this; exposing it here supports supervisor/graph tests.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`ComputerUseError::IdentityMismatch`] if the returned follow-up
196    /// does not match the authenticated context, or a transport/decode error
197    /// otherwise.
198    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
282/// Decides what a committed receipt proves about its declared postcondition.
283///
284/// Looks for a `verification` object on the receipt result carrying the observed digest, and
285/// requires it to match the postcondition's expected digest. Anything less is reported as
286/// committed-but-unverified rather than as verification, so the absence of evidence is never
287/// mistaken for evidence of success.
288fn 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    // An explicit negative observation is a failure, not merely missing evidence.
303    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        // A digest-free postcondition (existence only) is satisfied by an explicit
323        // affirmative observation.
324        (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
335/// The digest a postcondition expects, where it declares one.
336fn 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        // Deserialization proves shape, not provenance: a well-formed lease for another
435        // session or principal parses cleanly. Bind it before it reaches graph state.
436        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        // The digest covers the envelope, so a receipt carrying a different one describes
518        // different work even when the identifiers line up.
519        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        // A committed receipt says the runtime accepted and performed the action. It does not
529        // say the intended effect occurred. Returning `status == Committed` as "verified"
530        // reported a committed-but-ineffective action as completed.
531        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        // `computer-use-mcp` may verify before issuing a receipt, but that is its contract,
545        // not something this adapter can assume. Evidence has to be present and bound to the
546        // postcondition to count.
547        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}