Skip to main content

forge_workspace/mcp/
gotify.rs

1//! Gotify MCP - subscribe to + read from external notifications
2//! (`mcp__forge__gotify__*`).
3//!
4//! A forge session subscribes the configured Gotify server; matching
5//! notifications deliver into the session as a user-turn, spawning it if
6//! asleep. `gotify__subscribe` / `gotify__list` / `gotify__unsubscribe`
7//! manage the caller's OWN subscriptions - a lead the lead's, a worker
8//! its own, neither the other's; `gotify__apps` / `gotify__recent` are
9//! read-only server queries (application names, recent-notification
10//! catch-up). All are ANY-CALLER - mirroring the cron family, not the
11//! lead-only agents verbs, down to the owner scoping on list + remove.
12//!
13//! - [`facade`] - the `GotifyFacade` seam (prod over `Weak<Workspace>` +
14//!   a mock for tool tests).
15
16use std::sync::Arc;
17
18use forge_sdk::mcp::server::McpServerBuilder;
19use forge_sdk::mcp::tool::{Tool, ToolInput, ToolOutput};
20
21use forge_connectors::gotify::GotifyRecent;
22use forge_primitives::GotifySubscription;
23use uuid::Uuid;
24
25use crate::SessionSlot;
26use crate::mcp::gotify::facade::{
27    GotifyFacade, GotifyReadError, GotifySubscribeError, GotifyUnsubscribeError,
28};
29
30pub(crate) mod facade;
31pub mod types;
32
33/// Attach the five Gotify tools to an existing [`McpServerBuilder`].
34/// Called for BOTH lead and worker sessions (any-caller), so
35/// `build_forge_server` invokes this unconditionally. `apps` / `recent`
36/// are server-global reads and don't take the caller slot.
37pub(crate) fn add_tools(
38    builder: McpServerBuilder,
39    facade: Arc<dyn GotifyFacade>,
40    slot: SessionSlot,
41) -> McpServerBuilder {
42    let subscribe = Subscribe { facade: facade.clone(), slot: slot.clone() };
43    let list = List { facade: facade.clone(), slot: slot.clone() };
44    let apps = Apps { facade: facade.clone() };
45    let recent = Recent { facade: facade.clone() };
46    let unsubscribe = Unsubscribe { facade, slot };
47    builder.tool(subscribe).tool(list).tool(unsubscribe).tool(apps).tool(recent)
48}
49
50fn tool_error(text: String) -> ToolOutput {
51    ToolOutput::error(text)
52}
53
54/// Readable JSON for one subscription (the tool-output shape the LLM
55/// sees), shared by the `gotify__list` rows and the `gotify__unsubscribe`
56/// echo. No `team_role`: both show only the caller's own, so the field
57/// could carry just one value, and `cron_to_json` omits it for the same
58/// reason.
59fn sub_to_json(sub: &GotifySubscription) -> serde_json::Value {
60    serde_json::json!({
61        "id": sub.id.to_string(),
62        "project": sub.project,
63        "applications": sub.applications,
64        "min_priority": sub.min_priority,
65    })
66}
67
68fn format_subscribe_error(err: &GotifySubscribeError) -> String {
69    match err {
70        GotifySubscribeError::NotConfigured => {
71            "no Gotify server configured in forge.toml [gotify]".to_owned()
72        }
73        GotifySubscribeError::UnknownCallerProject => {
74            "couldn't resolve your project; is this session attached to a forge.toml project?"
75                .to_owned()
76        }
77    }
78}
79
80/// Default notification count for `gotify__recent` when `limit` is omitted.
81const DEFAULT_RECENT_LIMIT: usize = 20;
82
83fn format_read_error(err: &GotifyReadError) -> String {
84    match err {
85        GotifyReadError::NotConfigured => {
86            "no Gotify server configured in forge.toml [gotify]".to_owned()
87        }
88        GotifyReadError::Fetch(msg) => format!("Gotify request failed: {msg}"),
89    }
90}
91
92fn recent_to_json(n: &GotifyRecent) -> serde_json::Value {
93    serde_json::json!({
94        "app": n.app,
95        "title": n.title,
96        "message": n.message,
97        "priority": n.priority,
98        "date": n.date,
99    })
100}
101
102struct Subscribe {
103    facade: Arc<dyn GotifyFacade>,
104    slot: SessionSlot,
105}
106
107#[derive(serde::Deserialize)]
108struct SubscribeArgs {
109    #[serde(default)]
110    applications: Option<Vec<String>>,
111    #[serde(default)]
112    min_priority: Option<u8>,
113}
114
115#[async_trait::async_trait]
116impl Tool for Subscribe {
117    fn name(&self) -> &'static str {
118        "gotify__subscribe"
119    }
120
121    fn description(&self) -> &'static str {
122        "Subscribe YOUR session to the configured Gotify server. When a matching notification \
123         arrives it is delivered to you as a user turn (spawning your session if it's asleep). \
124         Optionally filter by `applications` (a set of Gotify app NAMEs; a notification matches \
125         when its app is any one of them) and/or `min_priority` (only notifications at or above \
126         this priority). Both default to any (omit or leave empty). Returns the created \
127         subscription as {id, applications, min_priority, names_resolve}; a false names_resolve \
128         means a filter naming apps will not match until the stream reconnects (the \
129         subscription is live either way). Use the id with gotify__unsubscribe. Errors if no \
130         [gotify] server is configured. Any session in the project may subscribe."
131    }
132
133    fn input_schema(&self) -> serde_json::Value {
134        serde_json::json!({
135            "type": "object",
136            "properties": {
137                "applications": {
138                    "type": "array",
139                    "items": { "type": "string" },
140                    "description": "Gotify application NAMEs to match; a notification from any \
141                                    one of them matches. Omit or leave empty to match any app.",
142                },
143                "min_priority": {
144                    "type": "integer",
145                    "minimum": 0,
146                    "maximum": 255,
147                    "description": "Only deliver notifications at or above this priority. Omit \
148                                    to match any priority.",
149                },
150            },
151            "additionalProperties": false,
152        })
153    }
154
155    async fn call(&self, input: ToolInput) -> ToolOutput {
156        let args: SubscribeArgs = match serde_json::from_value(input.value) {
157            Ok(a) => a,
158            Err(err) => return tool_error(format!("invalid arguments: {err}")),
159        };
160        match self
161            .facade
162            .subscribe(&self.slot, args.applications.unwrap_or_default(), args.min_priority)
163            .await
164        {
165            Ok(outcome) => match serde_json::to_string_pretty(&serde_json::json!({
166                "id": outcome.id.to_string(),
167                "applications": outcome.applications,
168                "min_priority": outcome.min_priority,
169                "names_resolve": outcome.names_resolve,
170            })) {
171                Ok(json) => ToolOutput::text(json),
172                Err(err) => tool_error(format!("response serialization failed: {err}")),
173            },
174            Err(err) => tool_error(format_subscribe_error(&err)),
175        }
176    }
177}
178
179struct List {
180    facade: Arc<dyn GotifyFacade>,
181    slot: SessionSlot,
182}
183
184#[async_trait::async_trait]
185impl Tool for List {
186    fn name(&self) -> &'static str {
187        "gotify__list"
188    }
189
190    fn description(&self) -> &'static str {
191        "List YOUR OWN active Gotify subscriptions (a lead sees the lead's, a worker its own). \
192         Returns a JSON array of {id, project, applications, min_priority}. Use an id with \
193         gotify__unsubscribe. An empty array means no subscriptions. Takes no arguments. Any \
194         session in the project may call this."
195    }
196
197    fn input_schema(&self) -> serde_json::Value {
198        serde_json::json!({
199            "type": "object",
200            "properties": {},
201            "additionalProperties": false,
202        })
203    }
204
205    async fn call(&self, _input: ToolInput) -> ToolOutput {
206        let subs = self.facade.list(&self.slot);
207        let arr: Vec<serde_json::Value> = subs.iter().map(sub_to_json).collect();
208        match serde_json::to_string_pretty(&serde_json::Value::Array(arr)) {
209            Ok(json) => ToolOutput::text(json),
210            Err(err) => tool_error(format!("subscription-list serialization failed: {err}")),
211        }
212    }
213}
214
215struct Unsubscribe {
216    facade: Arc<dyn GotifyFacade>,
217    slot: SessionSlot,
218}
219
220#[derive(serde::Deserialize)]
221struct UnsubscribeArgs {
222    id: String,
223}
224
225#[async_trait::async_trait]
226impl Tool for Unsubscribe {
227    fn name(&self) -> &'static str {
228        "gotify__unsubscribe"
229    }
230
231    fn description(&self) -> &'static str {
232        "Remove one of YOUR OWN Gotify subscriptions by id (from gotify__list / \
233         gotify__subscribe), scoped to what you subscribed - a caller manages only what it \
234         created. Returns the removed subscription. Any session in the project may call this."
235    }
236
237    fn input_schema(&self) -> serde_json::Value {
238        serde_json::json!({
239            "type": "object",
240            "properties": {
241                "id": { "type": "string", "description": "The subscription id to remove." },
242            },
243            "required": ["id"],
244            "additionalProperties": false,
245        })
246    }
247
248    async fn call(&self, input: ToolInput) -> ToolOutput {
249        let args: UnsubscribeArgs = match serde_json::from_value(input.value) {
250            Ok(a) => a,
251            Err(err) => return tool_error(format!("invalid arguments: {err}")),
252        };
253        let Ok(id) = Uuid::parse_str(&args.id) else {
254            return tool_error(format!("not a valid subscription id: {}", args.id));
255        };
256        match self.facade.unsubscribe(&self.slot, id) {
257            Ok(removed) => {
258                let envelope = crate::mcp::deleted::removed_record(&sub_to_json(&removed));
259                match serde_json::to_string_pretty(&envelope) {
260                    Ok(json) => ToolOutput::text(json),
261                    Err(err) => tool_error(format!("response serialization failed: {err}")),
262                }
263            }
264            Err(GotifyUnsubscribeError::UnknownCallerProject) => {
265                tool_error("couldn't resolve your project".to_owned())
266            }
267            Err(GotifyUnsubscribeError::NoSuchSubscription) => {
268                tool_error(format!("no subscription with id {id} in this project"))
269            }
270            Err(GotifyUnsubscribeError::NotOwnedByCaller) => tool_error(format!(
271                "subscription {id} exists in this project but was not subscribed by you"
272            )),
273        }
274    }
275}
276
277struct Apps {
278    facade: Arc<dyn GotifyFacade>,
279}
280
281#[async_trait::async_trait]
282impl Tool for Apps {
283    fn name(&self) -> &'static str {
284        "gotify__apps"
285    }
286
287    fn description(&self) -> &'static str {
288        "List the application NAMEs on the configured Gotify server. Use these names to filter \
289         gotify__subscribe / gotify__recent by app. Returns a JSON array of strings. Takes no \
290         arguments. Errors if no [gotify] server is configured."
291    }
292
293    fn input_schema(&self) -> serde_json::Value {
294        serde_json::json!({
295            "type": "object",
296            "properties": {},
297            "additionalProperties": false,
298        })
299    }
300
301    async fn call(&self, _input: ToolInput) -> ToolOutput {
302        match self.facade.apps().await {
303            Ok(names) => match serde_json::to_string_pretty(&names) {
304                Ok(json) => ToolOutput::text(json),
305                Err(err) => tool_error(format!("application-list serialization failed: {err}")),
306            },
307            Err(err) => tool_error(format_read_error(&err)),
308        }
309    }
310}
311
312struct Recent {
313    facade: Arc<dyn GotifyFacade>,
314}
315
316#[derive(serde::Deserialize)]
317struct RecentArgs {
318    #[serde(default)]
319    applications: Option<Vec<String>>,
320    #[serde(default)]
321    min_priority: Option<u8>,
322    #[serde(default)]
323    limit: Option<usize>,
324}
325
326#[async_trait::async_trait]
327impl Tool for Recent {
328    fn name(&self) -> &'static str {
329        "gotify__recent"
330    }
331
332    fn description(&self) -> &'static str {
333        "Fetch the most recent notifications from the configured Gotify server, newest first - a \
334         catch-up for a session that was asleep or just woke. Optionally filter by `applications` \
335         (a set of Gotify app NAMEs, from gotify__apps) and/or `min_priority` (at or above), and \
336         cap the count with `limit` (default 20). Returns a JSON array of {app, title, message, \
337         priority, date}. Errors if no [gotify] server is configured."
338    }
339
340    fn input_schema(&self) -> serde_json::Value {
341        serde_json::json!({
342            "type": "object",
343            "properties": {
344                "applications": {
345                    "type": "array",
346                    "items": { "type": "string" },
347                    "description": "Gotify application NAMEs to include; a notification from any \
348                                    one of them matches. Omit or leave empty to include any app.",
349                },
350                "min_priority": {
351                    "type": "integer",
352                    "minimum": 0,
353                    "maximum": 255,
354                    "description": "Only include notifications at or above this priority. Omit \
355                                    to include any priority.",
356                },
357                "limit": {
358                    "type": "integer",
359                    "minimum": 1,
360                    "description": "Maximum number of notifications to return (default 20).",
361                },
362            },
363            "additionalProperties": false,
364        })
365    }
366
367    async fn call(&self, input: ToolInput) -> ToolOutput {
368        let args: RecentArgs = match serde_json::from_value(input.value) {
369            Ok(a) => a,
370            Err(err) => return tool_error(format!("invalid arguments: {err}")),
371        };
372        let limit = args.limit.unwrap_or(DEFAULT_RECENT_LIMIT);
373        match self
374            .facade
375            .recent(args.applications.unwrap_or_default(), args.min_priority, limit)
376            .await
377        {
378            Ok(items) => {
379                let arr: Vec<serde_json::Value> = items.iter().map(recent_to_json).collect();
380                match serde_json::to_string_pretty(&serde_json::Value::Array(arr)) {
381                    Ok(json) => ToolOutput::text(json),
382                    Err(err) => {
383                        tool_error(format!("recent-notifications serialization failed: {err}"))
384                    }
385                }
386            }
387            Err(err) => tool_error(format_read_error(&err)),
388        }
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::mcp::gotify::facade::{MockGotifyFacade, SubscribeOutcome};
396    use crate::mcp::test_support::text_of;
397    use std::time::SystemTime;
398
399    fn caller_slot() -> SessionSlot {
400        SessionSlot::from_str_for_test("caller")
401    }
402
403    fn sample_sub(id: Uuid, project: &str) -> GotifySubscription {
404        GotifySubscription {
405            id,
406            project: project.to_owned(),
407            team_role: None,
408            applications: vec!["alerts".to_owned()],
409            min_priority: Some(5),
410            created_at: SystemTime::UNIX_EPOCH,
411        }
412    }
413
414    fn input(value: serde_json::Value) -> ToolInput {
415        ToolInput { value }
416    }
417
418    /// The result is one structured row carrying the filter that was
419    /// actually registered, so a reader can draw the filter it created.
420    #[tokio::test]
421    async fn subscribe_returns_the_effective_filter() {
422        let id = Uuid::from_u128(0x42);
423        let mock = Arc::new(MockGotifyFacade::new());
424        *mock.subscribe_result.lock() = Some(Ok(SubscribeOutcome {
425            id,
426            applications: vec!["alerts".to_owned()],
427            min_priority: Some(5),
428            names_resolve: true,
429        }));
430        let tool = Subscribe { facade: mock.clone(), slot: caller_slot() };
431
432        let out = tool
433            .call(input(serde_json::json!({ "applications": ["alerts"], "min_priority": 5 })))
434            .await;
435        assert!(!out.is_error, "valid subscribe succeeds: {}", text_of(&out));
436        assert_eq!(out.blocks.len(), 1, "the outcome stays one text block: {out:?}");
437        let json: serde_json::Value =
438            serde_json::from_str(text_of(&out)).expect("the result is the structured row");
439        assert_eq!(json["id"], id.to_string());
440        assert_eq!(json["applications"], serde_json::json!(["alerts"]));
441        assert_eq!(json["min_priority"], 5);
442        assert_eq!(json["names_resolve"], true);
443
444        let calls = mock.subscribe_calls.lock();
445        assert_eq!(calls.len(), 1);
446        assert_eq!(calls[0].1, vec!["alerts".to_owned()]);
447        assert_eq!(calls[0].2, Some(5));
448    }
449
450    /// A subscription whose application-name filter could not be resolved
451    /// has to say so as a FIELD: a reply of plain success reads exactly
452    /// like the silent drop #1298 was filed for, and the degraded case
453    /// buried in a sentence cannot be drawn.
454    #[tokio::test]
455    async fn subscribe_reports_a_filter_it_could_not_resolve() {
456        let id = Uuid::from_u128(0x43);
457        let mock = Arc::new(MockGotifyFacade::new());
458        *mock.subscribe_result.lock() = Some(Ok(SubscribeOutcome {
459            id,
460            applications: vec!["phone-agent".to_owned()],
461            min_priority: None,
462            names_resolve: false,
463        }));
464        let tool = Subscribe { facade: mock.clone(), slot: caller_slot() };
465
466        let out = tool.call(input(serde_json::json!({ "applications": ["phone-agent"] }))).await;
467
468        assert!(!out.is_error, "the subscription was created, so this is not an error");
469        let json: serde_json::Value =
470            serde_json::from_str(text_of(&out)).expect("the result is the structured row");
471        assert_eq!(json["id"], id.to_string(), "the id still comes back");
472        assert_eq!(json["applications"], serde_json::json!(["phone-agent"]));
473        assert!(
474            json.get("min_priority").is_some_and(serde_json::Value::is_null),
475            "the key is present and null, not dropped: {json}",
476        );
477        assert_eq!(json["names_resolve"], false, "the unresolved filter is a field: {json}");
478    }
479
480    #[tokio::test]
481    async fn subscribe_surfaces_not_configured_error() {
482        let mock = Arc::new(MockGotifyFacade::new());
483        *mock.subscribe_result.lock() = Some(Err(GotifySubscribeError::NotConfigured));
484        let tool = Subscribe { facade: mock.clone(), slot: caller_slot() };
485
486        let out = tool.call(input(serde_json::json!({}))).await;
487        assert!(out.is_error);
488        assert!(
489            text_of(&out).contains("no Gotify server configured in forge.toml [gotify]"),
490            "unconfigured error surfaced to the LLM: {}",
491            text_of(&out),
492        );
493    }
494
495    #[tokio::test]
496    async fn list_returns_the_callers_own_subs() {
497        let a = Uuid::from_u128(0xa);
498        let b = Uuid::from_u128(0xb);
499        let mock = Arc::new(MockGotifyFacade::new());
500        *mock.subs.lock() = vec![sample_sub(a, "p"), sample_sub(b, "p")];
501        let tool = List { facade: mock.clone(), slot: caller_slot() };
502
503        let out = tool.call(input(serde_json::json!({}))).await;
504        assert!(!out.is_error);
505        assert!(
506            text_of(&out).contains(&a.to_string()) && text_of(&out).contains(&b.to_string()),
507            "both subscription ids appear in the list output",
508        );
509    }
510
511    /// An unsubscribe echoes the removed row whole, so a reader can name
512    /// the filter that stopped; a bare uuid names nothing a human can
513    /// place.
514    #[tokio::test]
515    async fn unsubscribe_echoes_the_removed_row() {
516        let id = Uuid::from_u128(0xc1);
517        let removed = sample_sub(id, "p");
518        let mock = Arc::new(MockGotifyFacade::new());
519        *mock.unsubscribe_result.lock() = Some(Ok(removed.clone()));
520        let tool = Unsubscribe { facade: mock.clone(), slot: caller_slot() };
521
522        let out = tool.call(input(serde_json::json!({ "id": id.to_string() }))).await;
523        assert!(!out.is_error, "unsubscribe succeeds: {out:?}");
524        assert_eq!(out.blocks.len(), 1, "the envelope stays one text block: {out:?}");
525        let json: serde_json::Value =
526            serde_json::from_str(text_of(&out)).expect("the result is the adopted envelope");
527        assert_eq!(json["status"], "deleted");
528        assert_eq!(
529            json["removed"],
530            sub_to_json(&removed),
531            "the echo is the whole row, not a hand-picked subset: {json}",
532        );
533        assert_eq!(mock.unsubscribe_calls.lock()[0].1, id);
534    }
535
536    /// A refusal has to say which case it is: an id that exists in the
537    /// project but is not the caller's own reads as *not yours*, an id
538    /// the project never had reads as *not there*. One string for both
539    /// lets a caller take its own refused unsubscribe as proof the
540    /// subscription is gone.
541    #[tokio::test]
542    async fn unsubscribe_refusals_name_the_case() {
543        let id = Uuid::from_u128(0xc1);
544        let mock = Arc::new(MockGotifyFacade::new());
545        let tool = Unsubscribe { facade: mock.clone(), slot: caller_slot() };
546
547        *mock.unsubscribe_result.lock() = Some(Err(GotifyUnsubscribeError::NotOwnedByCaller));
548        let not_yours = tool.call(input(serde_json::json!({ "id": id.to_string() }))).await;
549        assert!(not_yours.is_error, "a refused unsubscribe is an error");
550        assert_eq!(
551            text_of(&not_yours),
552            format!("subscription {id} exists in this project but was not subscribed by you"),
553            "a subscription that exists but is not the caller's own must say so",
554        );
555
556        *mock.unsubscribe_result.lock() = Some(Err(GotifyUnsubscribeError::NoSuchSubscription));
557        let not_there = tool.call(input(serde_json::json!({ "id": id.to_string() }))).await;
558        assert!(not_there.is_error, "an unknown id is an error");
559        assert_eq!(
560            text_of(&not_there),
561            format!("no subscription with id {id} in this project"),
562            "an id the project never had must not read as another session's subscription",
563        );
564    }
565
566    #[tokio::test]
567    async fn unsubscribe_rejects_bad_uuid_without_touching_facade() {
568        let mock = Arc::new(MockGotifyFacade::new());
569        let tool = Unsubscribe { facade: mock.clone(), slot: caller_slot() };
570
571        let out = tool.call(input(serde_json::json!({ "id": "not-a-uuid" }))).await;
572        assert!(out.is_error);
573        assert!(mock.unsubscribe_calls.lock().is_empty(), "a bad id never reaches the facade");
574    }
575
576    fn recent(app: &str, title: &str, priority: u8) -> GotifyRecent {
577        GotifyRecent {
578            app: app.to_owned(),
579            title: title.to_owned(),
580            message: "body".to_owned(),
581            priority,
582            date: "2026-07-04T09:18:00Z".to_owned(),
583        }
584    }
585
586    #[tokio::test]
587    async fn apps_returns_names_from_facade() {
588        let mock = Arc::new(MockGotifyFacade::new());
589        *mock.apps_result.lock() = Some(Ok(vec!["Backups".to_owned(), "CI".to_owned()]));
590        let tool = Apps { facade: mock.clone() };
591
592        let out = tool.call(input(serde_json::json!({}))).await;
593        assert!(!out.is_error, "apps succeeds: {}", text_of(&out));
594        assert!(
595            text_of(&out).contains("Backups") && text_of(&out).contains("CI"),
596            "both app names appear: {}",
597            text_of(&out),
598        );
599    }
600
601    #[tokio::test]
602    async fn apps_surfaces_not_configured_error() {
603        let mock = Arc::new(MockGotifyFacade::new());
604        *mock.apps_result.lock() = Some(Err(GotifyReadError::NotConfigured));
605        let tool = Apps { facade: mock.clone() };
606
607        let out = tool.call(input(serde_json::json!({}))).await;
608        assert!(out.is_error);
609        assert!(
610            text_of(&out).contains("no Gotify server configured in forge.toml [gotify]"),
611            "unconfigured error surfaced: {}",
612            text_of(&out),
613        );
614    }
615
616    #[tokio::test]
617    async fn recent_serializes_notifications_and_defaults_the_limit() {
618        let mock = Arc::new(MockGotifyFacade::new());
619        *mock.recent_result.lock() = Some(Ok(vec![recent("CI", "build failed", 8)]));
620        let tool = Recent { facade: mock.clone() };
621
622        let out = tool.call(input(serde_json::json!({}))).await;
623        assert!(!out.is_error, "recent succeeds: {}", text_of(&out));
624        assert!(
625            text_of(&out).contains("CI") && text_of(&out).contains("build failed"),
626            "notification fields serialized: {}",
627            text_of(&out),
628        );
629        let calls = mock.recent_calls.lock();
630        assert_eq!(calls.len(), 1);
631        assert_eq!(calls[0].2, 20, "an omitted limit defaults to 20");
632    }
633
634    #[tokio::test]
635    async fn recent_passes_explicit_filters_and_limit_to_facade() {
636        let mock = Arc::new(MockGotifyFacade::new());
637        *mock.recent_result.lock() = Some(Ok(vec![]));
638        let tool = Recent { facade: mock.clone() };
639
640        let out = tool
641            .call(input(
642                serde_json::json!({ "applications": ["CI"], "min_priority": 5, "limit": 3 }),
643            ))
644            .await;
645        assert!(!out.is_error);
646        let calls = mock.recent_calls.lock();
647        assert_eq!(calls[0].0, vec!["CI".to_owned()]);
648        assert_eq!(calls[0].1, Some(5));
649        assert_eq!(calls[0].2, 3);
650    }
651
652    #[tokio::test]
653    async fn recent_surfaces_fetch_error() {
654        let mock = Arc::new(MockGotifyFacade::new());
655        *mock.recent_result.lock() = Some(Err(GotifyReadError::Fetch("boom".to_owned())));
656        let tool = Recent { facade: mock.clone() };
657
658        let out = tool.call(input(serde_json::json!({}))).await;
659        assert!(out.is_error);
660        assert!(
661            text_of(&out).contains("boom"),
662            "fetch failure surfaced to the LLM: {}",
663            text_of(&out),
664        );
665    }
666
667    #[test]
668    fn tool_names_are_the_gotify_family() {
669        let mock = MockGotifyFacade::new().into_arc();
670        let slot = caller_slot();
671        let subscribe = Subscribe { facade: mock.clone(), slot: slot.clone() };
672        let list = List { facade: mock.clone(), slot: slot.clone() };
673        let unsubscribe = Unsubscribe { facade: mock.clone(), slot };
674        let apps = Apps { facade: mock.clone() };
675        let recent = Recent { facade: mock };
676        assert_eq!(subscribe.name(), "gotify__subscribe");
677        assert_eq!(list.name(), "gotify__list");
678        assert_eq!(unsubscribe.name(), "gotify__unsubscribe");
679        assert_eq!(apps.name(), "gotify__apps");
680        assert_eq!(recent.name(), "gotify__recent");
681    }
682}