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