1use std::path::PathBuf;
2use std::process::Command;
3
4use crate::core::{Event, EventStore, Result, ShuttleError};
5use crate::store::SqliteEventStore;
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value};
8use uuid::Uuid;
9
10#[derive(Clone)]
11pub struct McpRuntime {
12 pub store: SqliteEventStore,
13 pub cwd: PathBuf,
14 pub workspace_id: String,
15 pub agent: String,
16 pub session_id: String,
17}
18
19#[derive(Debug, Deserialize)]
20pub struct Request {
21 pub jsonrpc: Option<String>,
22 pub id: Option<Value>,
23 pub method: String,
24 #[serde(default)]
25 pub params: Value,
26}
27
28#[derive(Debug, Serialize)]
29struct Tool {
30 name: &'static str,
31 description: &'static str,
32 #[serde(rename = "inputSchema")]
33 input_schema: Value,
34 #[serde(rename = "outputSchema")]
35 output_schema: Value,
36}
37
38pub async fn handle_request(runtime: &McpRuntime, request: Request) -> Value {
39 let id = request.id.clone().unwrap_or(Value::Null);
40 if request.jsonrpc.as_deref() != Some("2.0") {
41 return error(id, -32600, "invalid jsonrpc version");
42 }
43
44 match request.method.as_str() {
45 "initialize" => ok(
46 id,
47 json!({
48 "protocolVersion": "2025-11-25",
49 "capabilities": { "tools": {} },
50 "serverInfo": { "name": "shuttle-rs", "version": env!("CARGO_PKG_VERSION") }
51 }),
52 ),
53 "notifications/initialized" => json!({"jsonrpc": "2.0"}),
54 "tools/list" => ok(id, json!({ "tools": tools() })),
55 "tools/call" => {
56 let tool_name = request
57 .params
58 .get("name")
59 .and_then(Value::as_str)
60 .map(ToOwned::to_owned);
61 match call_tool(runtime, request.params).await {
62 Ok(value) => ok(
63 id,
64 json!({
65 "content": [{ "type": "text", "text": value.to_string() }],
66 "structuredContent": structured_content(tool_name.as_deref(), &value),
67 }),
68 ),
69 Err(err) => error(id, -32603, &err.to_string()),
70 }
71 }
72 _ => error(id, -32601, "method not found"),
73 }
74}
75
76async fn call_tool(runtime: &McpRuntime, params: Value) -> Result<Value> {
77 let name = params
78 .get("name")
79 .and_then(Value::as_str)
80 .ok_or_else(|| ShuttleError::Store("missing tool name".to_owned()))?;
81 let args = params
82 .get("arguments")
83 .cloned()
84 .unwrap_or_else(|| json!({}));
85
86 match name {
87 "shuttle_memory_search" | "recall" => {
88 let query = string_arg(&args, "query")?;
89 let events = crate::memory::recall(&runtime.store, &query).await?;
90 serde_json::to_value(events).map_err(|err| ShuttleError::Serialization(err.to_string()))
91 }
92 "shuttle_memory_store" | "remember" => {
93 let content = string_arg(&args, "content")?;
94 let event = with_repo_metadata(
95 crate::memory::new_memory(
96 runtime.workspace_id.clone(),
97 runtime.agent.clone(),
98 runtime.session_id.clone(),
99 content,
100 ),
101 runtime,
102 );
103 let event = runtime.store.append(event).await?;
104 serde_json::to_value(event).map_err(|err| ShuttleError::Serialization(err.to_string()))
105 }
106 "shuttle_message_inbox" | "inbox" => {
107 let agent = args
108 .get("agent")
109 .and_then(Value::as_str)
110 .unwrap_or(&runtime.agent);
111 let events = crate::message::inbox(&runtime.store, agent).await?;
112 serde_json::to_value(events).map_err(|err| ShuttleError::Serialization(err.to_string()))
113 }
114 "shuttle_message_history" | "history" => {
115 let events = crate::message::history(&runtime.store).await?;
116 serde_json::to_value(events).map_err(|err| ShuttleError::Serialization(err.to_string()))
117 }
118 "shuttle_message_send" | "send" => {
119 let to_agent = string_arg(&args, "agent")?;
120 let content = string_arg(&args, "content")?;
121 let event = with_repo_metadata(
122 crate::message::new_message(
123 runtime.workspace_id.clone(),
124 runtime.agent.clone(),
125 runtime.session_id.clone(),
126 to_agent,
127 content,
128 ),
129 runtime,
130 );
131 let event = runtime.store.append(event).await?;
132 serde_json::to_value(event).map_err(|err| ShuttleError::Serialization(err.to_string()))
133 }
134 "shuttle_task_list" | "tasks" => {
135 let tasks =
136 crate::task::tasks(&runtime.store, Some(&runtime.workspace_id), None).await?;
137 serde_json::to_value(tasks).map_err(|err| ShuttleError::Serialization(err.to_string()))
138 }
139 "shuttle_task_create" => {
140 let content = string_arg(&args, "content")?;
141 let event = with_repo_metadata(
142 crate::task::new_task(
143 runtime.workspace_id.clone(),
144 runtime.agent.clone(),
145 runtime.session_id.clone(),
146 content,
147 ),
148 runtime,
149 );
150 let event = runtime.store.append(event).await?;
151 serde_json::to_value(event).map_err(|err| ShuttleError::Serialization(err.to_string()))
152 }
153 "shuttle_task_claim" => {
154 let id = Uuid::parse_str(&string_arg(&args, "id")?)
155 .map_err(|err| ShuttleError::Store(err.to_string()))?;
156 crate::task::ensure_task_exists(&runtime.store, &runtime.workspace_id, id).await?;
157 let event = with_repo_metadata(
158 crate::task::new_claim(
159 runtime.workspace_id.clone(),
160 runtime.agent.clone(),
161 runtime.session_id.clone(),
162 id,
163 ),
164 runtime,
165 );
166 let event = runtime.store.append(event).await?;
167 serde_json::to_value(event).map_err(|err| ShuttleError::Serialization(err.to_string()))
168 }
169 "shuttle_task_update" => {
170 let id = Uuid::parse_str(&string_arg(&args, "id")?)
171 .map_err(|err| ShuttleError::Store(err.to_string()))?;
172 let content = string_arg(&args, "content")?;
173 crate::task::ensure_task_exists(&runtime.store, &runtime.workspace_id, id).await?;
174 let event = with_repo_metadata(
175 crate::task::new_task_update(
176 runtime.workspace_id.clone(),
177 runtime.agent.clone(),
178 runtime.session_id.clone(),
179 id,
180 content,
181 ),
182 runtime,
183 );
184 let event = runtime.store.append(event).await?;
185 serde_json::to_value(event).map_err(|err| ShuttleError::Serialization(err.to_string()))
186 }
187 "shuttle_task_done" => {
188 let id = Uuid::parse_str(&string_arg(&args, "id")?)
189 .map_err(|err| ShuttleError::Store(err.to_string()))?;
190 crate::task::ensure_task_exists(&runtime.store, &runtime.workspace_id, id).await?;
191 let event = with_repo_metadata(
192 crate::task::new_task_done(
193 runtime.workspace_id.clone(),
194 runtime.agent.clone(),
195 runtime.session_id.clone(),
196 id,
197 ),
198 runtime,
199 );
200 let event = runtime.store.append(event).await?;
201 serde_json::to_value(event).map_err(|err| ShuttleError::Serialization(err.to_string()))
202 }
203 "shuttle_handoff_request" => {
204 let to_agent = string_arg(&args, "agent")?;
205 let content = string_arg(&args, "content")?;
206 let event = with_repo_metadata(
207 crate::task::new_handoff(
208 runtime.workspace_id.clone(),
209 runtime.agent.clone(),
210 runtime.session_id.clone(),
211 to_agent,
212 content,
213 ),
214 runtime,
215 );
216 let event = runtime.store.append(event).await?;
217 serde_json::to_value(event).map_err(|err| ShuttleError::Serialization(err.to_string()))
218 }
219 "shuttle_handoff_list" => {
220 let handoffs =
221 crate::task::handoffs(&runtime.store, Some(&runtime.workspace_id), None).await?;
222 serde_json::to_value(handoffs)
223 .map_err(|err| ShuttleError::Serialization(err.to_string()))
224 }
225 "shuttle_handoff_accept" => {
226 let id = Uuid::parse_str(&string_arg(&args, "id")?)
227 .map_err(|err| ShuttleError::Store(err.to_string()))?;
228 crate::task::ensure_handoff_exists(&runtime.store, &runtime.workspace_id, id).await?;
229 let event = with_repo_metadata(
230 crate::task::new_handoff_accept(
231 runtime.workspace_id.clone(),
232 runtime.agent.clone(),
233 runtime.session_id.clone(),
234 id,
235 ),
236 runtime,
237 );
238 let event = runtime.store.append(event).await?;
239 serde_json::to_value(event).map_err(|err| ShuttleError::Serialization(err.to_string()))
240 }
241 "shuttle_handoff_done" => {
242 let id = Uuid::parse_str(&string_arg(&args, "id")?)
243 .map_err(|err| ShuttleError::Store(err.to_string()))?;
244 crate::task::ensure_handoff_exists(&runtime.store, &runtime.workspace_id, id).await?;
245 let event = with_repo_metadata(
246 crate::task::new_handoff_done(
247 runtime.workspace_id.clone(),
248 runtime.agent.clone(),
249 runtime.session_id.clone(),
250 id,
251 ),
252 runtime,
253 );
254 let event = runtime.store.append(event).await?;
255 serde_json::to_value(event).map_err(|err| ShuttleError::Serialization(err.to_string()))
256 }
257 "shuttle_repo_context" | "context" => {
258 let context = crate::context::assemble_context(
259 &runtime.store,
260 &runtime.cwd,
261 &runtime.workspace_id,
262 &runtime.agent,
263 )
264 .await?;
265 serde_json::to_value(context)
266 .map_err(|err| ShuttleError::Serialization(err.to_string()))
267 }
268 "shuttle_repo_status" => {
269 let status = crate::context::repo_status(&runtime.cwd)?;
270 serde_json::to_value(status).map_err(|err| ShuttleError::Serialization(err.to_string()))
271 }
272 "shuttle_repo_changed_files" => {
273 let files = git(&runtime.cwd, ["diff", "--name-only"])?;
274 Ok(json!({
275 "files": files.lines().filter(|line| !line.trim().is_empty()).collect::<Vec<_>>()
276 }))
277 }
278 "shuttle_repo_diff" => {
279 let max_bytes = args
280 .get("max_bytes")
281 .and_then(Value::as_u64)
282 .unwrap_or(60_000)
283 .min(200_000) as usize;
284 let path = args.get("path").and_then(Value::as_str);
285 let diff = if let Some(path) = path {
286 git_vec(&runtime.cwd, vec!["diff", "--", path])?
287 } else {
288 git(&runtime.cwd, ["diff"])?
289 };
290 let truncated = diff.len() > max_bytes;
291 let diff = if truncated {
292 diff.chars().take(max_bytes).collect::<String>()
293 } else {
294 diff
295 };
296 Ok(json!({ "diff": diff, "truncated": truncated }))
297 }
298 _ => Err(ShuttleError::Store(format!("unknown tool: {name}"))),
299 }
300}
301
302fn with_repo_metadata(mut event: Event, runtime: &McpRuntime) -> Event {
303 if let Ok(status) = crate::context::repo_status(&runtime.cwd) {
304 let repo_id = crate::context::repo_id(&status);
305 event.repo_id = Some(repo_id.clone());
306 event.repo_path = Some(status.repo_path.clone());
307 event.git_remote = status.git_remote.clone();
308 event.branch = Some(status.branch.clone());
309 event.commit = Some(status.commit.clone());
310 event.repo_dirty = Some(status.dirty);
311 if let Some(metadata) = event.metadata_json.as_object_mut() {
312 metadata.insert("repo_id".to_owned(), json!(repo_id));
313 metadata.insert("repo_path".to_owned(), json!(status.repo_path));
314 metadata.insert("git_remote".to_owned(), json!(status.git_remote));
315 metadata.insert("branch".to_owned(), json!(status.branch));
316 metadata.insert("commit".to_owned(), json!(status.commit));
317 metadata.insert("repo_dirty".to_owned(), json!(status.dirty));
318 metadata.insert("dirty_files".to_owned(), json!(status.dirty_files));
319 }
320 }
321 event
322}
323
324fn string_arg(args: &Value, name: &str) -> Result<String> {
325 args.get(name)
326 .and_then(Value::as_str)
327 .map(ToOwned::to_owned)
328 .ok_or_else(|| ShuttleError::Store(format!("missing string argument: {name}")))
329}
330
331fn tools() -> Vec<Tool> {
332 vec![
333 tool(
334 "remember",
335 "Store a local Shuttle memory",
336 event_output_schema(),
337 ),
338 tool(
339 "recall",
340 "Search local Shuttle memories",
341 events_output_schema(),
342 ),
343 tool("inbox", "Read an agent inbox", events_output_schema()),
344 tool("send", "Send a message to an agent", event_output_schema()),
345 tool("history", "Read message history", events_output_schema()),
346 tool(
347 "context",
348 "Read assembled repo context",
349 context_output_schema(),
350 ),
351 tool("tasks", "List Shuttle task state", tasks_output_schema()),
352 tool(
353 "shuttle_memory_search",
354 "Search local Shuttle memories",
355 events_output_schema(),
356 ),
357 tool(
358 "shuttle_memory_store",
359 "Store a local Shuttle memory",
360 event_output_schema(),
361 ),
362 tool(
363 "shuttle_message_inbox",
364 "Read an agent inbox",
365 events_output_schema(),
366 ),
367 tool(
368 "shuttle_message_history",
369 "Read message history",
370 events_output_schema(),
371 ),
372 tool(
373 "shuttle_message_send",
374 "Send a message to an agent",
375 event_output_schema(),
376 ),
377 tool(
378 "shuttle_task_list",
379 "List Shuttle task state",
380 tasks_output_schema(),
381 ),
382 tool(
383 "shuttle_task_create",
384 "Create a Shuttle task",
385 event_output_schema(),
386 ),
387 tool(
388 "shuttle_task_claim",
389 "Claim a Shuttle task",
390 event_output_schema(),
391 ),
392 tool(
393 "shuttle_task_update",
394 "Update a Shuttle task",
395 event_output_schema(),
396 ),
397 tool(
398 "shuttle_task_done",
399 "Complete a Shuttle task",
400 event_output_schema(),
401 ),
402 tool(
403 "shuttle_handoff_request",
404 "Request a Shuttle handoff",
405 event_output_schema(),
406 ),
407 tool(
408 "shuttle_handoff_list",
409 "List Shuttle handoff state",
410 handoffs_output_schema(),
411 ),
412 tool(
413 "shuttle_handoff_accept",
414 "Accept a Shuttle handoff",
415 event_output_schema(),
416 ),
417 tool(
418 "shuttle_handoff_done",
419 "Complete a Shuttle handoff",
420 event_output_schema(),
421 ),
422 tool(
423 "shuttle_repo_context",
424 "Read assembled repo context",
425 context_output_schema(),
426 ),
427 tool(
428 "shuttle_repo_status",
429 "Read Git repo status",
430 repo_status_output_schema(),
431 ),
432 tool(
433 "shuttle_repo_changed_files",
434 "List changed files in the current Git repo",
435 changed_files_output_schema(),
436 ),
437 tool(
438 "shuttle_repo_diff",
439 "Read the current Git diff, optionally for one path",
440 diff_output_schema(),
441 ),
442 ]
443}
444
445fn tool(name: &'static str, description: &'static str, output_schema: Value) -> Tool {
446 Tool {
447 name,
448 description,
449 input_schema: json!({ "type": "object", "additionalProperties": true }),
450 output_schema,
451 }
452}
453
454fn structured_content(tool_name: Option<&str>, value: &Value) -> Value {
455 match tool_name {
456 Some(
457 "remember"
458 | "send"
459 | "shuttle_memory_store"
460 | "shuttle_message_send"
461 | "shuttle_task_create"
462 | "shuttle_task_claim"
463 | "shuttle_task_update"
464 | "shuttle_task_done"
465 | "shuttle_handoff_request"
466 | "shuttle_handoff_accept"
467 | "shuttle_handoff_done",
468 ) => json!({ "event": value }),
469 Some(
470 "recall"
471 | "inbox"
472 | "history"
473 | "shuttle_memory_search"
474 | "shuttle_message_inbox"
475 | "shuttle_message_history",
476 ) => {
477 json!({ "events": value })
478 }
479 Some("tasks" | "shuttle_task_list") => json!({ "tasks": value }),
480 Some("shuttle_handoff_list") => json!({ "handoffs": value }),
481 _ => value.clone(),
482 }
483}
484
485fn event_output_schema() -> Value {
486 object_schema(json!({ "event": event_schema() }), vec!["event"])
487}
488
489fn events_output_schema() -> Value {
490 object_schema(
491 json!({ "events": array_schema(event_schema()) }),
492 vec!["events"],
493 )
494}
495
496fn tasks_output_schema() -> Value {
497 object_schema(
498 json!({ "tasks": array_schema(task_schema()) }),
499 vec!["tasks"],
500 )
501}
502
503fn handoffs_output_schema() -> Value {
504 object_schema(
505 json!({ "handoffs": array_schema(handoff_schema()) }),
506 vec!["handoffs"],
507 )
508}
509
510fn context_output_schema() -> Value {
511 object_schema(
512 json!({
513 "repo": string_schema("Repository path"),
514 "branch": string_schema("Git branch"),
515 "commit": string_schema("Git commit"),
516 "git_remote": nullable_string_schema("Git remote URL"),
517 "dirty": boolean_schema("Whether the repository has changes"),
518 "dirty_files": array_schema(string_schema("Changed file path")),
519 "open_tasks": array_schema(task_schema()),
520 "claimed_tasks": array_schema(task_schema()),
521 "recent_decisions": array_schema(event_schema()),
522 "related_memories": array_schema(event_schema()),
523 "recent_messages": array_schema(event_schema()),
524 "pending_handoffs": array_schema(handoff_schema()),
525 "recent_completed_handoffs": array_schema(handoff_schema()),
526 "inbox": array_schema(event_schema()),
527 }),
528 vec![
529 "repo",
530 "branch",
531 "commit",
532 "dirty",
533 "dirty_files",
534 "open_tasks",
535 "claimed_tasks",
536 "recent_decisions",
537 "related_memories",
538 "recent_messages",
539 "pending_handoffs",
540 "recent_completed_handoffs",
541 "inbox",
542 ],
543 )
544}
545
546fn repo_status_output_schema() -> Value {
547 object_schema(
548 json!({
549 "repo_path": string_schema("Repository path"),
550 "git_remote": nullable_string_schema("Git remote URL"),
551 "branch": string_schema("Git branch"),
552 "commit": string_schema("Git commit"),
553 "dirty": boolean_schema("Whether the repository has changes"),
554 "dirty_files": array_schema(string_schema("Changed file path")),
555 }),
556 vec!["repo_path", "branch", "commit", "dirty", "dirty_files"],
557 )
558}
559
560fn changed_files_output_schema() -> Value {
561 object_schema(
562 json!({ "files": array_schema(string_schema("Changed file path")) }),
563 vec!["files"],
564 )
565}
566
567fn diff_output_schema() -> Value {
568 object_schema(
569 json!({
570 "diff": string_schema("Git diff text"),
571 "truncated": boolean_schema("Whether the diff was truncated"),
572 }),
573 vec!["diff", "truncated"],
574 )
575}
576
577fn event_schema() -> Value {
578 object_schema(
579 json!({
580 "id": string_schema("Event UUID"),
581 "event_type": string_schema("Event type"),
582 "workspace_id": string_schema("Workspace identifier"),
583 "agent": string_schema("Agent identifier"),
584 "session_id": string_schema("Session identifier"),
585 "content": string_schema("Event content"),
586 "tags": array_schema(string_schema("Event tag")),
587 "metadata_json": json!({ "type": "object", "additionalProperties": true }),
588 "created_at": string_schema("RFC3339 creation timestamp"),
589 }),
590 vec![
591 "id",
592 "event_type",
593 "workspace_id",
594 "agent",
595 "session_id",
596 "content",
597 "tags",
598 "metadata_json",
599 "created_at",
600 ],
601 )
602}
603
604fn task_schema() -> Value {
605 object_schema(
606 json!({
607 "id": string_schema("Task UUID"),
608 "status": enum_schema("Task status", &["open", "claimed", "completed"]),
609 "content": string_schema("Task content"),
610 "created_by": string_schema("Creating agent"),
611 "claimed_by": nullable_string_schema("Claiming agent"),
612 "created_at": string_schema("RFC3339 creation timestamp"),
613 "updated_at": string_schema("RFC3339 update timestamp"),
614 "source_event_ids": array_schema(string_schema("Source event UUID")),
615 }),
616 vec![
617 "id",
618 "status",
619 "content",
620 "created_by",
621 "created_at",
622 "updated_at",
623 "source_event_ids",
624 ],
625 )
626}
627
628fn handoff_schema() -> Value {
629 object_schema(
630 json!({
631 "id": string_schema("Handoff UUID"),
632 "status": enum_schema("Handoff status", &["pending", "accepted", "completed"]),
633 "content": string_schema("Handoff content"),
634 "from_agent": string_schema("Requesting agent"),
635 "to_agent": string_schema("Receiving agent"),
636 "accepted_by": nullable_string_schema("Accepting agent"),
637 "created_at": string_schema("RFC3339 creation timestamp"),
638 "updated_at": string_schema("RFC3339 update timestamp"),
639 "source_event_ids": array_schema(string_schema("Source event UUID")),
640 }),
641 vec![
642 "id",
643 "status",
644 "content",
645 "from_agent",
646 "to_agent",
647 "created_at",
648 "updated_at",
649 "source_event_ids",
650 ],
651 )
652}
653
654fn object_schema(properties: Value, required: Vec<&str>) -> Value {
655 json!({
656 "type": "object",
657 "properties": properties,
658 "required": required,
659 "additionalProperties": true,
660 })
661}
662
663fn array_schema(items: Value) -> Value {
664 json!({ "type": "array", "items": items })
665}
666
667fn string_schema(description: &str) -> Value {
668 json!({ "type": "string", "description": description })
669}
670
671fn nullable_string_schema(description: &str) -> Value {
672 json!({ "type": ["string", "null"], "description": description })
673}
674
675fn boolean_schema(description: &str) -> Value {
676 json!({ "type": "boolean", "description": description })
677}
678
679fn enum_schema(description: &str, values: &[&str]) -> Value {
680 json!({ "type": "string", "description": description, "enum": values })
681}
682
683fn ok(id: Value, result: Value) -> Value {
684 json!({ "jsonrpc": "2.0", "id": id, "result": result })
685}
686
687fn error(id: Value, code: i32, message: &str) -> Value {
688 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
689}
690
691fn git<const N: usize>(cwd: &PathBuf, args: [&str; N]) -> Result<String> {
692 git_vec(cwd, args.to_vec())
693}
694
695fn git_vec(cwd: &PathBuf, args: Vec<&str>) -> Result<String> {
696 let output = Command::new("git")
697 .args(args)
698 .current_dir(cwd)
699 .output()
700 .map_err(|err| ShuttleError::Store(format!("failed to run git: {err}")))?;
701
702 if !output.status.success() {
703 return Err(ShuttleError::Store(format!(
704 "git command failed: {}",
705 String::from_utf8_lossy(&output.stderr).trim()
706 )));
707 }
708
709 Ok(String::from_utf8_lossy(&output.stdout).to_string())
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715 use crate::core::{EventFilter, EventStore, EventType};
716 use std::fs;
717
718 #[test]
719 fn memory_store_tool_adds_repo_metadata() {
720 let repo = tempfile::tempdir().unwrap();
721 let data = tempfile::tempdir().unwrap();
722 init_git_repo(repo.path());
723 fs::write(repo.path().join("dirty.txt"), "dirty").unwrap();
724 let store = SqliteEventStore::open(data.path().join("shuttle.db")).unwrap();
725 let runtime = McpRuntime {
726 store: store.clone(),
727 cwd: repo.path().to_path_buf(),
728 workspace_id: "workspace".into(),
729 agent: "codex".into(),
730 session_id: "session".into(),
731 };
732 let request = Request {
733 jsonrpc: Some("2.0".into()),
734 id: Some(json!(1)),
735 method: "tools/call".into(),
736 params: json!({
737 "name": "shuttle_memory_store",
738 "arguments": { "content": "repo-aware memory" }
739 }),
740 };
741
742 let response = futures_executor::block_on(handle_request(&runtime, request));
743 assert!(response.get("error").is_none());
744 let events = futures_executor::block_on(store.list(EventFilter {
745 event_type: Some(EventType::Memory),
746 ..EventFilter::default()
747 }))
748 .unwrap();
749
750 assert_eq!(events.len(), 1);
751 assert_eq!(events[0].repo_dirty, Some(true));
752 assert_eq!(events[0].metadata_json["repo_dirty"], true);
753 assert_eq!(events[0].metadata_json["dirty_files"], json!(["dirty.txt"]));
754 assert!(events[0].repo_id.is_some());
755 assert!(events[0].repo_path.is_some());
756 assert!(events[0].branch.is_some());
757 assert!(events[0].commit.is_some());
758 }
759
760 #[test]
761 fn tools_list_includes_phase_two_tools() {
762 let tools = tools();
763 let names = tools.iter().map(|tool| tool.name).collect::<Vec<_>>();
764
765 assert!(names.contains(&"shuttle_message_history"));
766 assert!(names.contains(&"shuttle_task_update"));
767 assert!(names.contains(&"shuttle_task_done"));
768 assert!(names.contains(&"shuttle_handoff_request"));
769 assert!(names.contains(&"shuttle_handoff_list"));
770 assert!(names.contains(&"shuttle_handoff_accept"));
771 assert!(names.contains(&"shuttle_handoff_done"));
772 assert!(tools
773 .iter()
774 .all(|tool| tool.output_schema["type"] == "object"));
775 }
776
777 #[test]
778 fn task_and_handoff_tools_round_trip() {
779 let repo = tempfile::tempdir().unwrap();
780 let data = tempfile::tempdir().unwrap();
781 init_git_repo(repo.path());
782 let store = SqliteEventStore::open(data.path().join("shuttle.db")).unwrap();
783 let runtime = McpRuntime {
784 store,
785 cwd: repo.path().to_path_buf(),
786 workspace_id: "workspace".into(),
787 agent: "codex".into(),
788 session_id: "session".into(),
789 };
790
791 let task_response = futures_executor::block_on(handle_request(
792 &runtime,
793 tool_request(
794 "shuttle_task_create",
795 json!({ "content": "ship task tools" }),
796 ),
797 ));
798 let task_id = response_text_json(&task_response)["id"]
799 .as_str()
800 .unwrap()
801 .to_owned();
802 assert_eq!(
803 task_response["result"]["structuredContent"]["event"]["id"],
804 task_id
805 );
806 futures_executor::block_on(handle_request(
807 &runtime,
808 tool_request("shuttle_task_claim", json!({ "id": task_id })),
809 ));
810 futures_executor::block_on(handle_request(
811 &runtime,
812 tool_request(
813 "shuttle_task_update",
814 json!({ "id": task_id, "content": "updated task tools" }),
815 ),
816 ));
817 futures_executor::block_on(handle_request(
818 &runtime,
819 tool_request("shuttle_task_done", json!({ "id": task_id })),
820 ));
821 let task_list = futures_executor::block_on(handle_request(
822 &runtime,
823 tool_request("shuttle_task_list", json!({})),
824 ));
825 let task_json = response_text_json(&task_list);
826 assert_eq!(task_json[0]["status"], "completed");
827 assert_eq!(task_json[0]["content"], "updated task tools");
828 assert_eq!(
829 task_list["result"]["structuredContent"]["tasks"][0]["status"],
830 "completed"
831 );
832
833 futures_executor::block_on(handle_request(
834 &runtime,
835 tool_request(
836 "shuttle_message_send",
837 json!({ "agent": "claude", "content": "review this" }),
838 ),
839 ));
840 let history = futures_executor::block_on(handle_request(
841 &runtime,
842 tool_request("shuttle_message_history", json!({})),
843 ));
844 let history_json = response_text_json(&history);
845 assert_eq!(history_json[0]["content"], "review this");
846
847 let handoff_response = futures_executor::block_on(handle_request(
848 &runtime,
849 tool_request(
850 "shuttle_handoff_request",
851 json!({ "agent": "claude", "content": "continue this" }),
852 ),
853 ));
854 let handoff_id = response_text_json(&handoff_response)["id"]
855 .as_str()
856 .unwrap()
857 .to_owned();
858 futures_executor::block_on(handle_request(
859 &runtime,
860 tool_request("shuttle_handoff_accept", json!({ "id": handoff_id })),
861 ));
862 let handoff_list = futures_executor::block_on(handle_request(
863 &runtime,
864 tool_request("shuttle_handoff_list", json!({})),
865 ));
866 let handoff_json = response_text_json(&handoff_list);
867 assert_eq!(handoff_json[0]["status"], "accepted");
868 assert_eq!(handoff_json[0]["to_agent"], "claude");
869 }
870
871 fn tool_request(name: &str, arguments: Value) -> Request {
872 Request {
873 jsonrpc: Some("2.0".into()),
874 id: Some(json!(1)),
875 method: "tools/call".into(),
876 params: json!({ "name": name, "arguments": arguments }),
877 }
878 }
879
880 fn response_text_json(response: &Value) -> Value {
881 let text = response["result"]["content"][0]["text"].as_str().unwrap();
882 serde_json::from_str(text).unwrap()
883 }
884
885 fn init_git_repo(path: &std::path::Path) {
886 Command::new("git")
887 .arg("init")
888 .current_dir(path)
889 .output()
890 .unwrap();
891 fs::write(path.join("README.md"), "repo").unwrap();
892 Command::new("git")
893 .args(["add", "README.md"])
894 .current_dir(path)
895 .output()
896 .unwrap();
897 Command::new("git")
898 .args([
899 "-c",
900 "user.name=Shuttle Test",
901 "-c",
902 "user.email=shuttle@example.test",
903 "commit",
904 "-m",
905 "initial",
906 ])
907 .current_dir(path)
908 .output()
909 .unwrap();
910 }
911}