1use 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
33pub(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
54fn 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
80const 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 #[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 #[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 #[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 #[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(¬_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(¬_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}