1use std::sync::Arc;
17
18use rmcp::handler::server::router::tool::ToolRouter;
19use rmcp::handler::server::wrapper::Parameters;
20use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo};
21use rmcp::{ErrorData, ServerHandler, tool, tool_handler, tool_router};
22use serde::Deserialize;
23use serde_json::{Value, json};
24
25use prospero_core::fleet_provider::FleetProvider;
26use prospero_core::model::{AgentId, DrainPolicy, TaskSpec};
27use prospero_core::store::Store;
28
29#[must_use]
36pub fn service(
37 fleet: Arc<dyn FleetProvider>,
38 store: Arc<dyn Store>,
39) -> rmcp::transport::streamable_http_server::StreamableHttpService<
40 FleetMcp,
41 rmcp::transport::streamable_http_server::session::local::LocalSessionManager,
42> {
43 rmcp::transport::streamable_http_server::StreamableHttpService::new(
44 move || Ok(FleetMcp::new(fleet.clone(), store.clone())),
45 Arc::new(
46 rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(),
47 ),
48 rmcp::transport::streamable_http_server::StreamableHttpServerConfig::default(),
49 )
50}
51
52#[derive(Clone)]
54pub struct FleetMcp {
55 fleet: Arc<dyn FleetProvider>,
56 store: Arc<dyn Store>,
57 tool_router: ToolRouter<Self>,
58}
59
60impl FleetMcp {
61 #[must_use]
63 pub fn new(fleet: Arc<dyn FleetProvider>, store: Arc<dyn Store>) -> Self {
64 Self {
65 fleet,
66 store,
67 tool_router: Self::tool_router(),
68 }
69 }
70
71 fn err(msg: impl Into<String>) -> Result<CallToolResult, ErrorData> {
74 Ok(CallToolResult::error(vec![Content::text(msg.into())]))
75 }
76
77 fn ok(value: Value) -> Result<CallToolResult, ErrorData> {
78 Ok(CallToolResult::success(vec![Content::json(value)?]))
79 }
80}
81
82#[derive(Debug, Deserialize, schemars::JsonSchema)]
84pub struct AgentArgs {
85 pub agent_id: String,
87}
88
89#[derive(Debug, Deserialize, schemars::JsonSchema)]
91pub struct SpawnArgs {
92 pub workspace: String,
94 pub prompt: String,
96 #[serde(default)]
98 pub label: Option<String>,
99 #[serde(default)]
101 pub model: Option<String>,
102 #[serde(default)]
104 pub interactive: bool,
105 #[serde(default)]
107 pub timeout_secs: Option<u64>,
108}
109
110#[derive(Debug, Deserialize, schemars::JsonSchema)]
112pub struct EventsArgs {
113 pub agent_id: String,
115 #[serde(default)]
117 pub from: u64,
118 #[serde(default)]
121 pub limit: Option<usize>,
122}
123
124#[derive(Debug, Deserialize, schemars::JsonSchema)]
126pub struct InputArgs {
127 pub agent_id: String,
129 pub text: String,
131}
132
133const EVENTS_DEFAULT: usize = 100;
135const EVENTS_MAX: usize = 500;
136
137#[tool_router]
138impl FleetMcp {
139 #[tool(description = "List the workspaces prospero supervises, with health and agent counts.")]
140 async fn prospero_list_workspaces(&self) -> Result<CallToolResult, ErrorData> {
141 let snap = self.fleet.snapshot().await;
142 let workspaces: Vec<Value> = snap
143 .workspaces
144 .iter()
145 .map(|w| {
146 json!({
147 "name": w.name,
148 "healthy": matches!(w.health, prospero_core::model::WorkspaceHealth::Healthy),
149 "agents": w.agents.len(),
150 })
151 })
152 .collect();
153 Self::ok(json!({ "workspaces": workspaces }))
154 }
155
156 #[tool(description = "List every agent in the fleet with its status and workspace.")]
157 async fn prospero_list_agents(&self) -> Result<CallToolResult, ErrorData> {
158 let snap = self.fleet.snapshot().await;
159 let agents: Vec<Value> = snap
160 .workspaces
161 .iter()
162 .flat_map(|w| w.agents.iter())
163 .map(agent_json)
164 .collect();
165 Self::ok(json!({ "agents": agents }))
166 }
167
168 #[tool(description = "Launch an agent in a workspace; returns its id.")]
169 async fn prospero_spawn_agent(
170 &self,
171 Parameters(args): Parameters<SpawnArgs>,
172 ) -> Result<CallToolResult, ErrorData> {
173 let mut request = prospero_core::fleet::SpawnRequest::new(args.prompt);
174 request.label = args.label;
175 request.model = args.model;
176 request.interactive = args.interactive;
177 request.timeout_secs = args.timeout_secs;
178
179 match self
180 .fleet
181 .ensure_agent(TaskSpec {
182 workspace: args.workspace.clone(),
183 request,
184 })
185 .await
186 {
187 Ok(handle) => Self::ok(json!({
188 "agent_id": handle.id.to_string(),
189 "workspace": args.workspace,
190 "created": handle.created,
191 })),
192 Err(e) => Self::err(format!("spawn failed: {e}")),
193 }
194 }
195
196 #[tool(description = "Read one agent's current state.")]
197 async fn prospero_agent_status(
198 &self,
199 Parameters(args): Parameters<AgentArgs>,
200 ) -> Result<CallToolResult, ErrorData> {
201 let snap = self.fleet.snapshot().await;
202 match snap.find_agent(&args.agent_id) {
203 Some((_, agent)) => Self::ok(agent_json(agent)),
204 None => Self::err(format!("unknown agent: {}", args.agent_id)),
205 }
206 }
207
208 #[tool(description = "Read an agent's recorded events from `from` onward (bounded).")]
209 async fn prospero_agent_events(
210 &self,
211 Parameters(args): Parameters<EventsArgs>,
212 ) -> Result<CallToolResult, ErrorData> {
213 let limit = args.limit.unwrap_or(EVENTS_DEFAULT).min(EVENTS_MAX);
214 let events = match self.store.replay(&args.agent_id, args.from).await {
215 Ok(events) => events,
216 Err(e) => return Self::err(format!("reading history failed: {e}")),
217 };
218 let total = events.len();
219 let page: Vec<&prospero_core::FleetEvent> = events.iter().take(limit).collect();
220 let next = page.last().map(|e| e.seq + 1);
221 Self::ok(json!({
222 "events": page,
223 "truncated": total > limit,
226 "next_from": next,
227 }))
228 }
229
230 #[tool(description = "Send a message to an interactive agent, resuming its run.")]
231 async fn prospero_send_input(
232 &self,
233 Parameters(args): Parameters<InputArgs>,
234 ) -> Result<CallToolResult, ErrorData> {
235 let frame = prospero_core::AttachInbound::UserMessage { text: args.text };
236 match self
237 .fleet
238 .send_input(&AgentId::from(args.agent_id.clone()), frame)
239 .await
240 {
241 Ok(()) => Self::ok(json!({ "delivered": true })),
242 Err(e) => Self::err(format!("send failed: {e}")),
243 }
244 }
245
246 #[tool(description = "Signal end-of-input to an interactive agent; it finishes after.")]
247 async fn prospero_end_input(
248 &self,
249 Parameters(args): Parameters<AgentArgs>,
250 ) -> Result<CallToolResult, ErrorData> {
251 match self
252 .fleet
253 .send_input(
254 &AgentId::from(args.agent_id.clone()),
255 prospero_core::AttachInbound::EndInput,
256 )
257 .await
258 {
259 Ok(()) => Self::ok(json!({ "delivered": true })),
260 Err(e) => Self::err(format!("end-input failed: {e}")),
261 }
262 }
263
264 #[tool(description = "Kill a running agent.")]
265 async fn prospero_kill_agent(
266 &self,
267 Parameters(args): Parameters<AgentArgs>,
268 ) -> Result<CallToolResult, ErrorData> {
269 match self
270 .fleet
271 .stop_agent(&AgentId::from(args.agent_id.clone()), DrainPolicy::Kill)
272 .await
273 {
274 Ok(()) => Self::ok(json!({ "killed": args.agent_id })),
275 Err(e) => Self::err(format!("kill failed: {e}")),
276 }
277 }
278
279 #[tool(description = "Kill and respawn an agent with the same spec; returns the new id.")]
280 async fn prospero_respawn_agent(
281 &self,
282 Parameters(args): Parameters<AgentArgs>,
283 ) -> Result<CallToolResult, ErrorData> {
284 match self
285 .fleet
286 .restart_agent(&AgentId::from(args.agent_id.clone()))
287 .await
288 {
289 Ok(id) => Self::ok(json!({ "agent_id": id.to_string() })),
290 Err(e) => Self::err(format!("respawn failed: {e}")),
291 }
292 }
293}
294
295fn agent_json(agent: &prospero_core::model::Agent) -> Value {
297 json!({
298 "agent_id": agent.id,
299 "name": agent.name,
300 "workspace": agent.workspace,
301 "status": agent.status,
302 "started_at": agent.started_at,
303 "interactive": agent.interactive,
304 "isolated": agent.isolated,
305 "reason": agent.reason,
307 "detail": agent.detail,
308 })
309}
310
311#[tool_handler(router = self.tool_router)]
312impl ServerHandler for FleetMcp {
313 fn get_info(&self) -> ServerInfo {
314 let mut info = ServerInfo::default();
317 info.instructions = Some(
318 "Drive a prospero fleet: list workspaces and agents, spawn, read history, \
319 steer an interactive agent, kill or respawn."
320 .into(),
321 );
322 info.capabilities = ServerCapabilities::builder().enable_tools().build();
323 info
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use prospero_core::LocalFleet;
331 use prospero_core::discovery::{DiscoveryEnv, EnsureConfig};
332 use prospero_core::fleet::{FleetConfig, FleetManager};
333 use prospero_core::store::JsonlStore;
334 use prospero_core::testkit::FakeCaliband;
335
336 async fn harness() -> (FleetMcp, FleetManager, FakeCaliband, tempfile::TempDir) {
339 let dir = tempfile::tempdir().unwrap();
340 let mut config = FleetConfig::new("mcp-host", dir.path());
341 config.discovery_env = DiscoveryEnv {
342 caliban_daemon_runtime_dir: Some(dir.path().to_path_buf()),
343 xdg_runtime_dir: None,
344 tmpdir: None,
345 };
346 config.ensure = EnsureConfig {
347 autostart: false,
348 ..EnsureConfig::default()
349 };
350 let root = dir.path().join("repo");
351 std::fs::create_dir_all(&root).unwrap();
352 let socket =
353 prospero_core::discovery::resolve_socket(&root, &config.discovery_env).unwrap();
354 let fake = FakeCaliband::start_at(&socket).await.unwrap();
355
356 let store = Arc::new(JsonlStore::open(dir.path()).unwrap());
357 let manager = FleetManager::new(config, store.clone()).await.unwrap();
358 manager.add_repo("repo", &root).await.unwrap();
359
360 let fleet: Arc<dyn FleetProvider> = Arc::new(LocalFleet::new(manager.clone()));
361 (FleetMcp::new(fleet, store), manager, fake, dir)
362 }
363
364 fn payload(result: CallToolResult) -> Value {
366 assert!(
367 !result.is_error.unwrap_or(false),
368 "tool reported an error: {:?}",
369 result.content
370 );
371 let raw = result
372 .content
373 .first()
374 .and_then(|c| c.as_text().map(|t| t.text.clone()))
375 .expect("a tool result carries content");
376 serde_json::from_str(&raw).expect("tool content is JSON")
377 }
378
379 #[tokio::test]
380 async fn listing_reports_the_workspaces_prospero_supervises() {
381 let (mcp, _manager, _fake, _dir) = harness().await;
382 let v = payload(mcp.prospero_list_workspaces().await.unwrap());
383 let names: Vec<&str> = v["workspaces"]
384 .as_array()
385 .unwrap()
386 .iter()
387 .map(|w| w["name"].as_str().unwrap())
388 .collect();
389 assert_eq!(names, vec!["repo"]);
390 }
391
392 #[tokio::test]
395 async fn a_spawned_agent_is_visible_to_the_other_tools() {
396 let (mcp, manager, _fake, _dir) = harness().await;
397
398 let spawned = payload(
399 mcp.prospero_spawn_agent(Parameters(SpawnArgs {
400 workspace: "repo".into(),
401 prompt: "do the thing".into(),
402 label: Some("mcp-spawn".into()),
403 model: None,
404 interactive: false,
405 timeout_secs: None,
406 }))
407 .await
408 .unwrap(),
409 );
410 let id = spawned["agent_id"].as_str().expect("an id").to_string();
411 assert_eq!(spawned["workspace"], "repo");
412
413 manager.poll_repo_once("repo").await;
416
417 let listed = payload(mcp.prospero_list_agents().await.unwrap());
418 assert!(
419 listed["agents"]
420 .as_array()
421 .unwrap()
422 .iter()
423 .any(|a| a["agent_id"] == id.as_str()),
424 "the spawned agent must appear in the fleet: {listed}"
425 );
426
427 let status = payload(
428 mcp.prospero_agent_status(Parameters(AgentArgs {
429 agent_id: id.clone(),
430 }))
431 .await
432 .unwrap(),
433 );
434 assert_eq!(status["agent_id"], id.as_str());
435 }
436
437 #[tokio::test]
440 async fn an_unknown_agent_is_a_readable_tool_error() {
441 let (mcp, _manager, _fake, _dir) = harness().await;
442 let result = mcp
443 .prospero_agent_status(Parameters(AgentArgs {
444 agent_id: "nope".into(),
445 }))
446 .await
447 .unwrap();
448 assert!(result.is_error.unwrap_or(false), "{result:?}");
449 let text = result
450 .content
451 .first()
452 .and_then(|c| c.as_text().map(|t| t.text.clone()))
453 .unwrap_or_default();
454 assert!(
455 text.contains("nope"),
456 "the message must name the id: {text}"
457 );
458 }
459
460 #[tokio::test]
463 async fn agent_events_are_bounded_and_say_so() {
464 let (mcp, _manager, _fake, dir) = harness().await;
465 let store = JsonlStore::open(dir.path()).unwrap();
466 for seq in 1..=5u64 {
467 store
468 .append(&prospero_core::FleetEvent {
469 seq,
470 ts: "2026-09-20T00:00:00Z".into(),
471 repo: "repo".into(),
472 agent_id: "a1".into(),
473 kind: prospero_core::EventKind::Output {
474 stream: prospero_core::event::OutputStream::Stdout,
475 chunk: format!("chunk {seq}"),
476 },
477 actor: None,
478 on_behalf_of: None,
479 })
480 .await
481 .unwrap();
482 }
483
484 let page = payload(
485 mcp.prospero_agent_events(Parameters(EventsArgs {
486 agent_id: "a1".into(),
487 from: 0,
488 limit: Some(2),
489 }))
490 .await
491 .unwrap(),
492 );
493 assert_eq!(page["events"].as_array().unwrap().len(), 2);
494 assert_eq!(page["truncated"], true);
495 assert_eq!(page["next_from"], 3, "resume after the last one delivered");
496
497 let rest = payload(
498 mcp.prospero_agent_events(Parameters(EventsArgs {
499 agent_id: "a1".into(),
500 from: 3,
501 limit: None,
502 }))
503 .await
504 .unwrap(),
505 );
506 assert_eq!(rest["events"].as_array().unwrap().len(), 3);
507 assert_eq!(rest["truncated"], false);
508 }
509}