Skip to main content

shuttle_rs/
mcp.rs

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}