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