Skip to main content

purview/
action.rs

1//! Actions (tools): handler storage, `on_ui`/async bridging, annotations, and
2//! schema synthesis.
3
4use std::{future::Future, pin::Pin, sync::Arc};
5
6use schemars::JsonSchema;
7use serde::{Deserialize, de::IgnoredAny};
8use serde_json::{Map, Value};
9
10use crate::{
11    error::ActionError,
12    snapshot::Outcome,
13    ui::{OnUi, Ui},
14};
15
16/// Placeholder parameter type for actions with no parameters: deserializes from
17/// any JSON (including `{}` / absent) and ignores the content.
18#[derive(Debug, Default, Clone, Copy)]
19pub struct NoArgs;
20
21impl<'de> Deserialize<'de> for NoArgs {
22    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
23    where
24        D: serde::Deserializer<'de>,
25    {
26        IgnoredAny::deserialize(deserializer)?;
27        Ok(NoArgs)
28    }
29}
30
31/// MCP tool annotations (§5.6); all opt-in, emitted only when explicitly set.
32#[derive(Debug, Clone, Default)]
33pub(crate) struct Annotations {
34    pub read_only: Option<bool>,
35    pub destructive: Option<bool>,
36    pub idempotent: Option<bool>,
37    pub open_world: Option<bool>,
38}
39
40impl Annotations {
41    pub(crate) fn is_empty(&self) -> bool {
42        self.read_only.is_none()
43            && self.destructive.is_none()
44            && self.idempotent.is_none()
45            && self.open_world.is_none()
46    }
47}
48
49pub(crate) type HandlerFuture = Pin<Box<dyn Future<Output = Result<Outcome, ActionError>> + Send>>;
50pub(crate) type BoxedHandler = Box<dyn Fn(Value) -> HandlerFuture + Send + Sync>;
51
52/// A fully registered action definition (stored on a window). The action name
53/// is the key in the window's action map, so it is not duplicated here. Schemas
54/// are `Arc` so `tools/list` clones a pointer, not the map, and
55/// `expectedVersion` is injected once at registration.
56pub(crate) struct ActionSpec {
57    pub title: String,
58    pub description: Option<String>,
59    pub annotations: Annotations,
60    pub input_schema: Arc<Map<String, Value>>,
61    pub output_schema: Arc<Map<String, Value>>,
62    pub available: bool,
63    pub handler: BoxedHandler,
64}
65
66fn deserialize_params<P>(params: Value) -> Result<P, ActionError>
67where
68    P: for<'de> Deserialize<'de>,
69{
70    serde_json::from_value(params).map_err(|e| ActionError::field("_params", e.to_string()))
71}
72
73/// An opaque, stored action handler produced by [`IntoActionHandler`]. An
74/// implementation detail of the `.on(...)` bridge; not constructed by users.
75#[doc(hidden)]
76pub struct RegisteredHandler(pub(crate) BoxedHandler);
77
78/// Bound satisfied by the closures accepted by `.on(...)`: an `async` closure
79/// (tokio), with or without params; or a synchronous `on_ui(…)` closure run on
80/// the main thread in any of four shapes — `|p|`, `|p, cx|`, `|cx|`, `||` —
81/// mixing params and the host context `&mut Cx`. Sealed — users do not
82/// implement it. `Marker` disambiguates the blanket impls; `P` is the parameter
83/// type; `Cx` is the host main-thread context (defaults to `()`).
84pub trait IntoActionHandler<Marker, P, Cx> {
85    /// Box the closure into a stored handler (internal). `Cx` is erased once
86    /// the closure captures the typed [`Ui<Cx>`].
87    #[doc(hidden)]
88    fn into_handler(self, ui: Ui<Cx>) -> RegisteredHandler;
89}
90
91/// Disambiguating marker for the async-closure `.on(...)` form (internal).
92#[doc(hidden)]
93pub struct AsyncMarker;
94
95/// Disambiguating marker for the parameterless async-closure `.on(|| async …)`
96/// form (internal).
97#[doc(hidden)]
98pub struct AsyncBareMarker;
99
100/// Disambiguating marker for the context-free `on_ui(...)` `.on(...)` form
101/// (internal).
102#[doc(hidden)]
103pub struct UiMarker;
104
105/// Disambiguating marker for the `on_ui(|p, &mut Cx|)` `.on(...)` form
106/// (internal).
107#[doc(hidden)]
108pub struct UiCtxMarker;
109
110/// Disambiguating marker for the `on_ui(|&mut Cx|)` `.on(...)` form — host
111/// context only, no parameter (internal).
112#[doc(hidden)]
113pub struct UiCtxOnlyMarker;
114
115/// Disambiguating marker for the `on_ui(|| …)` `.on(...)` form — neither
116/// parameter nor host context (internal).
117#[doc(hidden)]
118pub struct UiBareMarker;
119
120impl<P, Cx, F, Fut, O> IntoActionHandler<AsyncMarker, P, Cx> for F
121where
122    P: for<'de> Deserialize<'de> + Send + 'static,
123    O: Into<Outcome> + Send + 'static,
124    F: Fn(P) -> Fut + Send + Sync + 'static,
125    Fut: Future<Output = Result<O, ActionError>> + Send + 'static,
126{
127    fn into_handler(self, _ui: Ui<Cx>) -> RegisteredHandler {
128        let f = Arc::new(self);
129        RegisteredHandler(Box::new(move |params| {
130            let f = f.clone();
131            Box::pin(async move {
132                let p = deserialize_params::<P>(params)?;
133                f(p).await.map(Into::into)
134            })
135        }))
136    }
137}
138
139// Parameterless async form: `|| async { … }`. `P` is fixed to `NoArgs`, so it
140// only matches parameterless actions; the incoming params are ignored.
141impl<Cx, F, Fut, O> IntoActionHandler<AsyncBareMarker, NoArgs, Cx> for F
142where
143    O: Into<Outcome> + Send + 'static,
144    F: Fn() -> Fut + Send + Sync + 'static,
145    Fut: Future<Output = Result<O, ActionError>> + Send + 'static,
146{
147    fn into_handler(self, _ui: Ui<Cx>) -> RegisteredHandler {
148        let f = Arc::new(self);
149        RegisteredHandler(Box::new(move |_params| {
150            let f = f.clone();
151            Box::pin(async move { f().await.map(Into::into) })
152        }))
153    }
154}
155
156impl<P, Cx, G, O> IntoActionHandler<UiMarker, P, Cx> for OnUi<G>
157where
158    P: for<'de> Deserialize<'de> + Send + 'static,
159    Cx: 'static,
160    O: Into<Outcome> + Send + 'static,
161    G: Fn(P) -> Result<O, ActionError> + Send + Sync + 'static,
162{
163    fn into_handler(self, ui: Ui<Cx>) -> RegisteredHandler {
164        let g = Arc::new(self.0);
165        RegisteredHandler(Box::new(move |params| {
166            let g = g.clone();
167            let ui = ui.clone();
168            Box::pin(async move {
169                let p = deserialize_params::<P>(params)?;
170                ui.run(move |_cx| g(p)).await.map(Into::into)
171            })
172        }))
173    }
174}
175
176impl<P, Cx, G, O> IntoActionHandler<UiCtxMarker, P, Cx> for OnUi<G>
177where
178    P: for<'de> Deserialize<'de> + Send + 'static,
179    Cx: 'static,
180    O: Into<Outcome> + Send + 'static,
181    G: Fn(P, &mut Cx) -> Result<O, ActionError> + Send + Sync + 'static,
182{
183    fn into_handler(self, ui: Ui<Cx>) -> RegisteredHandler {
184        let g = Arc::new(self.0);
185        RegisteredHandler(Box::new(move |params| {
186            let g = g.clone();
187            let ui = ui.clone();
188            Box::pin(async move {
189                let p = deserialize_params::<P>(params)?;
190                ui.run(move |cx: &mut Cx| g(p, cx)).await.map(Into::into)
191            })
192        }))
193    }
194}
195
196// Context-only form: `on_ui(|&mut Cx|)`. `P` is fixed to `NoArgs`, so this only
197// matches parameterless actions (the default `ActionBuilder<NoArgs, _>`); the
198// incoming params are ignored, matching `NoArgs`'s "accept and drop" contract.
199impl<Cx, G, O> IntoActionHandler<UiCtxOnlyMarker, NoArgs, Cx> for OnUi<G>
200where
201    Cx: 'static,
202    O: Into<Outcome> + Send + 'static,
203    G: Fn(&mut Cx) -> Result<O, ActionError> + Send + Sync + 'static,
204{
205    fn into_handler(self, ui: Ui<Cx>) -> RegisteredHandler {
206        let g = Arc::new(self.0);
207        RegisteredHandler(Box::new(move |_params| {
208            let g = g.clone();
209            let ui = ui.clone();
210            Box::pin(async move { ui.run(move |cx: &mut Cx| g(cx)).await.map(Into::into) })
211        }))
212    }
213}
214
215// Bare form: `on_ui(|| …)`. Neither a parameter nor the host context. `P` is
216// fixed to `NoArgs`, so it only matches parameterless actions; the closure
217// still runs on the main thread (via `ui.run`), just without touching `Cx`.
218impl<Cx, G, O> IntoActionHandler<UiBareMarker, NoArgs, Cx> for OnUi<G>
219where
220    Cx: 'static,
221    O: Into<Outcome> + Send + 'static,
222    G: Fn() -> Result<O, ActionError> + Send + Sync + 'static,
223{
224    fn into_handler(self, ui: Ui<Cx>) -> RegisteredHandler {
225        let g = Arc::new(self.0);
226        RegisteredHandler(Box::new(move |_params| {
227            let g = g.clone();
228            let ui = ui.clone();
229            Box::pin(async move { ui.run(move |_cx: &mut Cx| g()).await.map(Into::into) })
230        }))
231    }
232}
233
234/// Generate a JSON Schema object from a schemars type.
235pub(crate) fn schema_object<T>() -> Map<String, Value>
236where
237    T: JsonSchema,
238{
239    match serde_json::to_value(schemars::schema_for!(T)) {
240        Ok(Value::Object(m)) => m,
241        _ => empty_object_schema(),
242    }
243}
244
245pub(crate) fn empty_object_schema() -> Map<String, Value> {
246    let mut m = Map::new();
247    m.insert("type".into(), Value::String("object".into()));
248    m
249}
250
251/// Inject the optional `expectedVersion` property into an input schema (§5.5).
252pub(crate) fn inject_expected_version(schema: &mut Map<String, Value>) {
253    let props = schema
254        .entry("properties")
255        .or_insert_with(|| Value::Object(Map::new()));
256    if let Value::Object(props) = props {
257        props.insert(
258            "expectedVersion".into(),
259            serde_json::json!({
260                "type": "integer",
261                "description": "the window.version this write is based on (optimistic concurrency, §7.1)"
262            }),
263        );
264    }
265    schema
266        .entry("type")
267        .or_insert_with(|| Value::String("object".into()));
268}
269
270/// Synthesize an output schema: protocol base fields plus an optional `result`
271/// sub-schema (§12.3).
272pub(crate) fn build_output_schema(result_schema: Option<Map<String, Value>>) -> Map<String, Value> {
273    let mut props = Map::new();
274    props.insert("message".into(), serde_json::json!({ "type": "string" }));
275    props.insert(
276        "openedWindowIds".into(),
277        serde_json::json!({ "type": "array", "items": { "type": "string" } }),
278    );
279    if let Some(rs) = result_schema {
280        props.insert("result".into(), Value::Object(rs));
281    }
282    let mut schema = Map::new();
283    schema.insert("type".into(), Value::String("object".into()));
284    schema.insert("properties".into(), Value::Object(props));
285    schema
286}