Skip to main content

comindly_engine/
mcp.rs

1//! Comindly MCP server — the code graph as tools a coding agent calls live.
2//!
3//! Loads the resolved org graph from LanceDB once at startup (local mmap or S3), holds it in
4//! memory, and serves deterministic navigation over stdio:
5//!
6//!   * `search`       — natural-language / hybrid code search (semantic + lexical + centrality)
7//!   * `repos`        — indexed repositories + symbol counts
8//!   * `suggest`      — pre-generated questions you can ask (query catalog)
9//!   * `find`         — locate symbols by name/path substring
10//!   * `zoom`         — 360° view of a symbol (callers, callees, importers, members)
11//!   * `ripple`       — org-wide blast radius (who breaks if I change this)
12//!   * `thread`       — forward call trace from an entry point
13//!   * `flow`         — pre-generated walkthrough of a flow + its live call trace
14//!   * `context_pack` — the minimal token-budgeted read-set to change a symbol safely
15//!   * `guide`        — the repo's inferred style guide
16//!
17//! Results are handed back as **markdown by default** (readable for the agent). Pass
18//! `serve --format json` for JSON text with the same JSON as structured content.
19//! Every result carries an exact `file:line`.
20
21use std::collections::HashMap;
22use std::fmt::Write as _;
23use std::sync::Arc;
24
25use crate::embed::Embedder;
26use crate::graph::{CodeGraph, Node as GNode};
27use crate::model::Symbol;
28use anyhow::Result;
29use rmcp::handler::server::wrapper::Parameters;
30use rmcp::model::{CallToolResult, ContentBlock};
31use rmcp::{schemars, tool, tool_router, ServiceExt};
32use serde::{Deserialize, Serialize};
33
34/// The org's teams, shared with the web app so changes reach agents at once.
35pub type Teams = Arc<std::sync::RwLock<crate::team::TeamSettings>>;
36
37/// Ledger records and when they were read.
38type Cached = (std::time::Instant, Vec<crate::ledger::Record>);
39
40/// Per-symbol LLM enrichment keyed by rendered symbol id: `(summary, generated_queries)`.
41type Enrichment = HashMap<String, (String, Vec<String>)>;
42
43#[derive(Clone)]
44pub struct ComindlyServer {
45    graph: Arc<CodeGraph>,
46    enrichment: Arc<Enrichment>,
47    /// Narrative style guides by repository.
48    style_guides: Arc<Vec<(String, String)>>,
49    /// Pre-generated flow walkthroughs by entry-point id: `(narration, flow_questions)`.
50    flows: Arc<HashMap<String, (String, Vec<String>)>>,
51    /// Symbols by rendered id — used by `search` to look up candidate metadata.
52    symbols: Arc<HashMap<String, Symbol>>,
53    /// Each repository's recent history, to settle notes on records.
54    logs: Arc<crate::index::RepoLogs>,
55    /// Query embedder for `search`; `None` if the model could not be loaded (offline).
56    embedder: Option<Arc<Embedder>>,
57    uri: Arc<String>,
58    /// The index's tables, held open so LanceDB caches their metadata and indexes.
59    tables: Arc<crate::index::Tables>,
60    /// Ledger records with when they were read; re-read at most once a minute.
61    records: Arc<tokio::sync::Mutex<Option<Cached>>>,
62    /// Manifest/lockfile rows for internal-dependency answers.
63    packages: Arc<Vec<crate::manifests::PackageRow>>,
64    /// Measured conventions (per repo + org baseline), computed at startup from stored facts.
65    conventions: Arc<Vec<crate::conventions::Convention>>,
66    /// All edges (contracts and blast radius for `preflight`).
67    edges: Arc<Vec<crate::model::Edge>>,
68    /// Hand results back as markdown text (default). `false` → raw JSON text.
69    markdown: bool,
70    /// The key records agents write are signed with; this machine's in local mode.
71    signer: Option<std::path::PathBuf>,
72    /// Who records are written for, in local mode; on a server, the member whose token is used.
73    identity: crate::ledger::Identity,
74    /// The agent's name, when set rather than taken from the MCP handshake.
75    agent_name: Option<String>,
76    /// The org's teams: labels on repositories, and who belongs to which.
77    pub teams: Teams,
78}
79
80impl ComindlyServer {
81    /// The index root holding `_graph` and `_ledger`.
82    fn ledger_root(&self) -> String {
83        self.uri
84            .trim_end_matches('/')
85            .strip_suffix("/_graph")
86            .unwrap_or(&self.uri)
87            .to_string()
88    }
89
90    fn team_settings(&self) -> crate::team::TeamSettings {
91        self.teams.read().unwrap_or_else(|e| e.into_inner()).clone()
92    }
93
94    /// Whether `repo` is in the teams a tool's `team` argument names.
95    fn in_team(&self, team: Option<&str>, repo: &str) -> bool {
96        let sel = crate::team::parse_selection(team);
97        sel.is_empty() || self.team_settings().in_view(repo, &sel)
98    }
99
100    /// Status and trust of every record, with notes from outside a team weighed.
101    fn entries(
102        &self,
103        records: &[crate::ledger::Record],
104        symbols: &[Symbol],
105    ) -> Vec<crate::ledger::Entry> {
106        let mut e = crate::ledger::derive(records, symbols, &crate::config::local_verifier());
107        crate::ledger::settle_notes(&mut e, records, &self.logs);
108        crate::ledger::weigh_teams(&mut e, records, &self.team_settings());
109        if let Some(embedder) = self.embedder.as_deref() {
110            crate::ledger::flag_lookalikes(&mut e, records, embedder);
111        }
112        e
113    }
114
115    async fn records(&self) -> Vec<crate::ledger::Record> {
116        let mut cached = self.records.lock().await;
117        if let Some((at, r)) = cached.as_ref() {
118            if at.elapsed() < std::time::Duration::from_secs(60) {
119                return r.clone();
120            }
121        }
122        let r = match crate::ledger::Ledger::open(&self.ledger_root()) {
123            Ok(l) => l.all().await.unwrap_or_default(),
124            Err(_) => Vec::new(),
125        };
126        *cached = Some((std::time::Instant::now(), r.clone()));
127        r
128    }
129
130    /// Show a record this server just wrote without waiting for the next re-read.
131    async fn wrote(&self, rec: &crate::ledger::Record) {
132        if let Some((_, r)) = self.records.lock().await.as_mut() {
133            r.push(rec.clone());
134        }
135    }
136
137    /// The calling agent, from its MCP handshake unless named; over HTTP on a server, working for
138    /// the member whose token it uses.
139    /// Viewers' agents read; writing to the ledger needs a member.
140    fn may_write(ctx: &rmcp::service::RequestContext<rmcp::RoleServer>) -> anyhow::Result<()> {
141        let viewer = ctx
142            .extensions
143            .get::<axum::http::request::Parts>()
144            .and_then(|parts| parts.extensions.get::<crate::server::Who>())
145            .is_some_and(|w| !w.role.at_least(crate::server::Role::Member));
146        anyhow::ensure!(!viewer, "viewers can read, not write to the ledger");
147        Ok(())
148    }
149
150    fn agent(
151        &self,
152        ctx: &rmcp::service::RequestContext<rmcp::RoleServer>,
153    ) -> crate::ledger::Provenance {
154        let name = self.agent_name.clone().or_else(|| client_name(ctx));
155        let who = ctx
156            .extensions
157            .get::<axum::http::request::Parts>()
158            .and_then(|parts| parts.extensions.get::<crate::server::Who>());
159        let identity = match who {
160            Some(w) => crate::ledger::Identity {
161                actor: format!("actor-{}", &blake3::hash(w.email.as_bytes()).to_hex()[..10]),
162                name: Some(w.email.clone()),
163            },
164            None => self.identity.clone(),
165        };
166        let mut p = crate::ledger::Provenance::agent("mcp", name, &identity);
167        if let Some(w) = who {
168            p.teams = self.team_settings().teams_of_member(&w.email);
169        }
170        p
171    }
172
173    /// Trusted (T2+), still-relevant ledger records about these symbols, as compact lines.
174    async fn known(&self, ids: &[String], limit: usize) -> Vec<String> {
175        let records = self.records().await;
176        if records.is_empty() {
177            return Vec::new();
178        }
179        let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
180        let entries = self.entries(&records, &symbols);
181        let mut about = crate::ledger::about(&entries, ids);
182        // The owners' notes first; notes from other teams after them.
183        about.sort_by_key(|e| !e.outside.is_empty());
184        about
185            .into_iter()
186            .filter(|e| e.trust >= crate::ledger::Trust::T2)
187            .filter(|e| e.status == crate::ledger::Status::Active)
188            .take(limit)
189            .map(crate::ledger::line)
190            .collect()
191    }
192
193    async fn write_record(
194        &self,
195        p: RememberParam,
196        by: crate::ledger::Provenance,
197    ) -> anyhow::Result<String> {
198        let kind = crate::ledger::Kind::parse(&p.kind)
199            .ok_or_else(|| anyhow::anyhow!("unknown kind `{}`", p.kind))?;
200        let commits = crate::index::read_repo_commits(&self.uri)
201            .await
202            .unwrap_or_default();
203        let mut anchors = Vec::new();
204        for focus in &p.on {
205            let idx = self
206                .graph
207                .lookup(focus)
208                .ok_or_else(|| anyhow::anyhow!("no symbol matches `{focus}` — use `find` first"))?;
209            let id = self.graph.node(idx).id;
210            let s = self
211                .symbols
212                .get(&id)
213                .ok_or_else(|| anyhow::anyhow!("symbol `{id}` not loaded"))?;
214            anchors.push(crate::ledger::Anchor::of(s).at(&commits));
215        }
216        let repos: Vec<&str> = anchors.iter().map(|a| a.repo.as_str()).collect();
217        let teams = self.team_settings();
218        teams.accepts(&by.teams, &repos)?;
219        let outside = teams.outside(&by.teams, &repos);
220        let commit = anchors.first().and_then(|a| a.commit.clone());
221        let mut rec = crate::ledger::Record::new(kind, &p.title, anchors, by);
222        rec.snippets = p
223            .snippets
224            .into_iter()
225            .map(|s| crate::ledger::Snippet {
226                path: s.path,
227                code: s.code,
228                commit: s.commit.or_else(|| commit.clone()),
229            })
230            .collect();
231        rec.body = p.body.unwrap_or_default();
232        anyhow::ensure!(
233            !rec.body.trim().is_empty(),
234            "say why in `body`: every record carries the reasoning other agents need"
235        );
236        rec.trigger = p.trigger;
237        rec.evidence = p.evidence;
238        rec.fields = p
239            .fields
240            .into_iter()
241            .map(|(k, v)| (k, serde_json::Value::String(v)))
242            .collect();
243        if let Some(embedder) = self.embedder.as_deref() {
244            let records = self.records().await;
245            let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
246            let entries = self.entries(&records, &symbols);
247            let alike = crate::ledger::lookalikes(&entries, &rec, embedder);
248            let ids = |n: usize| {
249                alike
250                    .iter()
251                    .map(|(e, _)| e.record.id[..n].to_string())
252                    .collect::<Vec<_>>()
253            };
254            match p
255                .distinct
256                .as_deref()
257                .map(str::trim)
258                .filter(|d| !d.is_empty())
259            {
260                _ if alike.is_empty() => {}
261                Some(why) => {
262                    let differs = format!("{}: {why}", ids(8).join(", "));
263                    rec.fields
264                        .insert("differs_from".into(), serde_json::Value::String(differs));
265                }
266                None => {
267                    let list: Vec<String> = alike
268                        .iter()
269                        .map(|(e, sim)| {
270                            format!(
271                                "`{}` \"{}\" ({}, {:?}, {:?}, {:.2} alike)",
272                                &e.record.id[..13],
273                                e.record.title,
274                                e.record.kind.as_str(),
275                                e.trust,
276                                e.status,
277                                sim
278                            )
279                        })
280                        .collect();
281                    anyhow::bail!(
282                        "a record like this already stands: {}. If it holds, confirm it with `review` and put what yours adds in the reason. If yours says something different, call `remember` again with `distinct` saying how it differs; it is then written with that note.",
283                        list.join("; ")
284                    );
285                }
286            }
287        }
288        crate::ledger::Ledger::open(&self.ledger_root())?
289            .signed_by(self.signer.clone())
290            .put(&rec)
291            .await?;
292        self.wrote(&rec).await;
293        let until = match outside {
294            Some(owners) => format!("a member of {} reviews it", owners.join(" or ")),
295            None => "a human reviews it".into(),
296        };
297        Ok(format!(
298            "recorded {} `{}` (id `{}`); it is unconfirmed until {until}",
299            kind.as_str(),
300            rec.title,
301            rec.id
302        ))
303    }
304
305    async fn write_review(
306        &self,
307        p: ReviewParam,
308        by: crate::ledger::Provenance,
309    ) -> anyhow::Result<String> {
310        let ledger =
311            crate::ledger::Ledger::open(&self.ledger_root())?.signed_by(self.signer.clone());
312        let records = self.records().await;
313        anyhow::ensure!(
314            p.id.len() >= 6,
315            "give at least the first 6 characters of the record id"
316        );
317        let matches: Vec<_> = records
318            .iter()
319            .filter(|r| r.id == p.id || r.id.starts_with(&p.id))
320            .collect();
321        let target = match matches.as_slice() {
322            [one] => *one,
323            [] => anyhow::bail!("no ledger record `{}`", p.id),
324            _ => anyhow::bail!(
325                "`{}` matches {} records; give more of the id",
326                p.id,
327                matches.len()
328            ),
329        };
330        let reason = p.reason.as_deref().unwrap_or_default();
331        let commits = crate::index::read_repo_commits(&self.uri)
332            .await
333            .unwrap_or_default();
334        let mut rec = crate::ledger::Record::review(target, &p.action, reason, by)?
335            .vouching(target, |id| {
336                Some(crate::ledger::Anchor::of(self.symbols.get(id)?).at(&commits))
337            });
338        if p.from_person {
339            rec = rec.from_person();
340        }
341        ledger.put(&rec).await?;
342        self.wrote(&rec).await;
343        Ok(format!("{}: `{}`", p.action, target.title))
344    }
345
346    /// The repo a focus names: a repo itself, a file path, or a symbol.
347    fn repo_of(&self, focus: &str) -> Option<String> {
348        if self.graph.repos().iter().any(|(r, _)| r == focus) {
349            return Some(focus.to_string());
350        }
351        if let Some(s) = self
352            .symbols
353            .values()
354            .find(|s| s.kind == crate::model::SymbolKind::File && s.file_path.ends_with(focus))
355        {
356            return Some(s.repo.0.clone());
357        }
358        self.graph.lookup(focus).map(|i| self.graph.node(i).repo)
359    }
360
361    /// Build a result node, attaching its LLM summary when available.
362    fn node_dto(&self, n: GNode) -> NodeDto {
363        let enr = self.enrichment.get(&n.id);
364        NodeDto {
365            id: n.id,
366            name: n.name,
367            kind: n.kind,
368            repo: n.repo,
369            location: n.location,
370            signature: n.signature,
371            summary: enr.map(|(s, _)| s.clone()),
372            queries: enr.map(|(_, q)| q.clone()).unwrap_or_default(),
373        }
374    }
375
376    /// Wrap a tool DTO into a result: markdown text (default), or JSON text with the same JSON as
377    /// structured content.
378    fn reply<T: Serialize>(&self, dto: &T, md: String) -> CallToolResult {
379        let value = serde_json::to_value(dto).ok();
380        let text = if self.markdown {
381            md
382        } else {
383            value
384                .as_ref()
385                .and_then(|v| serde_json::to_string_pretty(v).ok())
386                .unwrap_or_default()
387        };
388        let mut r = CallToolResult::success(vec![ContentBlock::text(text)]);
389        // Clients such as Claude Code show structured content instead of the text when both come.
390        if !self.markdown {
391            r.structured_content = value;
392        }
393        r
394    }
395}
396
397// ---- DTOs (serialized to the agent) -----------------------------------------------------
398
399#[derive(Serialize, schemars::JsonSchema)]
400struct NodeDto {
401    id: String,
402    name: String,
403    kind: String,
404    repo: String,
405    /// `path:line` — jump straight here.
406    location: String,
407    signature: Option<String>,
408    /// One-line LLM summary, when the index was enriched.
409    summary: Option<String>,
410    /// Natural-language questions this symbol answers (pre-generated during `--enrich`).
411    #[serde(default, skip_serializing_if = "Vec::is_empty")]
412    queries: Vec<String>,
413}
414
415#[derive(Serialize, schemars::JsonSchema)]
416struct HopDto {
417    name: String,
418    kind: String,
419    repo: String,
420    location: String,
421    depth: usize,
422    via: String,
423}
424
425#[derive(Serialize, schemars::JsonSchema)]
426struct RepoCount {
427    repo: String,
428    symbols: usize,
429    /// Teams labelling the repository.
430    #[serde(skip_serializing_if = "Vec::is_empty")]
431    teams: Vec<String>,
432    /// Packages published by other indexed repos that this repo depends on.
433    #[serde(skip_serializing_if = "Vec::is_empty")]
434    depends_on: Vec<DepDto>,
435}
436
437#[derive(Serialize, schemars::JsonSchema)]
438struct DepDto {
439    package: String,
440    publisher: String,
441    published: Option<String>,
442    locked: Option<String>,
443    /// Pinned to a different version than the publisher's source.
444    skew: bool,
445}
446
447#[derive(Serialize, schemars::JsonSchema)]
448struct ReposDto {
449    repos: Vec<RepoCount>,
450    /// The org's teams.
451    #[serde(skip_serializing_if = "Vec::is_empty")]
452    teams: Vec<String>,
453}
454
455#[derive(Serialize, schemars::JsonSchema)]
456struct TeamCount {
457    team: String,
458    repos: usize,
459    symbols: usize,
460}
461
462#[derive(Serialize, schemars::JsonSchema)]
463struct FindDto {
464    results: Vec<NodeDto>,
465}
466
467#[derive(Serialize, schemars::JsonSchema)]
468struct ThreadDto {
469    focus: String,
470    trace: Vec<HopDto>,
471}
472
473#[derive(Serialize, schemars::JsonSchema)]
474struct FlowDto {
475    focus: String,
476    found: bool,
477    /// Pre-generated LLM walkthrough (present only if the index was built with `--flows`).
478    narration: Option<String>,
479    queries: Vec<String>,
480    trace: Vec<HopDto>,
481}
482
483#[derive(Serialize, schemars::JsonSchema)]
484struct ZoomDto {
485    found: bool,
486    focus: Option<NodeDto>,
487    container: Option<NodeDto>,
488    callers: Vec<NodeDto>,
489    callees: Vec<NodeDto>,
490    importers: Vec<NodeDto>,
491    members: Vec<NodeDto>,
492    /// Service contracts (HTTP routes, queues, buckets, tables) this symbol takes part in.
493    contracts: Vec<ContractRoleDto>,
494    /// When the focus is a contract: its providers and consumers.
495    providers: Vec<NodeDto>,
496    consumers: Vec<NodeDto>,
497    /// Recorded constraints, failed attempts and decisions about this symbol or its containers.
498    known: Vec<String>,
499}
500
501#[derive(Serialize, schemars::JsonSchema)]
502struct ContractRoleDto {
503    role: String,
504    contract: String,
505    /// Participants on the other side (consumers of what it serves, providers of what it uses).
506    counterparts: Vec<NodeDto>,
507}
508
509#[derive(Serialize, schemars::JsonSchema)]
510struct ContractDto {
511    contract: String,
512    providers: Vec<NodeDto>,
513    consumers: Vec<NodeDto>,
514}
515
516#[derive(Serialize, schemars::JsonSchema)]
517struct ContractsDto {
518    contracts: Vec<ContractDto>,
519}
520
521#[derive(Deserialize, schemars::JsonSchema, Default)]
522struct ContractsParam {
523    /// Only this contract type: `http`, `sqs`, `sns`, `s3` or `table`.
524    #[serde(default)]
525    kind: Option<String>,
526    /// Only contracts missing a provider or consumer (calls to nothing, unused routes).
527    #[serde(default)]
528    dangling: Option<bool>,
529    /// Substring filter on the contract key, e.g. `/courses` or `orders`.
530    #[serde(default)]
531    query: Option<String>,
532    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
533    #[serde(default)]
534    team: Option<String>,
535}
536
537#[derive(Serialize, schemars::JsonSchema)]
538struct RippleDto {
539    focus: String,
540    found: bool,
541    total_dependents: usize,
542    by_repo: Vec<RepoCount>,
543    /// Dependents per team; unlabelled repositories count under `none`.
544    #[serde(skip_serializing_if = "Vec::is_empty")]
545    by_team: Vec<TeamCount>,
546    dependents: Vec<HopDto>,
547}
548
549#[derive(Serialize, schemars::JsonSchema)]
550struct PackItem {
551    name: String,
552    location: String,
553    est_tokens: usize,
554    summary: Option<String>,
555}
556
557#[derive(Serialize, schemars::JsonSchema)]
558struct GuideDto {
559    found: bool,
560    /// Repo the conventions are scoped to (from `focus`), if any.
561    repo: Option<String>,
562    /// Measured conventions for the repo, then the org baseline.
563    conventions: Vec<ConventionDto>,
564    /// LLM-written narrative guide (present only if the index was built with `--guide`).
565    guide: Option<String>,
566}
567
568#[derive(Serialize, schemars::JsonSchema)]
569struct ConventionDto {
570    scope: String,
571    dimension: String,
572    rule: String,
573    /// How strictly the code follows it: `Always`, `Usually`, `Often` or `Sometimes`.
574    #[serde(skip_serializing_if = "Option::is_none")]
575    strength: Option<&'static str>,
576    /// What it was measured from.
577    #[serde(skip_serializing_if = "Option::is_none")]
578    evidence: Option<String>,
579    exemplars: Vec<String>,
580    counterexamples: Vec<String>,
581    /// Written or edited by a person rather than measured.
582    #[serde(skip_serializing_if = "std::ops::Not::not")]
583    team: bool,
584}
585
586#[derive(Deserialize, schemars::JsonSchema, Default)]
587struct RememberParam {
588    /// `decision` | `attempt` | `constraint` | `gotcha` | `intent` | `question`.
589    kind: String,
590    /// Symbols (ids or names) the record is about.
591    on: Vec<String>,
592    /// One neutral, factual line.
593    title: String,
594    /// Why it holds or what it is in favour of: the reasoning other agents need. Required.
595    #[serde(default)]
596    body: Option<String>,
597    /// When a future engineer would need this.
598    #[serde(default)]
599    trigger: Option<String>,
600    /// Evidence: `commit:<sha>`, `mr:!123` or `pr:#123`, `test:<path>::<name>`, error text… Required for `attempt`.
601    #[serde(default)]
602    evidence: Vec<String>,
603    /// Kind-specific fields, e.g. `{"outcome": "reverted"}`, `{"alternatives": "…"}`.
604    #[serde(default)]
605    fields: std::collections::BTreeMap<String, String>,
606    /// Up to 3 short excerpts (60 lines each) of the code that shaped this, as it is now.
607    #[serde(default)]
608    snippets: Vec<SnippetParam>,
609    /// Only after `remember` named a similar record: how this one differs from it. Writes it anyway.
610    #[serde(default)]
611    distinct: Option<String>,
612}
613
614#[derive(Deserialize, schemars::JsonSchema, Default)]
615struct SnippetParam {
616    /// File path, e.g. `app/auth/session.py`.
617    path: String,
618    code: String,
619    /// The commit the code is from; defaults to the indexed commit.
620    #[serde(default)]
621    commit: Option<String>,
622}
623
624#[derive(Deserialize, schemars::JsonSchema, Default)]
625struct PreflightParam {
626    /// Files you plan to change (repo-relative or absolute paths).
627    #[serde(default)]
628    files: Vec<String>,
629    /// Symbols you plan to change (ids or names).
630    #[serde(default)]
631    symbols: Vec<String>,
632}
633
634#[derive(Deserialize, schemars::JsonSchema, Default)]
635struct ReviewParam {
636    /// Record id (or a unique prefix) from `why`.
637    id: String,
638    /// `confirm` | `archive` (no longer applies) | `invalidate` (was wrong) | `supersede` | `dispute`.
639    action: String,
640    /// Why: what you checked, or why it changed. Required.
641    #[serde(default)]
642    reason: Option<String>,
643    /// True when your person said the record holds; counts as their own confirmation.
644    #[serde(default)]
645    from_person: bool,
646}
647
648#[derive(Serialize, schemars::JsonSchema)]
649struct LedgerDto {
650    ok: bool,
651    message: String,
652}
653
654#[derive(Deserialize, schemars::JsonSchema, Default)]
655struct GuideParam {
656    /// A repo name, file path or symbol: scopes conventions to its repo. Omit for org-wide.
657    #[serde(default)]
658    focus: Option<String>,
659}
660
661#[derive(Serialize, schemars::JsonSchema)]
662struct PackDto {
663    focus: String,
664    found: bool,
665    est_total_tokens: usize,
666    token_budget: usize,
667    items: Vec<PackItem>,
668    /// Recorded constraints and failed attempts about the focus and the packed symbols.
669    known: Vec<String>,
670}
671
672#[derive(Serialize, schemars::JsonSchema)]
673struct SearchHitDto {
674    /// Stable handle to pass to zoom/ripple/thread/flow/context_pack.
675    id: String,
676    name: String,
677    kind: String,
678    repo: String,
679    location: String,
680    signature: Option<String>,
681    score: f32,
682    deps: usize,
683    summary: Option<String>,
684}
685
686#[derive(Serialize, schemars::JsonSchema)]
687struct SearchDto {
688    query: String,
689    results: Vec<SearchHitDto>,
690}
691
692#[derive(Serialize, schemars::JsonSchema)]
693struct SuggestItem {
694    query: String,
695    symbol: String,
696    location: String,
697}
698
699#[derive(Serialize, schemars::JsonSchema)]
700struct SuggestDto {
701    total: usize,
702    suggestions: Vec<SuggestItem>,
703}
704
705// ---- tool parameters --------------------------------------------------------------------
706
707#[derive(Deserialize, schemars::JsonSchema, Default)]
708struct ReposParam {
709    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
710    #[serde(default)]
711    team: Option<String>,
712}
713
714#[derive(Deserialize, schemars::JsonSchema, Default)]
715struct SearchParam {
716    /// A natural-language question ("how do we create a migration?") or a symbol name.
717    query: String,
718    /// Max results (default 12).
719    limit: Option<u32>,
720    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
721    #[serde(default)]
722    team: Option<String>,
723}
724
725#[derive(Deserialize, schemars::JsonSchema, Default)]
726struct SuggestParam {
727    /// Optional keyword to filter suggestions (matches the question or the symbol name).
728    about: Option<String>,
729    /// Max suggestions (default 40).
730    limit: Option<u32>,
731    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
732    #[serde(default)]
733    team: Option<String>,
734}
735
736#[derive(Deserialize, schemars::JsonSchema, Default)]
737struct FindParam {
738    /// Symbol name or descriptor/path substring.
739    query: String,
740    /// Max results (default 20).
741    limit: Option<u32>,
742    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
743    #[serde(default)]
744    team: Option<String>,
745}
746
747#[derive(Deserialize, schemars::JsonSchema, Default)]
748struct FocusParam {
749    /// A symbol name or SCIP descriptor (e.g. `acme/const/Settings`).
750    focus: String,
751}
752
753#[derive(Deserialize, schemars::JsonSchema, Default)]
754struct DepthParam {
755    /// A symbol name or SCIP descriptor (from `find`/`search`, e.g. `acme/const/Settings`).
756    focus: String,
757    /// Max hops to traverse (default 4).
758    depth: Option<u32>,
759}
760
761#[derive(Deserialize, schemars::JsonSchema, Default)]
762struct PackParam {
763    /// A symbol name or SCIP descriptor (from `find`/`search`, e.g. `acme/const/Settings`).
764    focus: String,
765    /// Token budget for the read-set (default 1500).
766    token_budget: Option<u32>,
767}
768
769// ---- tools ------------------------------------------------------------------------------
770
771#[tool_router(vis = "pub(crate)")]
772impl ComindlyServer {
773    #[tool(
774        name = "search",
775        description = "Natural-language + lexical hybrid code search, reranked by dependency-graph centrality. Ask a question ('how do we create a migration?') or pass a symbol name."
776    )]
777    async fn search(&self, Parameters(p): Parameters<SearchParam>) -> CallToolResult {
778        let limit = p.limit.unwrap_or(12) as usize;
779        let Some(embedder) = self.embedder.as_deref() else {
780            let dto = SearchDto {
781                query: p.query.clone(),
782                results: vec![],
783            };
784            return self.reply(
785                &dto,
786                "_search unavailable: the embedding model could not be loaded (offline?). Graph tools (find/zoom/ripple/thread/context_pack) still work._".into(),
787            );
788        };
789        let hits = crate::search::hybrid(
790            &self.tables,
791            &self.symbols,
792            &self.graph,
793            &self.enrichment,
794            embedder,
795            &p.query,
796            if p.team.is_some() { limit * 5 } else { limit },
797        )
798        .await
799        .unwrap_or_default();
800        let hits: Vec<_> = hits
801            .into_iter()
802            .filter(|h| self.in_team(p.team.as_deref(), &h.repo))
803            .take(limit)
804            .collect();
805        let md = crate::search::markdown(&p.query, &hits);
806        let dto = SearchDto {
807            query: p.query.clone(),
808            results: hits.iter().map(hit_dto).collect(),
809        };
810        self.reply(&dto, md)
811    }
812
813    #[tool(
814        name = "repos",
815        description = "List indexed repositories with their symbol counts and the teams labelling each; `team` narrows to some teams"
816    )]
817    fn repos(&self, Parameters(p): Parameters<ReposParam>) -> CallToolResult {
818        let teams = self.team_settings();
819        let dto = ReposDto {
820            repos: self
821                .graph
822                .repos()
823                .into_iter()
824                .filter(|(repo, _)| self.in_team(p.team.as_deref(), repo))
825                .map(|(repo, symbols)| RepoCount {
826                    depends_on: comindly_deps(&self.packages, &repo),
827                    teams: teams.teams_of(&repo),
828                    repo,
829                    symbols,
830                })
831                .collect(),
832            teams: teams.team_list().iter().map(|t| t.name.clone()).collect(),
833        };
834        let md = md_repos(&dto);
835        self.reply(&dto, md)
836    }
837
838    #[tool(
839        name = "suggest",
840        description = "Suggested questions you can ask about this codebase (pre-generated during --enrich), the most depended-on code first; vendored code is left out. Optionally filter by a keyword to discover what flows/topics exist."
841    )]
842    fn suggest(&self, Parameters(p): Parameters<SuggestParam>) -> CallToolResult {
843        let limit = p.limit.unwrap_or(40) as usize;
844        let about = p.about.as_deref().map(str::to_lowercase);
845        let mut items: Vec<(usize, SuggestItem)> = Vec::new();
846        for (id, (_, queries)) in self.enrichment.iter() {
847            if self
848                .symbols
849                .get(id)
850                .is_some_and(|s| third_party(&s.file_path))
851            {
852                continue;
853            }
854            let used = self.graph.dependents_count(id);
855            if p.team.is_some()
856                && !self
857                    .symbols
858                    .get(id)
859                    .is_some_and(|s| self.in_team(p.team.as_deref(), &s.repo.0))
860            {
861                continue;
862            }
863            let (symbol, location) = self
864                .symbols
865                .get(id)
866                .map(|s| {
867                    (
868                        s.name.clone(),
869                        format!("{}:{}", s.file_path, s.range.start.line),
870                    )
871                })
872                .unwrap_or_else(|| (id.clone(), String::new()));
873            for q in queries {
874                if let Some(a) = &about {
875                    if !q.to_lowercase().contains(a) && !symbol.to_lowercase().contains(a) {
876                        continue;
877                    }
878                }
879                items.push((
880                    used,
881                    SuggestItem {
882                        query: q.clone(),
883                        symbol: symbol.clone(),
884                        location: location.clone(),
885                    },
886                ));
887            }
888        }
889        // The most depended-on code first: what a newcomer most needs to understand.
890        items.sort_by(|(ua, a), (ub, b)| {
891            ub.cmp(ua)
892                .then(a.symbol.cmp(&b.symbol))
893                .then(a.query.cmp(&b.query))
894        });
895        let total = items.len();
896        let items: Vec<SuggestItem> = items.into_iter().take(limit).map(|(_, i)| i).collect();
897        let dto = SuggestDto {
898            total,
899            suggestions: items,
900        };
901        let md = md_suggest(&dto);
902        self.reply(&dto, md)
903    }
904
905    #[tool(
906        name = "find",
907        description = "Find symbols by name or path substring. Returns each match with its `id` and `file:line` — the entry point to the graph tools: pass an `id` to zoom/ripple/thread/context_pack/flow."
908    )]
909    fn find(&self, Parameters(p): Parameters<FindParam>) -> CallToolResult {
910        let limit = p.limit.unwrap_or(20) as usize;
911        let dto = FindDto {
912            results: self
913                .graph
914                .find(&p.query, if p.team.is_some() { limit * 5 } else { limit })
915                .into_iter()
916                .filter(|n| self.in_team(p.team.as_deref(), &n.repo))
917                .take(limit)
918                .map(|n| self.node_dto(n))
919                .collect(),
920        };
921        let md = md_find(&p.query, &dto);
922        self.reply(&dto, md)
923    }
924
925    #[tool(
926        name = "zoom",
927        description = "360° view of one symbol — definition, container, callers, callees, importers, members. Use to understand a symbol before changing it. Takes a symbol id/name from find/search."
928    )]
929    async fn zoom(&self, Parameters(p): Parameters<FocusParam>) -> CallToolResult {
930        let dto = match self.graph.lookup(&p.focus) {
931            None => ZoomDto {
932                found: false,
933                focus: None,
934                container: None,
935                callers: vec![],
936                callees: vec![],
937                importers: vec![],
938                members: vec![],
939                contracts: vec![],
940                providers: vec![],
941                consumers: vec![],
942                known: vec![],
943            },
944            Some(idx) => {
945                let z = self.graph.zoom(idx);
946                ZoomDto {
947                    found: true,
948                    focus: z.focus.map(|n| self.node_dto(n)),
949                    container: z.container.map(|n| self.node_dto(n)),
950                    callers: z.callers.into_iter().map(|n| self.node_dto(n)).collect(),
951                    callees: z.callees.into_iter().map(|n| self.node_dto(n)).collect(),
952                    importers: z.importers.into_iter().map(|n| self.node_dto(n)).collect(),
953                    members: z.members.into_iter().map(|n| self.node_dto(n)).collect(),
954                    contracts: z
955                        .contracts
956                        .into_iter()
957                        .map(|c| ContractRoleDto {
958                            role: c.role.to_string(),
959                            contract: c.contract.name,
960                            counterparts: c
961                                .counterparts
962                                .into_iter()
963                                .map(|n| self.node_dto(n))
964                                .collect(),
965                        })
966                        .collect(),
967                    providers: z.providers.into_iter().map(|n| self.node_dto(n)).collect(),
968                    consumers: z.consumers.into_iter().map(|n| self.node_dto(n)).collect(),
969                    known: match self.graph.scope_of(&p.focus) {
970                        Some((_, ids)) => self.known(&ids, 5).await,
971                        None => vec![],
972                    },
973                }
974            }
975        };
976        let md = md_zoom(&p.focus, &dto);
977        self.reply(&dto, md)
978    }
979
980    #[tool(
981        name = "contracts",
982        description = "Service contracts across repos: HTTP routes ↔ clients, SQS/SNS queues and topics, S3 buckets, DB tables — each with providers and consumers. Use to see how services talk, find calls to routes nobody serves (`dangling`), or filter by `kind`/`query`. `ripple` already crosses these."
983    )]
984    fn contracts(&self, Parameters(p): Parameters<ContractsParam>) -> CallToolResult {
985        let views = self
986            .graph
987            .contracts(p.kind.as_deref(), p.dangling.unwrap_or(false));
988        let dto = ContractsDto {
989            contracts: views
990                .into_iter()
991                .filter(|v| {
992                    p.query
993                        .as_deref()
994                        .is_none_or(|q| v.contract.name.contains(q))
995                })
996                .filter(|v| {
997                    p.team.is_none()
998                        || v.providers
999                            .iter()
1000                            .chain(&v.consumers)
1001                            .any(|n| self.in_team(p.team.as_deref(), &n.repo))
1002                })
1003                .take(200)
1004                .map(|v| ContractDto {
1005                    contract: v.contract.name,
1006                    providers: v.providers.into_iter().map(|n| self.node_dto(n)).collect(),
1007                    consumers: v.consumers.into_iter().map(|n| self.node_dto(n)).collect(),
1008                })
1009                .collect(),
1010        };
1011        let md = md_contracts(&dto);
1012        self.reply(&dto, md)
1013    }
1014
1015    #[tool(
1016        name = "why",
1017        description = "Reasoning blame for a symbol: recorded decisions, failed attempts, constraints, gotchas and intents about it or its class/file — with status (active / orphaned), notes on code that changed since, trust and evidence. Check before changing code so you don't repeat a failed approach or break a constraint."
1018    )]
1019    async fn why(&self, Parameters(p): Parameters<FocusParam>) -> CallToolResult {
1020        let Some((name, ids, n)) = self.graph.scopes_of(&p.focus) else {
1021            let dto = LedgerDto {
1022                ok: false,
1023                message: format!("no symbol matches `{}`", p.focus),
1024            };
1025            let md = format!("_{}_", dto.message);
1026            return self.reply(&dto, md);
1027        };
1028        let records = self.records().await;
1029        let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
1030        let entries = self.entries(&records, &symbols);
1031        let about = crate::ledger::about(&entries, &ids);
1032        let md = crate::ledger::markdown(&crate::answers::why_title(&name, n), &about);
1033        let dto = LedgerDto {
1034            ok: true,
1035            message: md.clone(),
1036        };
1037        self.reply(&dto, md)
1038    }
1039
1040    #[tool(
1041        name = "preflight",
1042        description = "Before you change code: given the files and/or symbols you plan to edit, returns recorded constraints and failed attempts about them, contracts (routes, tables, queues) other services consume, blast radius across repos, and directories that usually change together but aren't in your plan. Run it on your plan before editing."
1043    )]
1044    async fn preflight(&self, Parameters(p): Parameters<PreflightParam>) -> CallToolResult {
1045        let mut changes = Vec::new();
1046        for f in &p.files {
1047            if let Some(s) = self.symbols.values().find(|s| {
1048                s.kind == crate::model::SymbolKind::File
1049                    && (s.file_path == *f || f.ends_with(&format!("/{}", s.file_path)))
1050            }) {
1051                changes.push(crate::preflight::Change {
1052                    repo: s.repo.0.clone(),
1053                    path: s.file_path.clone(),
1054                    lines: Vec::new(),
1055                });
1056            }
1057        }
1058        for focus in &p.symbols {
1059            let Some(idx) = self.graph.lookup(focus) else {
1060                continue;
1061            };
1062            if let Some(s) = self.symbols.get(&self.graph.node(idx).id) {
1063                changes.push(crate::preflight::Change {
1064                    repo: s.repo.0.clone(),
1065                    path: s.file_path.clone(),
1066                    lines: vec![(s.range.start.line, s.range.end.line)],
1067                });
1068            }
1069        }
1070        let records = self.records().await;
1071        let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
1072        let entries = self.entries(&records, &symbols);
1073        let findings = crate::checks::run(&entries, &symbols, &self.edges, &self.packages);
1074        let recipes: Vec<crate::conventions::Convention> = self
1075            .conventions
1076            .iter()
1077            .filter(|c| c.dimension == "recipes")
1078            .cloned()
1079            .collect();
1080        let md = if changes.is_empty() {
1081            "_no indexed files or symbols matched — pass repo-relative paths or names from `find`._"
1082                .to_string()
1083        } else {
1084            crate::preflight::report(
1085                &self.graph,
1086                &symbols,
1087                &self.edges,
1088                &entries,
1089                &recipes,
1090                &findings,
1091                &changes,
1092            ) + &crate::preflight::teams_affected(
1093                &self.graph,
1094                &symbols,
1095                &changes,
1096                &self.team_settings(),
1097            )
1098        };
1099        let dto = LedgerDto {
1100            ok: !changes.is_empty(),
1101            message: md.clone(),
1102        };
1103        self.reply(&dto, md)
1104    }
1105
1106    #[tool(
1107        name = "remember",
1108        description = "Record durable engineering knowledge about symbols for the team: a failed or reverted approach (`attempt`, evidence required), a choice between alternatives (`decision`), an invariant (`constraint`), a trap (`gotcha`), why code exists (`intent`) or an open `question`. Only record what a future engineer would be glad to know before editing — not summaries of what you did. Write neutral facts, never instructions. If a standing record on the same code reads alike, nothing is written and the reply names it: confirm that one instead, or call again with `distinct` if yours says something different."
1109    )]
1110    async fn remember(
1111        &self,
1112        ctx: rmcp::service::RequestContext<rmcp::RoleServer>,
1113        Parameters(p): Parameters<RememberParam>,
1114    ) -> CallToolResult {
1115        let result = match Self::may_write(&ctx) {
1116            Ok(()) => self.write_record(p, self.agent(&ctx)).await,
1117            Err(e) => Err(e),
1118        };
1119        let dto = LedgerDto {
1120            ok: result.is_ok(),
1121            message: result.unwrap_or_else(|e| format!("not recorded: {e:#}")),
1122        };
1123        let md = dto.message.clone();
1124        self.reply(&dto, md)
1125    }
1126
1127    #[tool(
1128        name = "review",
1129        description = "Act on a ledger record: `confirm` (still true), `archive` (was true, no longer applies), `invalidate` (was wrong), `supersede` (replaced by a newer record) or `dispute` (contradicted). Every action needs a `reason` other agents can read: what you checked, or why it changed. When you relied on a record and checked it still matches the code, confirm it; confirmations from several people's agents give a record weight. Archive, invalidate or supersede only with your person's OK. Set `from_person` when your person told you the record holds; that counts as their own confirmation."
1130    )]
1131    async fn review(
1132        &self,
1133        ctx: rmcp::service::RequestContext<rmcp::RoleServer>,
1134        Parameters(p): Parameters<ReviewParam>,
1135    ) -> CallToolResult {
1136        let result = match Self::may_write(&ctx) {
1137            Ok(()) => self.write_review(p, self.agent(&ctx)).await,
1138            Err(e) => Err(e),
1139        };
1140        let dto = LedgerDto {
1141            ok: result.is_ok(),
1142            message: result.unwrap_or_else(|e| format!("not recorded: {e:#}")),
1143        };
1144        let md = dto.message.clone();
1145        self.reply(&dto, md)
1146    }
1147
1148    #[tool(
1149        name = "ripple",
1150        description = "Blast radius (reverse deps): who transitively depends on this symbol, cross-repo, grouped by repo and by team. Use to gauge the impact of changing it. This is the reverse direction of `thread`. Returns dependents with depth + repo counts."
1151    )]
1152    fn ripple(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1153        let depth = p.depth.unwrap_or(4) as usize;
1154        let dto = match self.graph.lookup(&p.focus) {
1155            None => RippleDto {
1156                focus: p.focus.clone(),
1157                found: false,
1158                total_dependents: 0,
1159                by_repo: vec![],
1160                by_team: vec![],
1161                dependents: vec![],
1162            },
1163            Some(idx) => {
1164                let hops = self.graph.ripple(idx, depth);
1165                let teams = self.team_settings();
1166                let mut by_repo: std::collections::BTreeMap<String, usize> =
1167                    std::collections::BTreeMap::new();
1168                for h in &hops {
1169                    *by_repo.entry(h.node.repo.clone()).or_default() += 1;
1170                }
1171                RippleDto {
1172                    focus: p.focus.clone(),
1173                    found: true,
1174                    total_dependents: hops.len(),
1175                    by_team: by_team(&teams, &by_repo),
1176                    by_repo: by_repo
1177                        .into_iter()
1178                        .map(|(repo, symbols)| RepoCount {
1179                            teams: teams.teams_of(&repo),
1180                            repo,
1181                            symbols,
1182                            depends_on: Vec::new(),
1183                        })
1184                        .collect(),
1185                    dependents: hops.into_iter().map(hop_dto).collect(),
1186                }
1187            }
1188        };
1189        let md = md_ripple(&dto);
1190        self.reply(&dto, md)
1191    }
1192
1193    #[tool(
1194        name = "guide",
1195        description = "Measured coding conventions to follow when editing: naming, signatures, base classes, error roots, layout, logging, config, internal libraries to use instead of raw ones, testing — each with counts, exemplar locations and known deviations. Pass `focus` (repo, file or symbol) for its repo's conventions and narrative guide; the org baseline is always included. Without `focus`: the org baseline and which repos have a guide."
1196    )]
1197    fn guide(&self, Parameters(p): Parameters<GuideParam>) -> CallToolResult {
1198        let repo = p.focus.as_deref().and_then(|f| self.repo_of(f));
1199        let repo_keys: std::collections::HashSet<&str> = self
1200            .conventions
1201            .iter()
1202            .filter(|c| Some(&c.repo) == repo.as_ref())
1203            .map(|c| c.key.as_str())
1204            .collect();
1205        let mut conventions: Vec<ConventionDto> = Vec::new();
1206        for c in self.conventions.iter() {
1207            let scoped = Some(&c.repo) == repo.as_ref();
1208            let org = c.repo == crate::conventions::ORG && !repo_keys.contains(c.key.as_str());
1209            if scoped || org {
1210                conventions.push(ConventionDto {
1211                    scope: c.repo.clone(),
1212                    dimension: c.dimension.clone(),
1213                    rule: c.rule.clone(),
1214                    strength: c.strength(),
1215                    evidence: c.evidence(),
1216                    exemplars: c.exemplars.iter().take(2).cloned().collect(),
1217                    counterexamples: c.counterexamples.iter().take(2).cloned().collect(),
1218                    team: c.origin != crate::conventions::Origin::Measured,
1219                });
1220            }
1221        }
1222        // One repository's narrative guide; all of them together outgrow what a client shows.
1223        let guide = match &repo {
1224            Some(r) => self
1225                .style_guides
1226                .iter()
1227                .find(|(g, _)| g == r)
1228                .map(|(_, g)| g.clone()),
1229            None => (!self.style_guides.is_empty()).then(|| {
1230                let names: Vec<&str> = self.style_guides.iter().map(|(r, _)| r.as_str()).collect();
1231                format!(
1232                    "Written for each repository; pass `focus` to read one: {}.",
1233                    names.join(", ")
1234                )
1235            }),
1236        };
1237        let dto = GuideDto {
1238            found: !conventions.is_empty() || guide.is_some(),
1239            repo,
1240            conventions,
1241            guide,
1242        };
1243        let md = md_guide(&dto);
1244        self.reply(&dto, md)
1245    }
1246
1247    #[tool(
1248        name = "thread",
1249        description = "Forward call trace from an entry point: what this symbol calls, transitively, with depth + edge kind. Use to follow execution downstream. `flow` = this trace plus a narrated explanation; `ripple` = the reverse (who calls in)."
1250    )]
1251    fn thread(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1252        let depth = p.depth.unwrap_or(4) as usize;
1253        let trace = match self.graph.lookup(&p.focus) {
1254            Some(idx) => self
1255                .graph
1256                .thread(idx, depth)
1257                .into_iter()
1258                .map(hop_dto)
1259                .collect(),
1260            None => vec![],
1261        };
1262        let dto = ThreadDto {
1263            focus: p.focus.clone(),
1264            trace,
1265        };
1266        let md = md_thread(&dto);
1267        self.reply(&dto, md)
1268    }
1269
1270    #[tool(
1271        name = "flow",
1272        description = "Explain how a flow works: a pre-generated LLM walkthrough of an entry point (when the index was built with --flows) plus its live forward call trace."
1273    )]
1274    fn flow(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1275        let depth = p.depth.unwrap_or(4) as usize;
1276        let dto = match self.graph.lookup(&p.focus) {
1277            None => FlowDto {
1278                focus: p.focus.clone(),
1279                found: false,
1280                narration: None,
1281                queries: vec![],
1282                trace: vec![],
1283            },
1284            Some(idx) => {
1285                let fid = self.graph.zoom(idx).focus.map(|n| n.id);
1286                let (narration, queries) = fid
1287                    .as_ref()
1288                    .and_then(|id| self.flows.get(id))
1289                    .map(|(n, q)| (Some(n.clone()), q.clone()))
1290                    .unwrap_or((None, vec![]));
1291                let trace = self
1292                    .graph
1293                    .thread(idx, depth)
1294                    .into_iter()
1295                    .map(hop_dto)
1296                    .collect();
1297                FlowDto {
1298                    focus: p.focus.clone(),
1299                    found: true,
1300                    narration,
1301                    queries,
1302                    trace,
1303                }
1304            }
1305        };
1306        let md = md_flow(&dto);
1307        self.reply(&dto, md)
1308    }
1309
1310    #[tool(
1311        name = "context_pack",
1312        description = "The minimal token-budgeted set of symbols to read before editing the focus symbol safely. Use to assemble just-enough context for a change. Returns ranked symbols with locations + token estimates within the budget."
1313    )]
1314    async fn context_pack(&self, Parameters(p): Parameters<PackParam>) -> CallToolResult {
1315        let budget = p.token_budget.unwrap_or(1500) as usize;
1316        let dto = match self.graph.lookup(&p.focus) {
1317            None => PackDto {
1318                focus: p.focus.clone(),
1319                found: false,
1320                est_total_tokens: 0,
1321                token_budget: budget,
1322                items: vec![],
1323                known: vec![],
1324            },
1325            Some(idx) => {
1326                let pack = self.graph.context_pack(idx, budget);
1327                let est_total_tokens = pack.iter().map(|(_, t)| t).sum();
1328                let mut ids: Vec<String> = pack.iter().map(|(n, _)| n.id.clone()).collect();
1329                if let Some((_, scope)) = self.graph.scope_of(&p.focus) {
1330                    ids.extend(scope);
1331                }
1332                PackDto {
1333                    focus: p.focus.clone(),
1334                    found: true,
1335                    est_total_tokens,
1336                    token_budget: budget,
1337                    known: self.known(&ids, 6).await,
1338                    items: pack
1339                        .into_iter()
1340                        .map(|(n, t)| PackItem {
1341                            summary: self.enrichment.get(&n.id).map(|(s, _)| s.clone()),
1342                            name: n.name,
1343                            location: n.location,
1344                            est_tokens: t,
1345                        })
1346                        .collect(),
1347                }
1348            }
1349        };
1350        let md = md_pack(&dto);
1351        self.reply(&dto, md)
1352    }
1353}
1354
1355impl ComindlyServer {
1356    /// The markdown a read-only tool gives an agent for these arguments, for the app's agent view.
1357    pub async fn as_agent(&self, tool: &str, args: serde_json::Value) -> Result<String> {
1358        let me = Self {
1359            markdown: true,
1360            ..self.clone()
1361        };
1362        macro_rules! args {
1363            () => {
1364                Parameters(serde_json::from_value(args)?)
1365            };
1366        }
1367        let r = match tool {
1368            "repos" => me.repos(args!()),
1369            "contracts" => me.contracts(args!()),
1370            "guide" => me.guide(args!()),
1371            "find" => me.find(args!()),
1372            "suggest" => me.suggest(args!()),
1373            "ripple" => me.ripple(args!()),
1374            "thread" => me.thread(args!()),
1375            "flow" => me.flow(args!()),
1376            "zoom" => me.zoom(args!()).await,
1377            "why" => me.why(args!()).await,
1378            "context_pack" => me.context_pack(args!()).await,
1379            _ => anyhow::bail!("`{tool}` is not a read-only tool"),
1380        };
1381        Ok(r.content
1382            .iter()
1383            .filter_map(|c| c.as_text().map(|t| t.text.as_str()))
1384            .collect::<Vec<_>>()
1385            .join("\n"))
1386    }
1387}
1388
1389#[rmcp::tool_handler(router = Self::tool_router())]
1390impl rmcp::ServerHandler for ComindlyServer {
1391    fn get_info(&self) -> rmcp::model::ServerConfig {
1392        rmcp::model::ServerConfig::new(
1393            rmcp::model::ServerCapabilities::builder()
1394                .enable_tools()
1395                .build(),
1396        )
1397        .with_server_info(rmcp::model::Implementation::from_build_env())
1398        .with_instructions(crate::agents::GUIDE)
1399    }
1400
1401    /// Each tool call, logged with the client's own name, so a review bot shows apart from an editor.
1402    async fn call_tool(
1403        &self,
1404        request: rmcp::model::CallToolRequestParams,
1405        context: rmcp::service::RequestContext<rmcp::RoleServer>,
1406    ) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
1407        let tool = request.name.to_string();
1408        let parts = context.extensions.get::<axum::http::request::Parts>();
1409        let client = client_name(&context).unwrap_or_default();
1410        let email = parts
1411            .and_then(|p| p.extensions.get::<crate::server::Who>())
1412            .map(|w| w.email.clone());
1413        let start = std::time::Instant::now();
1414        let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
1415        let out = Self::tool_router().call(tcc).await;
1416        let ms = start.elapsed().as_millis() as u64;
1417        let ok = out.is_ok();
1418        if !crate::telemetry::logs("tool") {
1419            return out;
1420        }
1421        tracing::info!(
1422            kind = "tool",
1423            tool = %tool,
1424            client = %client,
1425            email = email.as_deref(),
1426            ok,
1427            duration_ms = ms,
1428            "tool {tool} by {} {ms} ms",
1429            if client.is_empty() { "an unnamed client" } else { client.as_str() }
1430        );
1431        out
1432    }
1433}
1434
1435/// The app making a call: the gateway's name for its OAuth client, else the client's own, unless the
1436/// MCP library filled in its own name for a request without a session.
1437fn client_name(ctx: &rmcp::service::RequestContext<rmcp::RoleServer>) -> Option<String> {
1438    ctx.extensions
1439        .get::<axum::http::request::Parts>()
1440        .and_then(|p| p.extensions.get::<crate::server::GatewayClient>())
1441        .map(|c| c.0.clone())
1442        .or_else(|| {
1443            let own = rmcp::model::Implementation::from_build_env().name;
1444            ctx.peer
1445                .peer_info()
1446                .map(|p| p.client_info.name.clone())
1447                .filter(|n| *n != own)
1448        })
1449}
1450
1451fn hop_dto(h: crate::graph::Hop) -> HopDto {
1452    HopDto {
1453        name: h.node.name,
1454        kind: h.node.kind,
1455        repo: h.node.repo,
1456        location: h.node.location,
1457        depth: h.depth,
1458        via: h.via.as_str().to_string(),
1459    }
1460}
1461
1462fn hit_dto(h: &crate::search::SearchHit) -> SearchHitDto {
1463    SearchHitDto {
1464        id: h.id.clone(),
1465        name: h.name.clone(),
1466        kind: h.kind.clone(),
1467        repo: h.repo.clone(),
1468        location: h.location.clone(),
1469        signature: h.signature.clone(),
1470        score: h.score,
1471        deps: h.deps,
1472        summary: h.summary.clone(),
1473    }
1474}
1475
1476/// Code vendored from elsewhere, which suggestions skip.
1477fn third_party(path: &str) -> bool {
1478    path.split('/').any(|c| {
1479        matches!(
1480            c,
1481            "_vendor" | "vendor" | "vendored" | "third_party" | "node_modules" | "site-packages"
1482        )
1483    })
1484}
1485
1486// ---- markdown rendering (the agent-facing handover) -------------------------------------
1487
1488fn node_bullet(o: &mut String, n: &NodeDto) {
1489    let _ = write!(o, "- **{}** _{}_ — `{}`", n.name, n.kind, n.location);
1490    if !n.repo.is_empty() {
1491        let _ = write!(o, " · {}", n.repo);
1492    }
1493    if let Some(s) = &n.summary {
1494        let _ = write!(o, " — {s}");
1495    }
1496    // Stable handle to pass back to zoom/ripple/thread/flow/context_pack.
1497    let _ = write!(o, "  ·  id `{}`", n.id);
1498    o.push('\n');
1499}
1500
1501fn node_section(o: &mut String, title: &str, nodes: &[NodeDto]) {
1502    if nodes.is_empty() {
1503        return;
1504    }
1505    let _ = write!(o, "\n**{title}** ({})\n", nodes.len());
1506    for n in nodes {
1507        node_bullet(o, n);
1508    }
1509}
1510
1511fn md_repos(d: &ReposDto) -> String {
1512    let mut o = String::from("## Indexed repositories\n\n");
1513    if !d.teams.is_empty() {
1514        let _ = writeln!(o, "Teams: {}\n", d.teams.join(", "));
1515    }
1516    if d.repos.is_empty() {
1517        return o + "_none_\n";
1518    }
1519    for r in &d.repos {
1520        let teams = if r.teams.is_empty() {
1521            String::new()
1522        } else {
1523            format!(" · teams: {}", r.teams.join(", "))
1524        };
1525        let _ = writeln!(o, "- **{}** — {} symbols{teams}", r.repo, r.symbols);
1526        for dep in &r.depends_on {
1527            let _ = writeln!(
1528                o,
1529                "  - uses `{}` from **{}** — locked {} vs published {}{}",
1530                dep.package,
1531                dep.publisher,
1532                dep.locked.as_deref().unwrap_or("?"),
1533                dep.published.as_deref().unwrap_or("?"),
1534                if dep.skew { " ⚠ skew" } else { "" }
1535            );
1536        }
1537    }
1538    o
1539}
1540
1541fn comindly_deps(rows: &[crate::manifests::PackageRow], repo: &str) -> Vec<DepDto> {
1542    crate::manifests::internal(rows)
1543        .into_iter()
1544        .filter(|d| d.consumer == repo)
1545        .map(|d| DepDto {
1546            skew: d.skewed(),
1547            package: d.name,
1548            publisher: d.publisher,
1549            published: d.published,
1550            locked: d.locked,
1551        })
1552        .collect()
1553}
1554
1555fn md_suggest(d: &SuggestDto) -> String {
1556    let shown = d.suggestions.len();
1557    let mut o = if d.total > shown {
1558        format!(
1559            "## Suggested questions ({shown} of {} — pass `about` to filter)\n\n",
1560            d.total
1561        )
1562    } else {
1563        format!("## Suggested questions ({shown})\n\n")
1564    };
1565    if d.suggestions.is_empty() {
1566        return o + "_none — index was built without `--enrich`, or no match_\n";
1567    }
1568    let mut cur = "";
1569    for it in &d.suggestions {
1570        if it.symbol != cur {
1571            let _ = write!(o, "\n**{}** `{}`\n", it.symbol, it.location);
1572            cur = &it.symbol;
1573        }
1574        let _ = writeln!(o, "- {}", it.query);
1575    }
1576    o
1577}
1578
1579fn md_find(query: &str, d: &FindDto) -> String {
1580    let mut o = format!("## `find` \"{query}\" — {} result(s)\n\n", d.results.len());
1581    if d.results.is_empty() {
1582        return o + "_no symbols matched_\n";
1583    }
1584    for n in &d.results {
1585        node_bullet(&mut o, n);
1586    }
1587    o
1588}
1589
1590fn md_zoom(focus: &str, d: &ZoomDto) -> String {
1591    if !d.found {
1592        return format!("## zoom `{focus}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n");
1593    }
1594    let mut o = String::new();
1595    if let Some(f) = &d.focus {
1596        let _ = writeln!(o, "## {} _{}_\n`{}`", f.name, f.kind, f.location);
1597        if let Some(sig) = &f.signature {
1598            let _ = writeln!(o, "\n```\n{sig}\n```");
1599        }
1600        if let Some(s) = &f.summary {
1601            let _ = writeln!(o, "\n{s}");
1602        }
1603        if !f.queries.is_empty() {
1604            let _ = writeln!(o, "\n**Ask:** {}", f.queries.join(" · "));
1605        }
1606    } else {
1607        let _ = writeln!(o, "## zoom `{focus}`");
1608    }
1609    if let Some(c) = &d.container {
1610        let _ = write!(o, "\n**Container**\n");
1611        node_bullet(&mut o, c);
1612    }
1613    node_section(&mut o, "Members", &d.members);
1614    node_section(&mut o, "Callers", &d.callers);
1615    node_section(&mut o, "Callees", &d.callees);
1616    node_section(&mut o, "Importers", &d.importers);
1617    if !d.known.is_empty() {
1618        o.push_str(
1619            "\n**Known constraints & dead ends** (team ledger — verify before relying on them)\n",
1620        );
1621        for k in &d.known {
1622            let _ = writeln!(o, "- {k}");
1623        }
1624    }
1625    node_section(&mut o, "Provided by", &d.providers);
1626    node_section(&mut o, "Used by", &d.consumers);
1627    if !d.contracts.is_empty() {
1628        let _ = write!(o, "\n**Contracts**\n");
1629        for c in &d.contracts {
1630            let _ = writeln!(o, "- {} `{}`", c.role, c.contract);
1631            for n in &c.counterparts {
1632                let _ = writeln!(o, "  - **{}** ({}) `{}`", n.name, n.repo, n.location);
1633            }
1634        }
1635    }
1636    o
1637}
1638
1639fn md_contracts(d: &ContractsDto) -> String {
1640    let mut o = format!("## {} contract(s)\n", d.contracts.len());
1641    for c in &d.contracts {
1642        let _ = writeln!(o, "\n### `{}`", c.contract);
1643        if c.providers.is_empty() {
1644            o.push_str("- provided by: _none indexed_\n");
1645        }
1646        // A symbol on both sides is listed once.
1647        let both = |n: &NodeDto| c.consumers.iter().any(|m| m.id == n.id);
1648        let sides = c.providers.iter().map(|n| {
1649            (
1650                n,
1651                if both(n) {
1652                    "provides and uses"
1653                } else {
1654                    "provided by"
1655                },
1656            )
1657        });
1658        let users = c
1659            .consumers
1660            .iter()
1661            .filter(|n| !c.providers.iter().any(|m| m.id == n.id));
1662        for (n, label) in sides.chain(users.map(|n| (n, "used by"))) {
1663            let _ = writeln!(
1664                o,
1665                "- {label}: **{}** ({}) `{}` · id `{}`",
1666                n.name, n.repo, n.location, n.id
1667            );
1668        }
1669        if c.consumers.is_empty() {
1670            o.push_str("- used by: _none indexed_\n");
1671        }
1672    }
1673    o
1674}
1675
1676fn md_hop_lines(o: &mut String, hops: &[HopDto]) {
1677    for h in hops {
1678        let _ = writeln!(
1679            o,
1680            "- _d{}_ via **{}** — **{}** `{}`",
1681            h.depth, h.via, h.name, h.location
1682        );
1683    }
1684}
1685
1686fn md_ripple(d: &RippleDto) -> String {
1687    if !d.found {
1688        return format!("## Blast radius: `{}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n", d.focus);
1689    }
1690    let mut o = format!(
1691        "## Blast radius: `{}`\n\n**{} dependent(s)**",
1692        d.focus, d.total_dependents
1693    );
1694    if !d.by_repo.is_empty() {
1695        let by: Vec<String> = d
1696            .by_repo
1697            .iter()
1698            .map(|r| format!("{} ({})", r.repo, r.symbols))
1699            .collect();
1700        let _ = write!(o, " across {}", by.join(", "));
1701    }
1702    if !d.by_team.is_empty() {
1703        let by: Vec<String> = d
1704            .by_team
1705            .iter()
1706            .map(|t| {
1707                let what = if t.repos == 1 { "repo" } else { "repos" };
1708                format!("{} ({} {what}, {} symbols)", t.team, t.repos, t.symbols)
1709            })
1710            .collect();
1711        let _ = write!(o, "\n\nTeams affected: {}", by.join(", "));
1712    }
1713    o.push_str("\n\n");
1714    md_hop_lines(&mut o, &d.dependents);
1715    o
1716}
1717
1718/// Dependents per team from per-repo counts; a repository in two teams counts for both.
1719fn by_team(
1720    teams: &crate::team::TeamSettings,
1721    by_repo: &std::collections::BTreeMap<String, usize>,
1722) -> Vec<TeamCount> {
1723    if teams.team_list().is_empty() {
1724        return Vec::new();
1725    }
1726    let mut out: Vec<TeamCount> = Vec::new();
1727    for (repo, n) in by_repo {
1728        let mut names = teams.teams_of(repo);
1729        if names.is_empty() {
1730            names.push(crate::team::UNASSIGNED.to_string());
1731        }
1732        for name in names {
1733            match out.iter_mut().find(|t| t.team == name) {
1734                Some(t) => {
1735                    t.repos += 1;
1736                    t.symbols += n;
1737                }
1738                None => out.push(TeamCount {
1739                    team: name,
1740                    repos: 1,
1741                    symbols: *n,
1742                }),
1743            }
1744        }
1745    }
1746    out.sort_by(|a, b| b.symbols.cmp(&a.symbols).then(a.team.cmp(&b.team)));
1747    out
1748}
1749
1750fn md_thread(d: &ThreadDto) -> String {
1751    let mut o = format!(
1752        "## Call trace from `{}` — {} step(s)\n\n",
1753        d.focus,
1754        d.trace.len()
1755    );
1756    if d.trace.is_empty() {
1757        return o + "_no outgoing calls found_\n";
1758    }
1759    md_hop_lines(&mut o, &d.trace);
1760    o
1761}
1762
1763fn md_flow(d: &FlowDto) -> String {
1764    if !d.found {
1765        return format!("## Flow: `{}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n", d.focus);
1766    }
1767    let mut o = format!("## Flow: `{}`\n\n", d.focus);
1768    match &d.narration {
1769        Some(n) => {
1770            let _ = writeln!(o, "{n}\n");
1771        }
1772        None => {
1773            let _ = writeln!(
1774                o,
1775                "_no pre-generated narration (build the index with `--flows` to add one) — raw call trace below._\n"
1776            );
1777        }
1778    }
1779    if !d.queries.is_empty() {
1780        let _ = writeln!(o, "**Ask:** {}\n", d.queries.join(" · "));
1781    }
1782    let _ = writeln!(o, "**Call trace** ({} steps)", d.trace.len());
1783    md_hop_lines(&mut o, &d.trace);
1784    o
1785}
1786
1787fn md_pack(d: &PackDto) -> String {
1788    if !d.found {
1789        return format!("## Context pack: `{}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n", d.focus);
1790    }
1791    let mut o = format!(
1792        "## Context to change `{}`\n\n_~{} of {} token budget · {} symbols_\n\n",
1793        d.focus,
1794        d.est_total_tokens,
1795        d.token_budget,
1796        d.items.len()
1797    );
1798    if !d.known.is_empty() {
1799        o.push_str(
1800            "**Known constraints & dead ends** (team ledger — verify before relying on them)\n",
1801        );
1802        for k in &d.known {
1803            let _ = writeln!(o, "- {k}");
1804        }
1805        o.push('\n');
1806    }
1807    for (i, it) in d.items.iter().enumerate() {
1808        let _ = write!(
1809            o,
1810            "{}. **{}** — `{}` _(~{} tok)_",
1811            i + 1,
1812            it.name,
1813            it.location,
1814            it.est_tokens
1815        );
1816        if let Some(s) = &it.summary {
1817            let _ = write!(o, " — {s}");
1818        }
1819        o.push('\n');
1820    }
1821    o
1822}
1823
1824fn md_guide(d: &GuideDto) -> String {
1825    let mut o = match &d.repo {
1826        Some(r) => format!("## Conventions — {r}\n"),
1827        None => "## Conventions — org baseline\n".to_string(),
1828    };
1829    if d.conventions.is_empty() {
1830        o.push_str("\n_none measured — re-run `comindly index`/`link` to store facts._\n");
1831    }
1832    let mut org_header = false;
1833    for c in &d.conventions {
1834        if c.scope == crate::conventions::ORG && d.repo.is_some() && !org_header {
1835            o.push_str("\n**Org baseline**\n");
1836            org_header = true;
1837        }
1838        let team = if c.team { " _(set by the team)_" } else { "" };
1839        let how = c
1840            .strength
1841            .map(|s| format!(" ({})", s.to_lowercase()))
1842            .unwrap_or_default();
1843        let _ = writeln!(o, "- _{}_{how}: {}{team}", c.dimension, c.rule);
1844        if !c.exemplars.is_empty() {
1845            let _ = writeln!(o, "  - e.g. `{}`", c.exemplars.join("`, `"));
1846        }
1847        if !c.counterexamples.is_empty() {
1848            let _ = writeln!(o, "  - deviations: `{}`", c.counterexamples.join("`, `"));
1849        }
1850    }
1851    if let Some(g) = &d.guide {
1852        let _ = write!(o, "\n## Narrative guide\n\n{g}\n");
1853    }
1854    o
1855}
1856
1857/// Load the graph (and any LLM enrichment) from `uri` and serve MCP over stdio until the
1858/// client disconnects. `markdown` controls whether tool results are handed back as markdown
1859/// text (default) or raw JSON.
1860pub async fn serve_stdio(uri: &str, markdown: bool) -> Result<()> {
1861    let mut server = load(uri, markdown, crate::config::Config::load().fresh()).await?;
1862    server.signer = crate::config::local_signing_key();
1863    server.identity = crate::config::local_identity();
1864    server.agent_name = crate::config::env("COMINDLY_AGENT");
1865    if let Ok(t) = crate::team::load(&server.ledger_root()).await {
1866        server.teams = Arc::new(std::sync::RwLock::new(t.settings));
1867    }
1868    let service = server.serve(rmcp::transport::io::stdio()).await?;
1869    service.waiting().await?;
1870    Ok(())
1871}
1872
1873/// Load the graph, enrichment, conventions and search model for `uri` into a server.
1874pub async fn load(uri: &str, markdown: bool, fresh: std::time::Duration) -> Result<ComindlyServer> {
1875    let (symbols, edges) = crate::index::read_graph(uri).await?;
1876    let mut conventions = match crate::index::read_facts(uri).await {
1877        Ok(Some(facts)) => crate::conventions::mine(&symbols, &edges, &facts),
1878        _ => Vec::new(),
1879    };
1880    conventions.extend(crate::index::read_recipes(uri).await.unwrap_or_default());
1881    let root = uri.trim_end_matches('/');
1882    let root = root.strip_suffix("/_graph").unwrap_or(root);
1883    let manual = match crate::conventions::Store::open(root) {
1884        Ok(s) => s.all().await.unwrap_or_default(),
1885        Err(_) => Vec::new(),
1886    };
1887    let conventions = crate::conventions::apply(conventions, &manual);
1888    let graph = Arc::new(CodeGraph::build(&symbols, &edges));
1889    let by_id: HashMap<String, Symbol> =
1890        symbols.iter().map(|s| (s.id.render(), s.clone())).collect();
1891
1892    let enrichment: Enrichment = crate::index::read_enrichment(uri)
1893        .await
1894        .ok()
1895        .flatten()
1896        .unwrap_or_default()
1897        .into_iter()
1898        .map(|(id, s, q)| (id.render(), (s, q)))
1899        .collect();
1900
1901    // Narrative style guides, one per repository.
1902    let style_guides = crate::index::read_style_guide(uri)
1903        .await
1904        .unwrap_or_default();
1905
1906    let flows: HashMap<String, (String, Vec<String>)> = crate::index::read_flows(uri)
1907        .await
1908        .ok()
1909        .flatten()
1910        .unwrap_or_default()
1911        .into_iter()
1912        .map(|(id, narr, q)| (id.render(), (narr, q)))
1913        .collect();
1914
1915    // The embedder powers `search`; graph tools work without it, so a load failure is non-fatal.
1916    let model = crate::config::index_settings(root).await.embed_model;
1917    let embedder = match tokio::task::spawn_blocking(move || crate::embed::Embedder::shared(&model))
1918        .await
1919        .map_err(anyhow::Error::from)
1920        .and_then(|r| r)
1921    {
1922        Ok(e) => Some(e),
1923        Err(e) => {
1924            crate::ui::warn(&format!(
1925                "embedding model unavailable, `search` disabled: {e:#}"
1926            ));
1927            None
1928        }
1929    };
1930
1931    let server = ComindlyServer {
1932        graph,
1933        enrichment: Arc::new(enrichment),
1934        style_guides: Arc::new(style_guides),
1935        flows: Arc::new(flows),
1936        symbols: Arc::new(by_id),
1937        logs: Arc::new(crate::index::read_repo_logs(uri).await.unwrap_or_default()),
1938        embedder,
1939        uri: Arc::new(uri.to_string()),
1940        tables: Arc::new(crate::index::Tables::connect(uri, fresh).await?),
1941        records: Arc::new(tokio::sync::Mutex::new(None)),
1942        packages: Arc::new(crate::index::read_packages(uri).await.unwrap_or_default()),
1943        conventions: Arc::new(conventions),
1944        edges: Arc::new(edges),
1945        markdown,
1946        signer: None,
1947        identity: Default::default(),
1948        agent_name: None,
1949        teams: Default::default(),
1950    };
1951    Ok(server)
1952}
1953
1954/// The server agents reach over HTTP; empty until an index exists, replaced after each re-index.
1955pub type Shared = Arc<std::sync::RwLock<Option<ComindlyServer>>>;
1956
1957/// MCP over streamable HTTP for `hosts` (the server's own names), answering from `current`.
1958pub fn http(
1959    current: Shared,
1960    hosts: Vec<String>,
1961) -> rmcp::transport::streamable_http_server::StreamableHttpService<
1962    ComindlyServer,
1963    rmcp::transport::streamable_http_server::session::local::LocalSessionManager,
1964> {
1965    let config = rmcp::transport::streamable_http_server::StreamableHttpServerConfig::default()
1966        .with_allowed_hosts(hosts);
1967    rmcp::transport::streamable_http_server::StreamableHttpService::new(
1968        move || {
1969            current.read().ok().and_then(|s| s.clone()).ok_or_else(|| {
1970                std::io::Error::other("no index yet: an admin indexes repositories first")
1971            })
1972        },
1973        Default::default(),
1974        config,
1975    )
1976}
1977
1978/// The MCP tools this server offers: `(name, description)`.
1979pub fn tools() -> Vec<(String, String)> {
1980    ComindlyServer::tool_router()
1981        .list_all()
1982        .into_iter()
1983        .map(|t| {
1984            (
1985                t.name.to_string(),
1986                t.description.unwrap_or_default().to_string(),
1987            )
1988        })
1989        .collect()
1990}
1991
1992#[cfg(test)]
1993mod tests {
1994    use super::*;
1995
1996    #[test]
1997    fn a_contract_lists_each_symbol_once_with_its_id() {
1998        let node = |id: &str| NodeDto {
1999            id: id.into(),
2000            name: id.into(),
2001            kind: "function".into(),
2002            repo: "worker".into(),
2003            location: "w.py:1".into(),
2004            signature: None,
2005            summary: Some("long summary".into()),
2006            queries: vec!["a question".into()],
2007        };
2008        let md = md_contracts(&ContractsDto {
2009            contracts: vec![ContractDto {
2010                contract: "sqs:tasks".into(),
2011                providers: vec![node("retry"), node("send")],
2012                consumers: vec![node("retry"), node("run")],
2013            }],
2014        });
2015        assert_eq!(md.matches("**retry**").count(), 1);
2016        assert!(md.contains("provides and uses: **retry**"));
2017        assert!(md.contains("used by: **run** (worker) `w.py:1` · id `run`"));
2018        assert!(!md.contains("long summary") && !md.contains("a question"));
2019    }
2020
2021    #[test]
2022    fn vendored_code_is_third_party() {
2023        assert!(third_party("lib/_vendor/aioboto3/session.py"));
2024        assert!(third_party("web/node_modules/x/index.js"));
2025        assert!(!third_party("src/vendors.py"));
2026    }
2027
2028    #[test]
2029    fn impact_by_team() {
2030        let mut teams = crate::team::TeamSettings::default();
2031        teams.label("api", &["Backend".into(), "Payments".into()]);
2032        teams.label("web", &["Frontend".into()]);
2033        let by_repo = [
2034            ("api".to_string(), 3),
2035            ("web".into(), 1),
2036            ("tools".into(), 2),
2037        ]
2038        .into_iter()
2039        .collect();
2040        let got: Vec<(String, usize, usize)> = by_team(&teams, &by_repo)
2041            .into_iter()
2042            .map(|t| (t.team, t.repos, t.symbols))
2043            .collect();
2044        assert_eq!(
2045            got,
2046            [
2047                ("Backend".into(), 1, 3),
2048                ("Payments".into(), 1, 3),
2049                ("none".into(), 1, 2),
2050                ("Frontend".into(), 1, 1)
2051            ]
2052        );
2053        assert!(by_team(&Default::default(), &by_repo).is_empty());
2054    }
2055}