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