Skip to main content

diffler/
mcp.rs

1//! Embedded MCP server: agents read review comments, answer them in place,
2//! and long-poll for the human's feedback. Tool handlers never touch the
3//! review directly: every call is sent through the app event channel as an
4//! [`McpRequest`] and answered by `App::handle_mcp` on the main loop, so the
5//! app stays the single owner of all state (no locks).
6
7use std::fmt;
8use std::io::ErrorKind;
9use std::net::SocketAddr;
10use std::path::Path;
11use std::time::Duration;
12
13use diffler_core::feedback;
14use diffler_core::model::{DiffModel, FileStatus};
15use diffler_core::session::{Comment, CommentStatus};
16use diffler_core::source::ReviewSource;
17use rmcp::handler::server::router::prompt::PromptRouter;
18use rmcp::handler::server::router::tool::ToolRouter;
19use rmcp::handler::server::wrapper::Parameters;
20use rmcp::model::{PromptMessage, Role, ServerCapabilities, ServerInfo};
21use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
22use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
23use rmcp::{
24    ErrorData, Json, ServerHandler, prompt, prompt_handler, prompt_router, schemars, tool,
25    tool_handler, tool_router,
26};
27use serde::Serialize;
28use tokio::sync::mpsc::UnboundedSender;
29use tokio::sync::{oneshot, watch};
30use tokio::task::JoinHandle;
31
32use crate::event::AppEvent;
33
34/// Author label stamped on replies the agent writes through MCP.
35pub const AGENT_AUTHOR: &str = "agent";
36
37/// Agent HTTP clients drop a tool call that sends nothing for about a minute
38/// (Claude Code cuts at 60 s), and a review pause easily outlasts that, so the
39/// caller polls again to keep waiting. Answering a wait costs a further
40/// `REQUEST_TIMEOUT` at worst, so both together stay inside that minute.
41const DEFAULT_WAIT_SECONDS: u64 = 25;
42const MAX_WAIT_SECONDS: u64 = 540;
43/// How long a tool call waits for the app to respond before giving up.
44/// The editor suspension is the main source of delays; 30 s is generous.
45pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
46
47/// One agent tool call in flight: the app answers on `reply`.
48pub struct McpRequest {
49    pub kind: McpRequestKind,
50    pub reply: oneshot::Sender<McpResponse>,
51}
52
53impl fmt::Debug for McpRequest {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.debug_struct("McpRequest")
56            .field("kind", &self.kind)
57            .finish_non_exhaustive()
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum McpRequestKind {
63    ReviewStatus,
64    GetDiff {
65        file: Option<String>,
66    },
67    GetComments {
68        status: Option<CommentStatus>,
69    },
70    /// Every persisted review (working tree, commits, ranges) with its comment
71    /// counts, so the agent knows what the human reviewed and where from.
72    ListReviews,
73    ReplyComment {
74        id: String,
75        body: String,
76    },
77    ProposeResolve {
78        id: String,
79        note: Option<String>,
80    },
81    MarkViewed {
82        file: String,
83    },
84    /// Open + replied comments for `wait_for_feedback` after an epoch bump.
85    Feedback,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum McpResponse {
90    Status(ReviewStatusResponse),
91    Diff(String),
92    Comments(Vec<CommentInfo>),
93    Reviews(Vec<ReviewSummary>),
94    Replied {
95        status: String,
96    },
97    Ok,
98    /// Domain refusal (unknown id/file): surfaces as a tool error.
99    Error(String),
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
103pub struct ReviewStatusResponse {
104    pub repo: String,
105    pub branch: Option<String>,
106    pub oid7: String,
107    pub files_changed: Vec<FileEntry>,
108    #[schemars(with = "Count")]
109    pub open_comments: usize,
110    #[schemars(with = "Count")]
111    pub replied_comments: usize,
112    #[schemars(with = "Count")]
113    pub resolved_comments: usize,
114    #[schemars(with = "Count")]
115    pub feedback_epoch: u64,
116    /// Every persisted review and its comment counts; `files_changed` above is
117    /// the working-tree review, the default the human starts on.
118    pub reviews: Vec<ReviewSummary>,
119}
120
121/// One review's provenance and comment tally: what was reviewed (working tree,
122/// a commit, or a range) and how many comments sit at each status.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
124pub struct ReviewSummary {
125    /// Stable source key (e.g. "working", "commit-<oid>", "range-<a>-<b>").
126    pub source: String,
127    /// Human-facing description (e.g. "commit a1b2c3", "range a1b2c3..d4e5f6").
128    pub label: String,
129    #[schemars(with = "Count")]
130    pub open_comments: usize,
131    #[schemars(with = "Count")]
132    pub replied_comments: usize,
133    #[schemars(with = "Count")]
134    pub resolved_comments: usize,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
138pub struct FileEntry {
139    pub path: String,
140    pub status: String,
141    pub viewed: bool,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
145pub struct DiffResponse {
146    pub diff: String,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
150pub struct CommentInfo {
151    pub id: String,
152    /// Stable key of the review this comment belongs to (see [`ReviewSummary`]).
153    pub source: String,
154    /// Human-facing description of that review (what the human was looking at).
155    pub source_label: String,
156    pub file: String,
157    #[schemars(with = "Option<Count>")]
158    pub line: Option<u32>,
159    #[schemars(with = "Option<Count>")]
160    pub line_end: Option<u32>,
161    /// Which side of the diff the line numbers count: "old" or "new".
162    pub side: String,
163    pub body: String,
164    pub status: String,
165    pub author: String,
166    pub replies: Vec<ReplyInfo>,
167    /// The anchored line changed or vanished since the comment was made.
168    pub outdated: bool,
169    /// Origin-prefixed diff snippet around the anchored line.
170    pub context: Option<String>,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
174pub struct ReplyInfo {
175    pub author: String,
176    pub body: String,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
180pub struct CommentsResponse {
181    pub comments: Vec<CommentInfo>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
185pub struct ReviewsResponse {
186    pub reviews: Vec<ReviewSummary>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
190pub struct ReplyResponse {
191    pub ok: bool,
192    pub status: String,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
196pub struct OkResponse {
197    pub ok: bool,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)]
201pub struct WaitForFeedbackResponse {
202    #[schemars(with = "Count")]
203    pub epoch: u64,
204    pub timed_out: bool,
205    pub comments: Vec<CommentInfo>,
206}
207
208#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
209pub struct GetDiffParams {
210    /// Restrict the diff to one file (repo-relative path).
211    pub file: Option<String>,
212}
213
214#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
215pub struct GetCommentsParams {
216    /// Filter by comment status: "open", "replied", or "resolved".
217    pub status: Option<String>,
218}
219
220#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
221pub struct ReplyCommentParams {
222    pub id: String,
223    pub body: String,
224}
225
226#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
227pub struct ProposeResolveParams {
228    pub id: String,
229    /// Short note on why the comment is addressed.
230    pub note: Option<String>,
231}
232
233#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
234pub struct MarkViewedParams {
235    /// Repo-relative path of a file in the review diff.
236    pub file: String,
237}
238
239#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
240pub struct WaitForFeedbackParams {
241    /// Return once the feedback epoch exceeds this value. Defaults to the
242    /// current epoch, i.e. wait for the next human send.
243    #[schemars(with = "Option<Count>")]
244    pub since_epoch: Option<u64>,
245    /// Long-poll timeout (default 25, max 540). Values past your client's
246    /// request timeout fail the call.
247    #[schemars(with = "Option<Count>")]
248    pub timeout_seconds: Option<u64>,
249}
250
251/// Schema stand-in for every unsigned field in the tool types. `usize`/`u32`/
252/// `u64` derive `format: "uint"`/`"uint32"`/`"uint64"`, none of which JSON
253/// Schema registers, so strict validators warn on every tool schema. rmcp
254/// hardcodes its generator, leaving `#[schemars(with = "Count")]` (or
255/// `Option<Count>`, which keeps the field optional) as the per-field opt-out.
256struct Count;
257
258impl schemars::JsonSchema for Count {
259    fn inline_schema() -> bool {
260        true
261    }
262
263    fn schema_name() -> std::borrow::Cow<'static, str> {
264        "Count".into()
265    }
266
267    fn schema_id() -> std::borrow::Cow<'static, str> {
268        "diffler::mcp::Count".into()
269    }
270
271    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
272        schemars::json_schema!({ "type": "integer", "minimum": 0 })
273    }
274}
275
276pub const fn comment_status_name(status: CommentStatus) -> &'static str {
277    match status {
278        CommentStatus::Open => "open",
279        CommentStatus::Replied => "replied",
280        CommentStatus::Resolved => "resolved",
281    }
282}
283
284pub const fn file_status_name(status: FileStatus) -> &'static str {
285    match status {
286        FileStatus::Added => "added",
287        FileStatus::Modified => "modified",
288        FileStatus::Deleted => "deleted",
289        FileStatus::Renamed => "renamed",
290        FileStatus::Untracked => "untracked",
291    }
292}
293
294/// Unified-style text rendering of a diff model for `get_diff`.
295pub fn render_unified(model: &DiffModel, file: Option<&str>) -> Result<String, String> {
296    use std::fmt::Write as _;
297
298    if let Some(path) = file
299        && !model.files.iter().any(|f| f.path == path)
300    {
301        return Err(format!("unknown file: {path}"));
302    }
303    let mut out = String::new();
304    for f in &model.files {
305        if file.is_some_and(|path| path != f.path) {
306            continue;
307        }
308        let old = match f.status {
309            FileStatus::Added | FileStatus::Untracked => "/dev/null".to_owned(),
310            _ => format!("a/{}", f.old_path.as_deref().unwrap_or(&f.path)),
311        };
312        let new = match f.status {
313            FileStatus::Deleted => "/dev/null".to_owned(),
314            _ => format!("b/{}", f.path),
315        };
316        let _ = writeln!(out, "--- {old}");
317        let _ = writeln!(out, "+++ {new}");
318        if f.binary {
319            out.push_str("Binary files differ\n");
320            continue;
321        }
322        for hunk in &f.hunks {
323            let _ = writeln!(out, "{}", hunk.header());
324            for line in &hunk.lines {
325                let _ = writeln!(out, "{}{}", line.kind.origin(), line.text);
326            }
327        }
328    }
329    Ok(out)
330}
331
332/// Agent-facing view of one comment, with context and outdated detection
333/// judged against the current diff model, tagged with its review source.
334pub fn comment_info(comment: &Comment, model: &DiffModel, source: &ReviewSource) -> CommentInfo {
335    let anchor = &comment.anchor;
336    // range comments anchor to their END line (`Anchor::is_outdated`), but
337    // the context snippet renders from the START line so it reads naturally
338    let context = anchor
339        .line
340        .and_then(|line| feedback::context_snippet(model, &anchor.file, line, anchor.on_old_side))
341        .map(|snippet| {
342            snippet
343                .iter()
344                .map(|(origin, text)| format!("{origin}{text}"))
345                .collect::<Vec<_>>()
346                .join("\n")
347        });
348    let outdated = anchor.is_outdated(model);
349    CommentInfo {
350        id: comment.id.clone(),
351        source: source.key(),
352        source_label: source.label(),
353        file: anchor.file.clone(),
354        line: anchor.line,
355        line_end: anchor.line_end,
356        side: if anchor.on_old_side { "old" } else { "new" }.to_owned(),
357        body: comment.body.clone(),
358        status: comment_status_name(comment.status).to_owned(),
359        author: comment.author.clone(),
360        replies: comment
361            .replies
362            .iter()
363            .map(|r| ReplyInfo {
364                author: r.author.clone(),
365                body: r.body.clone(),
366            })
367            .collect(),
368        outdated,
369        context,
370    }
371}
372
373/// MCP tool handler: forwards every call to the app over the event channel
374/// and holds a feedback-epoch receiver for the long poll.
375#[derive(Clone)]
376pub struct DifflerMcp {
377    tx: UnboundedSender<AppEvent>,
378    feedback_rx: watch::Receiver<u64>,
379    tool_router: ToolRouter<Self>,
380    prompt_router: PromptRouter<Self>,
381}
382
383fn mismatch() -> ErrorData {
384    ErrorData::internal_error("unexpected response from the diffler app", None)
385}
386
387fn parse_status(status: &str) -> Result<CommentStatus, ErrorData> {
388    match status {
389        "open" => Ok(CommentStatus::Open),
390        "replied" => Ok(CommentStatus::Replied),
391        "resolved" => Ok(CommentStatus::Resolved),
392        other => Err(ErrorData::invalid_params(
393            format!("unknown status {other:?} (expected open, replied, or resolved)"),
394            None,
395        )),
396    }
397}
398
399impl DifflerMcp {
400    pub fn new(tx: UnboundedSender<AppEvent>, feedback_rx: watch::Receiver<u64>) -> Self {
401        Self {
402            tx,
403            feedback_rx,
404            tool_router: Self::tool_router(),
405            prompt_router: Self::prompt_router(),
406        }
407    }
408
409    async fn request(&self, kind: McpRequestKind) -> Result<McpResponse, ErrorData> {
410        self.request_with_timeout(kind, REQUEST_TIMEOUT).await
411    }
412
413    async fn request_with_timeout(
414        &self,
415        kind: McpRequestKind,
416        timeout: Duration,
417    ) -> Result<McpResponse, ErrorData> {
418        let (reply, response) = oneshot::channel();
419        self.tx
420            .send(AppEvent::Mcp(McpRequest { kind, reply }))
421            .map_err(|_| ErrorData::internal_error("the diffler TUI is not running", None))?;
422        match tokio::time::timeout(timeout, response).await {
423            Ok(Ok(McpResponse::Error(message))) => Err(ErrorData::invalid_params(message, None)),
424            Ok(Ok(response)) => Ok(response),
425            Ok(Err(_)) => Err(ErrorData::internal_error(
426                "the diffler TUI dropped the request",
427                None,
428            )),
429            Err(_) => Err(ErrorData::internal_error(
430                "diffler is busy (editor open or loop stalled), retry",
431                None,
432            )),
433        }
434    }
435}
436
437#[tool_router]
438impl DifflerMcp {
439    #[tool(
440        description = "Current review state: repo, branch, changed files with viewed marks, comment counts, and the feedback epoch for wait_for_feedback."
441    )]
442    async fn review_status(&self) -> Result<Json<ReviewStatusResponse>, ErrorData> {
443        match self.request(McpRequestKind::ReviewStatus).await? {
444            McpResponse::Status(status) => Ok(Json(status)),
445            _ => Err(mismatch()),
446        }
447    }
448
449    #[tool(
450        description = "Unified diff text of the working tree under review, optionally restricted to one file."
451    )]
452    async fn get_diff(
453        &self,
454        Parameters(params): Parameters<GetDiffParams>,
455    ) -> Result<Json<DiffResponse>, ErrorData> {
456        let kind = McpRequestKind::GetDiff { file: params.file };
457        match self.request(kind).await? {
458            McpResponse::Diff(diff) => Ok(Json(DiffResponse { diff })),
459            _ => Err(mismatch()),
460        }
461    }
462
463    #[tool(
464        description = "Review comments across every review (working tree, commits, ranges), each tagged with its source and source_label; anchors, diff context, and threads included. Optionally filtered by status (open, replied, resolved)."
465    )]
466    async fn get_comments(
467        &self,
468        Parameters(params): Parameters<GetCommentsParams>,
469    ) -> Result<Json<CommentsResponse>, ErrorData> {
470        let status = params.status.as_deref().map(parse_status).transpose()?;
471        match self.request(McpRequestKind::GetComments { status }).await? {
472            McpResponse::Comments(comments) => Ok(Json(CommentsResponse { comments })),
473            _ => Err(mismatch()),
474        }
475    }
476
477    #[tool(
478        description = "List every review the human has: the working tree, individual commits, and commit ranges, each with its comment counts, so you can tell where feedback came from."
479    )]
480    async fn list_reviews(&self) -> Result<Json<ReviewsResponse>, ErrorData> {
481        match self.request(McpRequestKind::ListReviews).await? {
482            McpResponse::Reviews(reviews) => Ok(Json(ReviewsResponse { reviews })),
483            _ => Err(mismatch()),
484        }
485    }
486
487    #[tool(description = "Answer a review comment in place; the human sees the reply immediately.")]
488    async fn reply_comment(
489        &self,
490        Parameters(params): Parameters<ReplyCommentParams>,
491    ) -> Result<Json<ReplyResponse>, ErrorData> {
492        let kind = McpRequestKind::ReplyComment {
493            id: params.id,
494            body: params.body,
495        };
496        match self.request(kind).await? {
497            McpResponse::Replied { status } => Ok(Json(ReplyResponse { ok: true, status })),
498            _ => Err(mismatch()),
499        }
500    }
501
502    #[tool(
503        description = "Flag a comment as addressed: marks it replied. Writes nothing into the thread, so call it after reply_comment rather than repeating the answer. The optional note is used only when you did not reply. Only the human can resolve it, in the TUI."
504    )]
505    async fn propose_resolve(
506        &self,
507        Parameters(params): Parameters<ProposeResolveParams>,
508    ) -> Result<Json<ReplyResponse>, ErrorData> {
509        let kind = McpRequestKind::ProposeResolve {
510            id: params.id,
511            note: params.note,
512        };
513        match self.request(kind).await? {
514            McpResponse::Replied { status } => Ok(Json(ReplyResponse { ok: true, status })),
515            _ => Err(mismatch()),
516        }
517    }
518
519    #[tool(
520        description = "Mark a file as viewed in the review the human is currently looking at (working tree, or the open commit/range diff)."
521    )]
522    async fn mark_viewed(
523        &self,
524        Parameters(params): Parameters<MarkViewedParams>,
525    ) -> Result<Json<OkResponse>, ErrorData> {
526        let kind = McpRequestKind::MarkViewed { file: params.file };
527        match self.request(kind).await? {
528            McpResponse::Ok => Ok(Json(OkResponse { ok: true })),
529            _ => Err(mismatch()),
530        }
531    }
532
533    #[tool(
534        description = "Long-poll until the human sends feedback (comments, replies, or the send key). Returns the new epoch and all open/replied comments, or timed_out. A timed_out result means the human is still reviewing: call again with the epoch it returned to keep waiting."
535    )]
536    async fn wait_for_feedback(
537        &self,
538        Parameters(params): Parameters<WaitForFeedbackParams>,
539    ) -> Result<Json<WaitForFeedbackResponse>, ErrorData> {
540        let mut rx = self.feedback_rx.clone();
541        let since = params.since_epoch.unwrap_or_else(|| *rx.borrow());
542        let timeout = Duration::from_secs(
543            params
544                .timeout_seconds
545                .unwrap_or(DEFAULT_WAIT_SECONDS)
546                .min(MAX_WAIT_SECONDS),
547        );
548        let waited = tokio::time::timeout(timeout, rx.wait_for(|epoch| *epoch > since))
549            .await
550            // copy the epoch out so the watch borrow ends before the match
551            .map(|result| result.map(|epoch| *epoch));
552        let epoch = match waited {
553            Ok(Ok(epoch)) => epoch,
554            Ok(Err(_)) => {
555                return Err(ErrorData::internal_error(
556                    "the diffler TUI is not running",
557                    None,
558                ));
559            }
560            Err(_) => {
561                let epoch = *rx.borrow();
562                return Ok(Json(WaitForFeedbackResponse {
563                    epoch,
564                    timed_out: true,
565                    comments: Vec::new(),
566                }));
567            }
568        };
569        match self.request(McpRequestKind::Feedback).await? {
570            McpResponse::Comments(comments) => Ok(Json(WaitForFeedbackResponse {
571                epoch,
572                timed_out: false,
573                comments,
574            })),
575            _ => Err(mismatch()),
576        }
577    }
578}
579
580/// Clients surface MCP prompts as commands (Claude Code renders this as
581/// `/diffler:review`), so connected agents get a one-keystroke entry into
582/// the review loop.
583#[prompt_router]
584impl DifflerMcp {
585    #[prompt(
586        name = "review",
587        description = "Check the diffler review: read the human's open comments, address them in code, reply, and wait for the next round."
588    )]
589    async fn review(&self) -> Vec<PromptMessage> {
590        vec![PromptMessage::new_text(
591            Role::User,
592            "Check the diffler review and respond to the human's feedback:\n\
593             1. Call review_status for the active review and its changed files.\n\
594             2. Call get_comments with status \"open\" and read each comment in place.\n\
595             3. Address every comment in the code it anchors to.\n\
596             4. Answer each with reply_comment (what you changed and why), then \
597             propose_resolve to flag it addressed. That flag adds no text: do \
598             not summarise the answer you just gave. Only the human resolves \
599             for real, in the TUI.\n\
600             5. Call wait_for_feedback with the latest epoch and start over when it \
601             returns: the human just sent new feedback. It answers within a minute; \
602             a timed_out result means they are still reviewing, so call it again. \
603             If the call itself fails, check review_status: diffler is closed only \
604             when that fails too, otherwise keep waiting.",
605        )]
606    }
607}
608
609#[tool_handler(router = self.tool_router)]
610#[prompt_handler(router = self.prompt_router)]
611impl ServerHandler for DifflerMcp {
612    fn get_info(&self) -> ServerInfo {
613        ServerInfo::new(
614            ServerCapabilities::builder()
615                .enable_tools()
616                .enable_prompts()
617                .build(),
618        )
619        .with_instructions(
620            "diffler code review: get_comments reads the human's diff comments, \
621             reply_comment answers them in place, propose_resolve flags an \
622             answered one as addressed without adding text, and \
623             wait_for_feedback long-polls until the human sends \
624             new feedback. The review prompt packages that loop as a command.",
625        )
626    }
627}
628
629pub struct McpHandle {
630    /// Actual bound port: the configured one, or an ephemeral fallback.
631    pub port: u16,
632    pub handle: JoinHandle<()>,
633}
634
635// SO_REUSEADDR lets a restart reclaim the port while the old socket lingers in
636// TIME_WAIT. Unix only: on Windows it instead allows hijacking a live port and
637// errors WSAEACCES, defeating the busy-port fallback.
638fn bind_reusable(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
639    let socket = tokio::net::TcpSocket::new_v4()?;
640    #[cfg(unix)]
641    socket.set_reuseaddr(true)?;
642    socket.bind(addr)?;
643    socket.listen(1024)
644}
645
646/// Serve the MCP tools over streamable HTTP at `127.0.0.1:{port}/mcp`.
647/// A taken port falls back to an ephemeral one instead of failing the TUI;
648/// the returned handle carries the port that actually bound.
649pub fn spawn_mcp(
650    tx: UnboundedSender<AppEvent>,
651    feedback_rx: watch::Receiver<u64>,
652    port: u16,
653) -> std::io::Result<McpHandle> {
654    let listener = match bind_reusable(SocketAddr::from(([127, 0, 0, 1], port))) {
655        Ok(listener) => listener,
656        Err(e) if e.kind() == ErrorKind::AddrInUse => {
657            bind_reusable(SocketAddr::from(([127, 0, 0, 1], 0)))?
658        }
659        Err(e) => return Err(e),
660    };
661    let port = listener.local_addr()?.port();
662    let service: StreamableHttpService<DifflerMcp, LocalSessionManager> =
663        StreamableHttpService::new(
664            move || Ok(DifflerMcp::new(tx.clone(), feedback_rx.clone())),
665            std::sync::Arc::default(),
666            StreamableHttpServerConfig::default(),
667        );
668    let router = axum::Router::new().nest_service("/mcp", service);
669    let handle = tokio::spawn(async move {
670        // serve only ends on listener errors; the TUI aborts this task on quit
671        let _ = axum::serve(listener, router).await;
672    });
673    Ok(McpHandle { port, handle })
674}
675
676/// Repo-relative path of the endpoint discovery file an external proxy reads.
677const ENDPOINT_FILE: &str = "mcp.json";
678
679fn endpoint_path(repo_root: &Path) -> std::path::PathBuf {
680    repo_root.join(".diffler").join(ENDPOINT_FILE)
681}
682
683/// Publish the live MCP endpoint to `.diffler/mcp.json` so a stdio proxy (the
684/// `npx` bridge) can discover the actual port, which may differ from the
685/// configured one after an ephemeral fallback.
686pub fn write_endpoint(repo_root: &Path, port: u16) -> std::io::Result<()> {
687    let dir = repo_root.join(".diffler");
688    std::fs::create_dir_all(&dir)?;
689    let gitignore = dir.join(".gitignore");
690    if !gitignore.exists() {
691        std::fs::write(&gitignore, "*\n")?;
692    }
693    let body = format!("{{\n  \"port\": {port},\n  \"url\": \"http://127.0.0.1:{port}/mcp\"\n}}\n");
694    std::fs::write(endpoint_path(repo_root), body)
695}
696
697/// Remove the endpoint file on shutdown, but only when it still names this
698/// process's own port. A second diffler instance in the same repo overwrites
699/// the file with its own port; deleting unconditionally would let whichever
700/// process exits first destroy the still-running one's proxy discovery.
701pub fn clear_endpoint(repo_root: &Path, port: u16) {
702    let path = endpoint_path(repo_root);
703    let Ok(body) = std::fs::read_to_string(&path) else {
704        return;
705    };
706    let current_owner = serde_json::from_str::<serde_json::Value>(&body)
707        .ok()
708        .and_then(|v| v.get("port").and_then(serde_json::Value::as_u64));
709    if current_owner == Some(u64::from(port)) {
710        let _ = std::fs::remove_file(&path);
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use diffler_core::model::{DiffLine, FileDiff, Hunk, HunkId, LineKind};
717    use diffler_core::session::{Anchor, Session};
718
719    use super::*;
720
721    fn diff_line(kind: LineKind, old_no: Option<u32>, new_no: Option<u32>, text: &str) -> DiffLine {
722        DiffLine::new(kind, old_no, new_no, text.to_owned())
723    }
724
725    fn sample_model() -> DiffModel {
726        DiffModel {
727            files: vec![
728                FileDiff {
729                    path: "src/auth.py".into(),
730                    old_path: None,
731                    status: FileStatus::Modified,
732                    binary: false,
733                    old_text: None,
734                    new_text: Some("one\nTWO\nthree\n".into()),
735                    hunks: vec![Hunk {
736                        id: HunkId("h1".into()),
737                        old_start: 1,
738                        old_lines: 3,
739                        new_start: 1,
740                        new_lines: 3,
741                        context: String::new(),
742                        lines: vec![
743                            diff_line(LineKind::Context, Some(1), Some(1), "one"),
744                            diff_line(LineKind::Deleted, Some(2), None, "two"),
745                            diff_line(LineKind::Added, None, Some(2), "TWO"),
746                            diff_line(LineKind::Context, Some(3), Some(3), "three"),
747                        ],
748                    }],
749                    hashes: diffler_core::model::HashCache::default(),
750                },
751                FileDiff {
752                    path: "logo.png".into(),
753                    old_path: None,
754                    status: FileStatus::Added,
755                    binary: true,
756                    old_text: None,
757                    new_text: None,
758                    hunks: vec![],
759                    hashes: diffler_core::model::HashCache::default(),
760                },
761            ],
762        }
763    }
764
765    fn anchor(file: &str, line: Option<u32>) -> Anchor {
766        Anchor {
767            file: file.to_owned(),
768            line,
769            line_end: None,
770            on_old_side: false,
771            line_text: None,
772        }
773    }
774
775    #[test]
776    fn render_unified_emits_headers_hunks_and_origins() {
777        let text = render_unified(&sample_model(), None).unwrap();
778        assert!(text.contains("--- a/src/auth.py\n+++ b/src/auth.py\n"));
779        assert!(text.contains("@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n"));
780        assert!(text.contains("--- /dev/null\n+++ b/logo.png\nBinary files differ\n"));
781    }
782
783    #[test]
784    fn render_unified_filters_to_one_file() {
785        let text = render_unified(&sample_model(), Some("src/auth.py")).unwrap();
786        assert!(text.contains("src/auth.py"));
787        assert!(!text.contains("logo.png"));
788    }
789
790    #[test]
791    fn render_unified_unknown_file_is_an_error() {
792        let err = render_unified(&sample_model(), Some("nope.rs")).unwrap_err();
793        assert!(err.contains("nope.rs"));
794    }
795
796    #[test]
797    fn render_unified_marks_deleted_files_with_dev_null() {
798        let mut model = sample_model();
799        model.files[0].status = FileStatus::Deleted;
800        let text = render_unified(&model, Some("src/auth.py")).unwrap();
801        assert!(text.contains("--- a/src/auth.py\n+++ /dev/null\n"));
802    }
803
804    #[test]
805    fn render_unified_renamed_file_pairs_old_and_new_paths() {
806        let mut model = sample_model();
807        model.files[0].status = FileStatus::Renamed;
808        model.files[0].old_path = Some("src/auth_v1.py".to_owned());
809        let text = render_unified(&model, Some("src/auth.py")).unwrap();
810        assert!(
811            text.contains("--- a/src/auth_v1.py\n+++ b/src/auth.py\n"),
812            "rename header must pair both paths: {text}"
813        );
814    }
815
816    #[test]
817    fn render_unified_empty_model_is_an_empty_diff() {
818        let model = DiffModel { files: vec![] };
819        assert_eq!(render_unified(&model, None).unwrap(), "");
820    }
821
822    #[test]
823    fn comment_info_old_side_anchor_reports_side_and_context() {
824        let mut session = Session::default();
825        let mut a = anchor("src/auth.py", Some(2));
826        a.on_old_side = true;
827        a.line_text = Some("two".to_owned());
828        session.add_comment(a, "human", "what was wrong with two?");
829        let info = comment_info(
830            &session.comments[0],
831            &sample_model(),
832            &ReviewSource::WorkingTree,
833        );
834        assert_eq!(info.side, "old");
835        assert_eq!(info.line, Some(2));
836        assert_eq!(info.line_end, None);
837        assert!(!info.outdated, "the deleted line is still in the diff");
838        assert_eq!(info.context.as_deref(), Some(" one\n-two\n+TWO"));
839    }
840
841    #[test]
842    fn comment_info_carries_context_and_thread() {
843        let mut session = Session::default();
844        let mut a = anchor("src/auth.py", Some(2));
845        a.line_text = Some("TWO".to_owned());
846        let id = session.add_comment(a, "human", "why uppercase?").id.clone();
847        session.reply(&id, AGENT_AUTHOR, "legacy API");
848        let info = comment_info(
849            &session.comments[0],
850            &sample_model(),
851            &ReviewSource::WorkingTree,
852        );
853        assert_eq!(info.file, "src/auth.py");
854        assert_eq!(info.line, Some(2));
855        assert_eq!(info.side, "new");
856        assert_eq!(info.status, "replied");
857        assert_eq!(info.context.as_deref(), Some("-two\n+TWO\n three"));
858        assert!(!info.outdated);
859        assert_eq!(info.replies.len(), 1);
860        assert_eq!(info.replies[0].author, AGENT_AUTHOR);
861    }
862
863    #[test]
864    fn comment_info_flags_departed_lines_outdated() {
865        let mut session = Session::default();
866        session.add_comment(anchor("src/auth.py", Some(99)), "human", "moved on");
867        let info = comment_info(
868            &session.comments[0],
869            &sample_model(),
870            &ReviewSource::WorkingTree,
871        );
872        assert!(info.outdated);
873        assert_eq!(info.context, None);
874    }
875
876    #[test]
877    fn comment_info_flags_drifted_line_text_outdated() {
878        let mut session = Session::default();
879        let mut a = anchor("src/auth.py", Some(2));
880        a.line_text = Some("old text".to_owned());
881        session.add_comment(a, "human", "stale");
882        let info = comment_info(
883            &session.comments[0],
884            &sample_model(),
885            &ReviewSource::WorkingTree,
886        );
887        assert!(info.outdated);
888        assert!(info.context.is_some(), "context still renders");
889    }
890
891    #[test]
892    fn file_level_comment_outdated_only_when_file_left_the_diff() {
893        let mut session = Session::default();
894        session.add_comment(anchor("src/auth.py", None), "human", "overall");
895        session.add_comment(anchor("gone.py", None), "human", "gone");
896        let model = sample_model();
897        assert!(!comment_info(&session.comments[0], &model, &ReviewSource::WorkingTree).outdated);
898        assert!(comment_info(&session.comments[1], &model, &ReviewSource::WorkingTree).outdated);
899    }
900
901    // A range comment where start != end is NOT outdated when the end
902    // line still matches.  Only the end-line text is checked for drift; the
903    // context snippet is still rooted at the start line.
904    #[test]
905    fn range_comment_not_outdated_when_end_line_matches() {
906        let mut session = Session::default();
907        // comment spans new lines 1-3; line_text snapshots line 3 ("three")
908        let mut a = Anchor {
909            file: "src/auth.py".to_owned(),
910            line: Some(1),
911            line_end: Some(3),
912            on_old_side: false,
913            line_text: Some("three".to_owned()),
914        };
915        // sanity: start text differs from end text
916        assert_ne!(a.line_text.as_deref(), Some("one"));
917        session.add_comment(a.clone(), "human", "range comment");
918        let info = comment_info(
919            &session.comments[0],
920            &sample_model(),
921            &ReviewSource::WorkingTree,
922        );
923        assert!(
924            !info.outdated,
925            "end line text matches snapshot: must NOT be outdated"
926        );
927        // change the snapshot to something that no longer matches line 3
928        a.line_text = Some("changed".to_owned());
929        session.comments[0].anchor = a;
930        let info2 = comment_info(
931            &session.comments[0],
932            &sample_model(),
933            &ReviewSource::WorkingTree,
934        );
935        assert!(info2.outdated, "end line text drifted: must be outdated");
936    }
937
938    // request_with_timeout returns a "busy" error when the reply
939    // channel is never answered within the deadline.
940    #[tokio::test]
941    async fn request_times_out_when_app_does_not_answer() {
942        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
943        let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
944        let handler = DifflerMcp::new(tx, feedback_rx);
945        let err = handler
946            .request_with_timeout(McpRequestKind::ReviewStatus, Duration::from_millis(50))
947            .await
948            .unwrap_err();
949        assert!(
950            err.message.contains("busy"),
951            "error should mention busy: {err:?}"
952        );
953    }
954
955    // The long-poll cap must hold even when the caller asks for more.
956    // Under tokio's paused clock the runtime auto-advances to the next
957    // timer, so the elapsed virtual time is exactly the effective timeout.
958    #[tokio::test(start_paused = true)]
959    async fn wait_for_feedback_clamps_the_timeout_to_the_cap() {
960        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
961        let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
962        let handler = DifflerMcp::new(tx, feedback_rx);
963        let started = tokio::time::Instant::now();
964        let Json(response) = handler
965            .wait_for_feedback(Parameters(WaitForFeedbackParams {
966                since_epoch: None,
967                timeout_seconds: Some(MAX_WAIT_SECONDS * 10),
968            }))
969            .await
970            .unwrap();
971        assert!(response.timed_out, "no feedback ever arrives");
972        assert_eq!(
973            started.elapsed(),
974            Duration::from_secs(MAX_WAIT_SECONDS),
975            "a timeout above the cap must clamp to {MAX_WAIT_SECONDS}s"
976        );
977    }
978
979    #[tokio::test(start_paused = true)]
980    async fn wait_for_feedback_defaults_inside_the_client_request_timeout() {
981        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
982        let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
983        let handler = DifflerMcp::new(tx, feedback_rx);
984        let started = tokio::time::Instant::now();
985        let Json(response) = handler
986            .wait_for_feedback(Parameters(WaitForFeedbackParams {
987                since_epoch: None,
988                timeout_seconds: None,
989            }))
990            .await
991            .unwrap();
992        assert!(response.timed_out, "no feedback ever arrives");
993        assert!(
994            started.elapsed() + REQUEST_TIMEOUT < Duration::from_secs(60),
995            "the wait plus the reply it still owes must beat a 60 s client cut"
996        );
997    }
998
999    #[test]
1000    fn endpoint_file_publishes_the_port_and_clears() {
1001        let dir = tempfile::tempdir().expect("tempdir");
1002        write_endpoint(dir.path(), 8417).expect("write");
1003        let path = dir.path().join(".diffler/mcp.json");
1004        let body = std::fs::read_to_string(&path).expect("read");
1005        assert!(body.contains("\"port\": 8417"), "{body}");
1006        assert!(body.contains("http://127.0.0.1:8417/mcp"), "{body}");
1007        // the .diffler dir self-gitignores like the session store
1008        let gitignore =
1009            std::fs::read_to_string(dir.path().join(".diffler/.gitignore")).expect("gitignore");
1010        assert_eq!(gitignore, "*\n");
1011        clear_endpoint(dir.path(), 8417);
1012        assert!(!path.exists(), "endpoint file removed on shutdown");
1013    }
1014
1015    #[test]
1016    fn clear_endpoint_leaves_a_newer_owner_alone() {
1017        let dir = tempfile::tempdir().expect("tempdir");
1018        write_endpoint(dir.path(), 1111).expect("write first instance");
1019        // a second diffler instance in the same repo overwrites the file
1020        write_endpoint(dir.path(), 2222).expect("write second instance");
1021
1022        // the first instance shuts down and clears its own (stale) port
1023        clear_endpoint(dir.path(), 1111);
1024
1025        let path = dir.path().join(".diffler/mcp.json");
1026        let body = std::fs::read_to_string(&path)
1027            .expect("file survives: it names the still-running instance");
1028        assert!(body.contains("\"port\": 2222"), "{body}");
1029    }
1030
1031    // Rust's integer formats ("uint", "uint32", "uint64") are not registered
1032    // JSON Schema formats, and strict clients warn on every one they see.
1033    #[test]
1034    fn tool_schemas_use_no_unregistered_format() {
1035        for tool in DifflerMcp::tool_router().list_all() {
1036            for (part, schema) in [
1037                ("input", Some(&*tool.input_schema)),
1038                ("output", tool.output_schema.as_deref()),
1039            ] {
1040                let Some(schema) = schema else { continue };
1041                let text = serde_json::to_string(schema).expect("schema serializes");
1042                assert!(
1043                    !text.contains("\"format\""),
1044                    "{} {part} schema carries a format keyword: {text}",
1045                    tool.name
1046                );
1047            }
1048        }
1049    }
1050
1051    // spawn_mcp falls back to :0 only on AddrInUse, not on other errors.
1052    #[tokio::test]
1053    async fn spawn_mcp_fallback_on_addr_in_use() {
1054        // grab a port and hold the listener so the configured port is busy
1055        let holder = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1056            .await
1057            .unwrap();
1058        let busy_port = holder.local_addr().unwrap().port();
1059        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1060        let (_feedback_tx, feedback_rx) = tokio::sync::watch::channel(0u64);
1061        let handle = spawn_mcp(tx, feedback_rx, busy_port).unwrap();
1062        assert_ne!(
1063            handle.port, busy_port,
1064            "should have bound to a different ephemeral port"
1065        );
1066        handle.handle.abort();
1067    }
1068}
1069
1070#[cfg(test)]
1071mod agent_command_sync {
1072    /// The review loop is written out for two agent harnesses. Only the
1073    /// frontmatter differs (each host wants its own shape), so the numbered
1074    /// steps have to stay identical or one harness quietly teaches a stale
1075    /// loop. A wording fix that lands in one and not the other is invisible
1076    /// without this.
1077    /// A Windows checkout carries CRLF, so the newlines are normalised before
1078    /// the frontmatter is found and the bodies are compared. Matching on `\n`
1079    /// alone silently falls through to comparing the frontmatter, which does
1080    /// differ, and fails on that platform only.
1081    fn body(doc: &str) -> String {
1082        let doc = doc.replace("\r\n", "\n");
1083        let after_frontmatter = doc
1084            .strip_prefix("---\n")
1085            .and_then(|rest| rest.split_once("\n---\n"))
1086            .map_or(doc.as_str(), |(_, body)| body);
1087        after_frontmatter.trim().to_owned()
1088    }
1089
1090    #[test]
1091    fn both_agent_command_files_teach_the_same_review_loop() {
1092        const SKILL: &str = include_str!(concat!(
1093            env!("CARGO_MANIFEST_DIR"),
1094            "/../../skills/diffler/SKILL.md"
1095        ));
1096        const OPENCODE: &str = include_str!(concat!(
1097            env!("CARGO_MANIFEST_DIR"),
1098            "/../../.opencode/commands/diffler.md"
1099        ));
1100        assert_eq!(
1101            body(SKILL),
1102            body(OPENCODE),
1103            "skills/diffler/SKILL.md and .opencode/commands/diffler.md have drifted; \
1104             edit both or neither"
1105        );
1106    }
1107}