Skip to main content

difflore_core/mcp_server/
mod.rs

1//! MCP (Model Context Protocol) server implementation.
2//!
3//! Speaks JSON-RPC 2.0 over stdin/stdout. AI coding assistants (Claude Code,
4//! Cursor, etc.) connect to `difflore mcp-server` as an MCP stdio transport
5//! to query team rules and historical review verdicts while generating code.
6
7mod hook;
8mod hook_short_circuit;
9mod recall_sampler;
10mod schemas;
11mod serve_render;
12mod server;
13mod skill_docs;
14mod tools;
15mod trust_proof;
16
17pub use hook::{HookRuleContext, fetch_relevant_rules_for_hook, run};
18pub(crate) use tools::{HistoricalPr, predict_scope_from_corpus};
19pub use tools::{
20    detect_active_model, haiku_auto_disable_active, is_haiku_model, origin_to_kind,
21    parse_file_patterns,
22};
23
24/// Predict a PR's likely edit scope from the local imported review corpus.
25///
26/// This is the same core algorithm behind the MCP `plan_pr` tool, exposed so
27/// local CLI flows can reuse the memory signal without duplicating retrieval
28/// logic or depending on the cloud service.
29pub async fn predict_pr_scope(
30    db: &sqlx::SqlitePool,
31    intent: &str,
32    top_k: usize,
33) -> serde_json::Value {
34    let corpus = tools::load_pr_corpus(db).await;
35    predict_scope_from_corpus(&corpus, intent, top_k.clamp(1, 20))
36}
37
38/// Repo-scoped sibling of [`predict_pr_scope`].
39///
40/// When the caller knows the current GitHub repo (including fork/upstream
41/// aliases), use only same-repo historical PRs so file hints do not bleed
42/// across unrelated projects. If no rows match the repo scope, return an empty
43/// prediction and let CLI/project-structure advisories carry the experience.
44pub async fn predict_pr_scope_for_repos(
45    db: &sqlx::SqlitePool,
46    intent: &str,
47    top_k: usize,
48    repo_scopes: &[String],
49) -> serde_json::Value {
50    let corpus = tools::load_pr_corpus(db).await;
51    let scoped = repo_scoped_plan_corpus(&corpus, repo_scopes);
52    let no_repo_scope_memory = !repo_scopes.is_empty() && scoped.is_empty();
53    let mut prediction = if no_repo_scope_memory {
54        predict_scope_from_corpus(&[], intent, top_k.clamp(1, 20))
55    } else if scoped.is_empty() {
56        predict_scope_from_corpus(&corpus, intent, top_k.clamp(1, 20))
57    } else {
58        predict_scope_from_corpus(&scoped, intent, top_k.clamp(1, 20))
59    };
60    if let Some(obj) = prediction.as_object_mut() {
61        obj.insert(
62            "repo_scope".to_owned(),
63            serde_json::json!({
64                "requested": repo_scopes,
65                "matched_prs": scoped.len(),
66                "no_repo_scope_memory": no_repo_scope_memory,
67            }),
68        );
69    }
70    prediction
71}
72
73pub(crate) fn repo_scoped_plan_corpus(
74    corpus: &[HistoricalPr],
75    repo_scopes: &[String],
76) -> Vec<HistoricalPr> {
77    let scopes = repo_scopes
78        .iter()
79        .map(|repo| repo.trim().to_ascii_lowercase())
80        .filter(|repo| !repo.is_empty())
81        .collect::<std::collections::BTreeSet<_>>();
82    if scopes.is_empty() {
83        return Vec::new();
84    }
85    corpus
86        .iter()
87        .filter(|pr| scopes.contains(&pr.repo.to_ascii_lowercase()))
88        .cloned()
89        .collect()
90}
91
92#[cfg(test)]
93mod repo_scope_tests {
94    use super::*;
95
96    #[test]
97    fn repo_scoped_plan_corpus_keeps_current_repo_aliases() {
98        let corpus = vec![
99            historical_pr("gin-gonic/gin", 4542),
100            historical_pr("tanstack/router", 7150),
101            historical_pr("difflore-fixtures/gin", 4542),
102        ];
103
104        let scoped = repo_scoped_plan_corpus(
105            &corpus,
106            &[
107                "difflore-fixtures/gin".to_owned(),
108                "gin-gonic/gin".to_owned(),
109            ],
110        );
111
112        assert_eq!(scoped.len(), 2);
113        assert!(scoped.iter().all(|pr| pr.repo.ends_with("/gin")));
114    }
115
116    #[test]
117    fn repo_scoped_plan_corpus_returns_empty_without_matches() {
118        let corpus = vec![historical_pr("tanstack/router", 7150)];
119
120        let scoped = repo_scoped_plan_corpus(&corpus, &["gin-gonic/gin".to_owned()]);
121
122        assert!(scoped.is_empty());
123    }
124
125    fn historical_pr(repo: &str, pr_number: i32) -> HistoricalPr {
126        HistoricalPr {
127            repo: repo.to_owned(),
128            pr_number,
129            text: String::new(),
130            files: Vec::new(),
131            tokens: Vec::new(),
132        }
133    }
134}
135
136// Items re-exported in this scope so submodules can `use super::*;` and
137// reach all internal helpers / types without enumerating sibling paths.
138pub(crate) use hook::detect_git_remote_owner_repos;
139#[cfg(test)]
140pub(crate) use hook::parse_github_owner_repo;
141pub(crate) use server::{
142    AVG_FULL_RULE_TOKENS, McpState, build_cost_meta, emit_trajectory_step, estimate_tokens,
143    handle_message, jsonrpc_error, rule_hits_by_origin,
144};
145#[cfg(test)]
146pub(crate) use server::{parse_signature_uri, parse_verdict_uri};
147#[cfg(test)]
148pub(crate) use tools::{disabled_response, rule_injection_disabled};
149
150#[cfg(test)]
151mod tests;