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