Skip to main content

mcp_methods/server/
server.rs

1//! MCP `ServerHandler` implementation.
2//!
3//! Tool surface, top to bottom:
4//!
5//! - **Always registered**: `ping`; the source tools (`read_source`,
6//!   `grep`, `list_source`) gated on an active source-roots provider;
7//!   `repo_management` (no-ops outside `--workspace` mode).
8//! - **Conditionally registered at boot** (dynamic):
9//!   - `github_issues` and `github_api` — only when `GITHUB_TOKEN` is
10//!     reachable. This is "honest tool listing": agents see the tools
11//!     only when they can succeed. Decision is boot-time; restart the
12//!     server to pick up a token that appears later.
13//!   - `set_root_dir` — only when the bound workspace is local-flavoured
14//!     (`workspace.kind: local`); swaps the active root at runtime.
15//!   - Manifest-declared `python:` tools and `cypher:` tools — added by
16//!     downstream binaries through `apply_python_extensions`.
17//!
18//! The source-roots provider is dynamic — workspace mode swaps it as
19//! the active repo changes; source-root and watch modes wire it to a
20//! fixed root; local-workspace mode rebinds it on `set_root_dir`. An
21//! empty list signals "no active source" and the tools return a
22//! friendly error rather than failing the call.
23//!
24//! Per-server state held on `McpServer` (cloned per request via `Arc`):
25//! a `ServerOptions` struct (providers + workspace handle + manifest
26//! builtins) and the rmcp `ToolRouter`. The `github_issues` closure
27//! additionally captures an `Arc<Mutex<ElementCache>>` so FETCH calls
28//! can cache collapsed elements (`cb_N`, `patch_N`, `comment_N`,
29//! `overflow`) for the agent to drill into via `element_id` on
30//! subsequent calls — no re-fetching.
31
32#![allow(dead_code)]
33
34use std::sync::{Arc, Mutex};
35
36use rmcp::handler::server::router::prompt::{PromptRoute, PromptRouter};
37use rmcp::handler::server::router::tool::ToolRouter;
38use rmcp::handler::server::wrapper::Parameters;
39use rmcp::model::*;
40use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
41use serde::{Deserialize, Serialize};
42
43use crate::server::manifest::Manifest;
44use crate::server::skills::ResolvedRegistry;
45use crate::server::source::{
46    self, resolve_dir_under_roots, GrepOpts, ListOpts, ReadOpts, SourceRootsProvider,
47};
48
49/// Provider returning the active GitHub repo (e.g. `"pydata/xarray"`)
50/// or `None` when nothing is bound. Workspace mode wires this to the
51/// active workspace repo; single-graph mode can pin a fixed value.
52pub type RepoProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
53
54/// Read-only runtime context handed to a [`ResultPostprocessHook`].
55/// Exposes the active source roots and repo so a consumer's hook can
56/// tailor its footer to the current binding without capturing the
57/// workspace itself. Decoupled by design — no framework types leak.
58pub struct ResultCtx {
59    /// Active source roots at call time (empty when none bound).
60    pub source_roots: Vec<String>,
61    /// Active workspace repo (`org/repo` or a synthetic local name),
62    /// or `None` when nothing is bound.
63    pub active_repo: Option<String>,
64}
65
66/// Hook invoked after every builtin tool produces its text result.
67///
68/// Receives the tool name, the call arguments (as JSON), the result
69/// body, and a read-only [`ResultCtx`]. Returns `Some(footer)` to
70/// append a steering line (the framework inserts a blank separator
71/// line), or `None` to leave the result byte-for-byte unchanged.
72///
73/// This is the framework's *runtime* consumer→agent text channel — the
74/// counterpart to the load-once tool descriptions. Consumers supply the
75/// domain-aware content: e.g. a graph-backed server can detect a
76/// definition-shaped `grep` pattern (or a zero-match result) and steer
77/// the agent to `cypher_query`. The framework owns the hook; the graph
78/// knowledge stays downstream.
79pub type ResultPostprocessHook =
80    Arc<dyn Fn(&str, &serde_json::Value, &str, &ResultCtx) -> Option<String> + Send + Sync>;
81
82/// Append a hook-produced footer to a result body, separated by a
83/// blank line. Empty/`None` footers leave the body untouched. Shared
84/// by both dispatch paths so the footer contract lives in one place.
85fn append_footer(body: String, footer: Option<String>) -> String {
86    match footer {
87        Some(f) if !f.is_empty() => format!("{body}\n\n{f}"),
88        _ => body,
89    }
90}
91
92/// Per-server runtime state shared by every tool dispatch.
93#[derive(Clone, Default)]
94pub struct ServerOptions {
95    /// Server display name surfaced via initialize.
96    pub name: Option<String>,
97    /// Free-form text shown to the agent at session start.
98    pub instructions: Option<String>,
99    /// Dynamic provider returning the active source roots, if any.
100    /// `None` disables the source tools entirely.
101    pub source_roots: Option<SourceRootsProvider>,
102    /// Dynamic provider returning the active GitHub repo (org/repo).
103    /// When `None`, github tools require a per-call `repo_name=` arg.
104    pub default_repo: Option<RepoProvider>,
105    /// Workspace handle (when `--workspace` mode is active).
106    pub workspace: Option<crate::server::workspace::Workspace>,
107    /// Manifest-declared `builtins:` block. Surfaced verbatim so
108    /// downstream consumers (kglite's `graph_overview` tool, for
109    /// example) can read `temp_cleanup` / `save_graph` settings and
110    /// implement the corresponding behaviour without re-parsing YAML.
111    pub builtins: crate::server::manifest::BuiltinsConfig,
112    /// Manifest-declared `extensions:` block. The framework uses this
113    /// for the `extension_enabled:` skill predicate; downstream
114    /// consumers can also read it for their own per-extension config.
115    /// Empty map when no `extensions:` block is present.
116    pub extensions: serde_json::Map<String, serde_json::Value>,
117    /// Optional consumer hook run after every builtin tool result to
118    /// append a runtime steering footer. `None` (default) leaves every
119    /// result unchanged. See [`ResultPostprocessHook`].
120    pub result_postprocess: Option<ResultPostprocessHook>,
121}
122
123impl std::fmt::Debug for ServerOptions {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.debug_struct("ServerOptions")
126            .field("name", &self.name)
127            .field("instructions", &self.instructions)
128            .field(
129                "source_roots",
130                &self.source_roots.as_ref().map(|_| "<provider>"),
131            )
132            .field(
133                "default_repo",
134                &self.default_repo.as_ref().map(|_| "<provider>"),
135            )
136            .finish()
137    }
138}
139
140impl ServerOptions {
141    pub fn from_manifest(manifest: Option<&Manifest>, fallback_name: &str) -> Self {
142        Self {
143            name: manifest
144                .and_then(|m| m.name.clone())
145                .or_else(|| Some(fallback_name.to_string())),
146            instructions: manifest.and_then(|m| m.instructions.clone()),
147            source_roots: None,
148            default_repo: None,
149            workspace: None,
150            builtins: manifest.map(|m| m.builtins.clone()).unwrap_or_default(),
151            extensions: manifest.map(|m| m.extensions.clone()).unwrap_or_default(),
152            result_postprocess: None,
153        }
154    }
155
156    pub fn with_static_source_roots(mut self, roots: Vec<String>) -> Self {
157        let captured = Arc::new(roots);
158        self.source_roots = Some(Arc::new(move || captured.as_ref().clone()));
159        self
160    }
161
162    pub fn with_dynamic_source_roots(mut self, provider: SourceRootsProvider) -> Self {
163        self.source_roots = Some(provider);
164        self
165    }
166
167    pub fn with_static_repo(mut self, repo: String) -> Self {
168        self.default_repo = Some(Arc::new(move || Some(repo.clone())));
169        self
170    }
171
172    pub fn with_dynamic_repo(mut self, provider: RepoProvider) -> Self {
173        self.default_repo = Some(provider);
174        self
175    }
176
177    /// Bind a workspace handle. Source roots and default repo become
178    /// dynamic — both are read from the workspace's active-repo state
179    /// at every tool call, so `repo_management` swapping the active
180    /// repo immediately re-points the source tools.
181    pub fn with_workspace(mut self, ws: crate::server::workspace::Workspace) -> Self {
182        let ws_for_roots = ws.clone();
183        let ws_for_repo = ws.clone();
184        self.workspace = Some(ws);
185        self.source_roots = Some(Arc::new(move || {
186            ws_for_roots
187                .active_repo_path()
188                .map(|p| vec![p.to_string_lossy().into_owned()])
189                .unwrap_or_default()
190        }));
191        self.default_repo = Some(Arc::new(move || ws_for_repo.default_github_repo()));
192        self
193    }
194
195    /// Register a [`ResultPostprocessHook`] run after every builtin
196    /// tool result. Consumers use this to append runtime steering (e.g.
197    /// a graph-backed server nudging the agent from `grep` toward
198    /// `cypher_query` when a pattern is definition-shaped).
199    pub fn with_result_postprocess(mut self, hook: ResultPostprocessHook) -> Self {
200        self.result_postprocess = Some(hook);
201        self
202    }
203}
204
205#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
206pub struct PingArgs {
207    /// Optional message to echo back. Defaults to "pong".
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub message: Option<String>,
210}
211
212#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
213pub struct ReadSourceArgs {
214    /// File path relative to the configured source root(s).
215    pub file_path: String,
216    /// Start line (1-indexed). Defaults to start-of-file.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub start_line: Option<usize>,
219    /// End line (1-indexed, inclusive). Defaults to end-of-file.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub end_line: Option<usize>,
222    /// Regex pattern to filter lines. Returns matching lines plus context.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub grep: Option<String>,
225    /// Lines of context around each grep match (default 2).
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub grep_context: Option<usize>,
228    /// Cap the number of matches returned.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub max_matches: Option<usize>,
231    /// Cap output size in characters.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub max_chars: Option<usize>,
234    /// Read the file at this git revision (tag, branch, or commit SHA)
235    /// via `git show` instead of the working tree. Requires the active
236    /// source root to be a git repository. All other options
237    /// (`start_line`/`grep`/`max_chars`/…) apply to the historical
238    /// content unchanged.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub rev: Option<String>,
241}
242
243#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
244pub struct GrepArgs {
245    /// Regex pattern (Rust regex syntax).
246    pub pattern: String,
247    /// File-name glob (e.g. ``"*.py"``). Defaults to all files.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub glob: Option<String>,
250    /// Lines of context around each match (default 0).
251    #[serde(default)]
252    pub context: usize,
253    /// Cap the number of matches (default 50; pass null/None for unlimited).
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub max_results: Option<usize>,
256    /// Case-insensitive matching.
257    #[serde(default)]
258    pub case_insensitive: bool,
259}
260
261#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
262pub struct SetRootDirArgs {
263    /// Absolute or relative path to bind as the new source root.
264    pub path: String,
265    /// Optionally load multiple git revisions of the new root into one
266    /// graph. An integer N loads the newest N stable release tags of the
267    /// repo's dominant tag family plus HEAD (prereleases like rc/dev and
268    /// unrelated tag families are skipped); a list of strings uses those
269    /// git revspecs (tags, branches, or SHAs) verbatim. Requires the root
270    /// to be a git repo. Omit for the default single-revision (working
271    /// tree) activation.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub revs: Option<crate::server::workspace::RevsRequest>,
274}
275
276#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
277pub struct RepoManagementArgs {
278    /// org/repo to clone and activate. Omit for list mode.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub name: Option<String>,
281    /// Delete the repo + inventory entry instead of activating.
282    #[serde(default)]
283    pub delete: bool,
284    /// Refresh the active repo (no name required).
285    #[serde(default)]
286    pub update: bool,
287    /// Bypass the auto-rebuild gate: re-run the post-activate hook
288    /// even when the HEAD SHA matches the last successful build.
289    /// Useful after upgrading the builder code itself.
290    #[serde(default)]
291    pub force_rebuild: bool,
292    /// Optionally load multiple git revisions of the repo into one graph.
293    /// An integer N loads the newest N stable release tags of the repo's
294    /// dominant tag family plus HEAD (prereleases like rc/dev and
295    /// unrelated tag families are skipped); a list of strings uses those
296    /// git revspecs (tags, branches, or SHAs) verbatim. Omit for the
297    /// default single-revision (HEAD) activation. A revs request always
298    /// rebuilds (the SHA-skip gate applies only to the plain path).
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub revs: Option<crate::server::workspace::RevsRequest>,
301}
302
303#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
304pub struct GithubIssuesArgs {
305    /// GitHub issue / PR / Discussion number (FETCH mode).
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub number: Option<u64>,
308    /// org/repo override; defaults to the active server repo.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub repo_name: Option<String>,
311    /// Free-text query (SEARCH mode). When set, `number` is ignored.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub query: Option<String>,
314    /// "issue" | "pr" | "discussion" | "all" (default).
315    #[serde(default = "default_kind")]
316    pub kind: String,
317    /// "open" (default) | "closed" | "all".
318    #[serde(default = "default_state")]
319    pub state: String,
320    /// Sort key. Default "created" for list mode, relevance for search.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub sort: Option<String>,
323    /// Max results to return (default 20).
324    #[serde(default = "default_limit")]
325    pub limit: usize,
326    /// Comma-separated label filter (e.g. "bug,P0").
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub labels: Option<String>,
329    /// Drill-down: cached collapsed-element ID returned by a previous
330    /// FETCH (e.g. ``"cb_1"``, ``"comment_3"``, ``"overflow"``). When
331    /// set, `number` is required and the call returns the cached
332    /// element instead of re-fetching.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub element_id: Option<String>,
335    /// Line range filter for drill-down (``"N-M"`` 1-indexed). Only
336    /// meaningful alongside `element_id`. For comment segments,
337    /// interpreted as comment-index range.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub lines: Option<String>,
340    /// Regex pattern for drill-down. Only meaningful alongside
341    /// `element_id`. Returns matching lines/items plus context.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub grep: Option<String>,
344    /// Context lines around each grep match in drill-down mode
345    /// (default 3).
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub context: Option<usize>,
348    /// Force a re-fetch (skip cache) when in FETCH mode. Useful after
349    /// an issue has been updated upstream.
350    #[serde(default)]
351    pub refresh: bool,
352}
353
354fn default_kind() -> String {
355    "all".to_string()
356}
357fn default_state() -> String {
358    "open".to_string()
359}
360fn default_limit() -> usize {
361    20
362}
363
364impl Default for GithubIssuesArgs {
365    fn default() -> Self {
366        Self {
367            number: None,
368            repo_name: None,
369            query: None,
370            kind: default_kind(),
371            state: default_state(),
372            sort: None,
373            limit: default_limit(),
374            labels: None,
375            element_id: None,
376            lines: None,
377            grep: None,
378            context: None,
379            refresh: false,
380        }
381    }
382}
383
384#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
385pub struct GithubApiArgs {
386    /// API path, with or without a leading slash. Repo-relative paths
387    /// (e.g. "pulls?state=open", "commits/abc", "branches",
388    /// "compare/main...x") are prefixed with /repos/<repo_name>/. Top-level
389    /// resources ("search/issues?q=...", "users/octocat", "repos/o/r") pass
390    /// through. A leading slash is accepted on either form — "/repos/o/r"
391    /// and "repos/o/r" resolve identically.
392    pub path: String,
393    /// org/repo override; defaults to the active server repo.
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub repo_name: Option<String>,
396    /// Truncate response body at N chars (default 80,000).
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    pub truncate_at: Option<usize>,
399}
400
401#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
402pub struct ListSourceArgs {
403    /// Subdirectory relative to the source root (default ``"."``).
404    #[serde(default = "default_path")]
405    pub path: String,
406    /// Recursion depth (1 = flat ls; 2+ = tree).
407    #[serde(default = "default_depth")]
408    pub depth: usize,
409    /// Glob filter for entry names.
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub glob: Option<String>,
412    /// Show only directories.
413    #[serde(default)]
414    pub dirs_only: bool,
415}
416
417fn default_path() -> String {
418    ".".to_string()
419}
420fn default_depth() -> usize {
421    1
422}
423
424#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
425pub struct ScreenStargazersArgs {
426    /// Repo whose stargazers to screen, as "owner/repo".
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub repo: Option<String>,
429    /// Alternatively, screen an explicit set of users — comma-separated
430    /// logins ("octocat,torvalds"). Takes precedence over `repo`.
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub users: Option<String>,
433    /// Focused view via a named preset: "outreach" (relevant+active by
434    /// reach), "peers" (your stack by effort), "legends" (biggest reach),
435    /// "intel" (on-domain by popularity), "adopters" (actual users).
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub preset: Option<String>,
438    /// Or rank explicitly by one axis: relatedness | popularity | effort | recency.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub rank_by: Option<String>,
441    /// Top-K for the focused/preset view (default 10).
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub top: Option<usize>,
444    /// Filter: minimum distinct keyword hits (relatedness gate).
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub min_keywords: Option<usize>,
447    /// Filter: only people active since this date (YYYY-MM-DD).
448    #[serde(default, skip_serializing_if = "Option::is_none")]
449    pub active_since: Option<String>,
450    /// Filter: only people who actually depend on the seed package.
451    #[serde(default)]
452    pub adopters_only: bool,
453    /// Filter: only architectural (stack) peers.
454    #[serde(default)]
455    pub stack_only: bool,
456    /// Comma-separated topic keywords for the relevance gate (e.g.
457    /// "graph,rag,agent,llm"). Matched whole-word against repo
458    /// name/topics/description; devs hitting ≥2 distinct keywords are
459    /// surfaced as leads, single-keyword hits demoted to a footnote.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub keywords: Option<String>,
462    /// Comma-separated languages defining the seed project's stack (e.g.
463    /// "Rust,Python"). Stargazers using all of them are flagged as a
464    /// keyword-invisible "stack match" to drill into.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub stack: Option<String>,
467    /// Cap the number of stargazers screened (most-recent first).
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub max_stargazers: Option<usize>,
470    /// Drill into the cached screen instead of returning the overview:
471    /// "cohort:<key>", "user:<login>", "user:<login>/repo:<name>", or
472    /// ".../readme". Requires a prior no-element_id call for the repo.
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub element_id: Option<String>,
475    /// Re-fetch from GitHub instead of reusing the cached screen.
476    #[serde(default)]
477    pub refresh: bool,
478}
479
480/// MCP server backed by the rmcp framework.
481///
482/// The struct is cloned per request by rmcp's handler dispatch; the
483/// expensive bits (provider closure) are behind an Arc so cloning is cheap.
484#[derive(Clone)]
485pub struct McpServer {
486    options: ServerOptions,
487    tool_router: ToolRouter<McpServer>,
488    /// Skill-backed prompt routes. Empty until [`serve_prompts`] is
489    /// called with a resolved skill registry; remains empty for the
490    /// existing zero-skills boot path so `prompts/list` returns the
491    /// rmcp default (empty result, no capability advertised).
492    prompt_router: PromptRouter<McpServer>,
493}
494
495#[tool_router]
496impl McpServer {
497    pub fn new(options: ServerOptions) -> Self {
498        let mut server = Self {
499            options,
500            tool_router: Self::tool_router(),
501            prompt_router: PromptRouter::new(),
502        };
503        server.register_github_tools_if_authorized();
504        server.register_local_workspace_tools();
505        server.gate_workspace_tools();
506        server
507    }
508
509    /// Drop `repo_management` from the router when no workspace is
510    /// bound — `tools/list` should reflect the actual surface, not a
511    /// tool whose handler immediately errors out with "requires
512    /// --workspace mode." Mirrors the gating downstream binaries
513    /// (e.g. `kglite-mcp-server`) apply to the same tool. Operators
514    /// comparing the bare framework against a downstream binary's
515    /// surface see consistent behaviour now.
516    fn gate_workspace_tools(&mut self) {
517        if self.options.workspace.is_none() {
518            self.tool_router.remove_route("repo_management");
519        }
520    }
521
522    /// Register `set_root_dir` when the bound workspace is local-flavoured.
523    /// Github workspaces use `repo_management(name='org/repo')` to swap
524    /// roots; local workspaces need this alternative entry point.
525    fn register_local_workspace_tools(&mut self) {
526        let Some(ws) = self.options.workspace.clone() else {
527            return;
528        };
529        if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
530            return;
531        }
532        self.register_typed_tool::<SetRootDirArgs, _>(
533            "set_root_dir",
534            "Swap the active source root (local-workspace mode only). Pass `path` \
535             to a directory; the framework canonicalises it, rebinds the source \
536             tools (`read_source`, `grep`, `list_source`), and fires the post-\
537             activate hook so any downstream graph rebuilds against the new root. \
538             Pass `revs` (an integer N, or a list of git revspecs) to load multiple \
539             revisions of the root into one graph — N loads the newest N stable \
540             release tags of the dominant tag family plus HEAD (prereleases and \
541             unrelated tag families skipped); requires the root to be a git repo. \
542             Inventory persists across swaps; SHA-gating skips rebuilds when \
543             the same root is re-bound with no content changes.",
544            move |args: SetRootDirArgs| {
545                let p = std::path::PathBuf::from(&args.path);
546                ws.set_root_dir(&p, args.revs.as_ref())
547            },
548        );
549    }
550
551    /// Register `github_issues` + `github_api` as dynamic tools — but
552    /// only when a GitHub token is reachable. This is honest tool
553    /// listing: agents see the tool only if it can actually succeed.
554    /// Decision is boot-time; restart the server to pick up a token
555    /// that appears later.
556    fn register_github_tools_if_authorized(&mut self) {
557        if !crate::github::has_git_token() {
558            tracing::info!(
559                "GITHUB_TOKEN not set — github_issues / github_api tools hidden from the agent. \
560                 Set the env var and restart to enable them."
561            );
562            return;
563        }
564        let default_repo = self.options.default_repo.clone();
565        let repo_provider = default_repo.clone();
566        // Per-server ElementCache: stores collapsed elements (cb_1,
567        // patch_2, comment_3, overflow) emitted by FETCH so the agent
568        // can drill down via `element_id` on subsequent calls without
569        // re-fetching the whole issue. Mutex contention is negligible
570        // for MCP's serial request dispatch.
571        let cache: Arc<Mutex<crate::cache::ElementCache>> =
572            Arc::new(Mutex::new(crate::cache::ElementCache::new()));
573        let cache_for_issues = cache.clone();
574        self.register_typed_tool::<GithubIssuesArgs, _>(
575            "github_issues",
576            "Search, list, or fetch GitHub issues / pull requests / Discussions. \
577             Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
578             for SEARCH (across issues+PRs and Discussions); neither for LIST. \
579             `kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
580             `state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
581             result count (default 20). `labels` is a comma-separated string. \
582             `repo_name=\"org/repo\"` overrides the active repo for one call. \
583             FETCH responses collapse big code blocks / patches / comments into \
584             `cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
585             `element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
586             element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
587             `refresh=true` bypasses the cache for re-fetch.",
588            move |args: GithubIssuesArgs| {
589                let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
590                    Ok(r) => r,
591                    Err(msg) => return msg,
592                };
593                // FETCH / drill-down: route through ElementCache so cb_*,
594                // patch_*, overflow stays addressable. Cache.fetch_issue
595                // does both the network fetch and the drill-down branch.
596                // All paths return a status `String` — invalid-repo,
597                // fetch-failure, cached-summary, overflow, full-text.
598                if let Some(number) = args.number {
599                    let context = args.context.unwrap_or(3);
600                    let mut guard = cache_for_issues.lock().unwrap();
601                    return guard.fetch_issue(
602                        &repo,
603                        number,
604                        args.element_id.as_deref(),
605                        args.lines.as_deref(),
606                        args.grep.as_deref(),
607                        context,
608                        args.refresh,
609                    );
610                }
611                if args.element_id.is_some() {
612                    return "element_id requires `number=N` (the issue/PR being drilled into)."
613                        .to_string();
614                }
615                // SEARCH / LIST: no caching, pure delegation.
616                crate::github::github_issues_rust(
617                    Some(&repo),
618                    args.number,
619                    args.query.as_deref(),
620                    &args.kind,
621                    &args.state,
622                    args.sort.as_deref(),
623                    args.limit,
624                    args.labels.as_deref(),
625                )
626            },
627        );
628        let repo_provider = default_repo.clone();
629        let repo_for_screen = default_repo;
630        self.register_typed_tool::<GithubApiArgs, _>(
631            "github_api",
632            "Read-only GET against the GitHub REST API. `path` may be a \
633             repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
634             \"branches\", \"compare/main...feature\") which is auto-prefixed \
635             with /repos/<repo_name>/, or a top-level resource (\"search/issues?q=...\", \
636             \"users/octocat\", \"repos/owner/name\") which passes through. A \
637             leading slash is optional and accepted on either form. Returns \
638             JSON, truncated at 80 KB by default.",
639            move |args: GithubApiArgs| match resolve_repo_from(
640                repo_provider.as_ref(),
641                args.repo_name.clone(),
642            ) {
643                Ok(repo) => {
644                    let truncate_at = args.truncate_at.unwrap_or(80_000);
645                    crate::github::git_api_internal(&repo, &args.path, truncate_at)
646                }
647                Err(msg) => msg,
648            },
649        );
650
651        // screen_stargazers — bulk-screen a repo's stargazers over cheap
652        // REST into a per-server store, return a compact cohort+relevance
653        // overview, and let the agent drill via `element_id` (cache hits;
654        // only `.../readme` costs a request). The store is the stargazer
655        // analogue of `github_issues`' ElementCache. Operators can drop it
656        // (keeping the other GitHub tools) via `builtins.screen_stargazers:
657        // false`; default on.
658        if self.options.builtins.screen_stargazers {
659            let screen_store: Arc<Mutex<crate::screen::ScreenStore>> =
660                Arc::new(Mutex::new(crate::screen::ScreenStore::new()));
661            self.register_typed_tool::<ScreenStargazersArgs, _>(
662                "screen_stargazers",
663                "Screen the people around a GitHub project to find relevant developers, \
664             notable/legendary devs, architectural peers, and actual users — cheaply. \
665             Seed on a repo (`repo=\"owner/repo\"` → screens its stargazers) OR an \
666             explicit user list (`users=\"alice,bob\"` → screens them directly). With \
667             just a repo it auto-derives relevance keywords + tech stack from the repo \
668             itself, bulk-fetches each person's public repo portfolio over plain REST \
669             (~1 request per person, no GraphQL, no READMEs), classifies them, and \
670             enriches a bounded shortlist with follower counts, dependency-adoption, \
671             stack co-location, and contributions. Every person gets a normalized \
672             0–100 score vector on four axes — relatedness, popularity, effort, \
673             recency. RANK/FILTER: pass a `preset` (\"outreach\"=relevant+active by \
674             reach, \"peers\"=your stack by effort, \"legends\"=biggest reach any \
675             domain, \"intel\"=on-domain by popularity, \"adopters\"=actual users), or \
676             `rank_by`=relatedness|popularity|effort|recency with filters \
677             (`min_keywords`, `active_since`, `adopters_only`, `stack_only`) and \
678             `top`=N (rank-then-take-N, default 10) for a focused filter→rank→take \
679             view; with none, the full multi-lens browse: \
680             `✅ ADOPTERS` (stargazers whose repos actually declare your package as a \
681             dependency — real users, not just watchers), `★ MOST RELEVANT` \
682             (relatedness — repos matching your topic keywords, with follower counts \
683             and external contributions), `🏆 NOTABLE` (popularity/reach lens — your \
684             highest-traction stargazers, flagged `LEGEND` for big audiences/projects), \
685             `✦ QUALITY` (best-kept maintained projects), `⚙ STACK MATCH` (architectural \
686             peers who build in your stack — co-location-confirmed where possible), and \
687             a cohort inventory. Override the auto-config with `keywords=\"graph,rag,agent\"` \
688             (single words — \"knowledge,graph\" not \"knowledge-graph\") and \
689             `stack=\"Rust,Python\"`; re-calling with new values re-ranks the cached \
690             fetch for free. Treat description-based leads as candidates to verify by \
691             drilling. DRILL via `element_id`: `\"cohort:<key>\"` (established / single / \
692             prolific / casual / dormant / consumers — the overview lists each key), \
693             `\"user:<login>\"` (portfolio), `\"user:<login>/repo:<name>\"` (repo profile), \
694             or `\"user:<login>/repo:<name>/readme\"` (README gist — the only drill that \
695             costs a request). `max_stargazers` samples the most-recent N (the overview \
696             reports if results are partial); `refresh=true` re-fetches.",
697                move |args: ScreenStargazersArgs| {
698                    use crate::screen::{self, Filters, RankBy, Seed, Selection};
699                    let split_csv = |s: Option<String>| -> Vec<String> {
700                        s.map(|v| {
701                            v.split(',')
702                                .map(|t| t.trim().to_string())
703                                .filter(|t| !t.is_empty())
704                                .collect()
705                        })
706                        .unwrap_or_default()
707                    };
708                    // Seed: explicit user list wins; else the repo (or active repo).
709                    let seed = if let Some(u) = &args.users {
710                        Seed::Users(split_csv(Some(u.clone())))
711                    } else {
712                        let repo =
713                            match resolve_repo_from(repo_for_screen.as_ref(), args.repo.clone()) {
714                                Ok(r) => r,
715                                Err(msg) => return msg,
716                            };
717                        if let Some(err) = crate::git_refs::validate_repo(&repo) {
718                            return err;
719                        }
720                        Seed::Repo(repo)
721                    };
722                    let cfg = screen::ScreenConfig {
723                        max_stargazers: args.max_stargazers,
724                        max_repos_per_user: 100,
725                        relevance_keywords: split_csv(args.keywords)
726                            .into_iter()
727                            .map(|k| k.to_lowercase())
728                            .collect(),
729                        stack_languages: split_csv(args.stack),
730                    };
731                    // Selection: preset, else explicit rank/filters, else none.
732                    let top = args.top.unwrap_or(10);
733                    let filters = Filters {
734                        min_keywords: args.min_keywords,
735                        active_since: args.active_since.clone(),
736                        adopters_only: args.adopters_only,
737                        stack_only: args.stack_only,
738                        ..Default::default()
739                    };
740                    let filters_active = filters.min_keywords.is_some()
741                        || filters.active_since.is_some()
742                        || filters.adopters_only
743                        || filters.stack_only;
744                    let selection: Option<Selection> = if let Some(name) = &args.preset {
745                        screen::preset(name, top)
746                    } else if args.rank_by.is_some() || filters_active {
747                        Some(Selection {
748                            filters,
749                            rank: args
750                                .rank_by
751                                .as_deref()
752                                .and_then(RankBy::parse)
753                                .unwrap_or(RankBy::Relatedness),
754                            label: "SELECTION".into(),
755                            take: top,
756                        })
757                    } else {
758                        None
759                    };
760                    screen::screen_dispatch(
761                        &screen_store,
762                        &seed,
763                        &cfg,
764                        selection.as_ref(),
765                        args.element_id.as_deref(),
766                        args.refresh,
767                    )
768                },
769            );
770        }
771    }
772
773    /// Read the manifest-declared `builtins:` config. Downstream
774    /// consumers (e.g. a `graph_overview` tool that wipes a `temp/`
775    /// directory when `temp_cleanup: on_overview` is set) call this
776    /// to discover what flags the operator asked for. The framework
777    /// itself does not act on this — that would force it to interpret
778    /// graph-specific semantics it shouldn't know about.
779    pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
780        &self.options.builtins
781    }
782
783    /// Mutable access to the tool router for dynamic tool registration.
784    ///
785    /// Use only at server-construction time (before [`serve`](rmcp::ServiceExt::serve)).
786    /// Once dispatching starts, the router is cloned per request and
787    /// mutation would race.
788    pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
789        &mut self.tool_router
790    }
791
792    /// Mutable access to the prompt router for dynamic skill / prompt
793    /// registration. Same lifecycle contract as [`tool_router_mut`]:
794    /// boot-time only. Most operators reach prompts via
795    /// [`serve_prompts`] rather than touching the router directly.
796    pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
797        &mut self.prompt_router
798    }
799
800    /// Register a typed dynamic tool. Compresses the boilerplate of:
801    /// 1. Generating a JSON Schema for the args type via `schemars`.
802    /// 2. Building a [`rmcp::model::Tool`] attr from the schema +
803    ///    name + description.
804    /// 3. Deserialising the per-call JSON arguments via serde.
805    /// 4. Wrapping the handler in a [`rmcp::handler::server::router::tool::ToolRoute::new_dyn`]
806    ///    closure suitable for [`tool_router_mut`](Self::tool_router_mut).
807    ///
808    /// The handler is `Fn(T) -> String`; it owns whatever state it
809    /// needs through the closure environment (typically an Arc-clone
810    /// of a domain-specific state handle). Returning a string means
811    /// the tool reports a clean text body to the agent rather than
812    /// exposing a tool-error envelope — matches the framework's
813    /// "errors as values" convention for source / GitHub tools.
814    pub fn register_typed_tool<T, F>(
815        &mut self,
816        name: &'static str,
817        description: &'static str,
818        handler: F,
819    ) where
820        T: for<'de> serde::Deserialize<'de>
821            + schemars::JsonSchema
822            + Default
823            + Send
824            + Sync
825            + 'static,
826        F: Fn(T) -> String + Send + Sync + 'static,
827    {
828        use std::pin::Pin;
829        type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
830
831        let schema_obj = serde_json::to_value(schemars::schema_for!(T))
832            .ok()
833            .and_then(|v| v.as_object().cloned())
834            .unwrap_or_default();
835        let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
836        let handler = std::sync::Arc::new(handler);
837        // Capture the result-postprocess plumbing: the dyn closure has
838        // no `&self`, so the hook and the state needed to build a
839        // `ResultCtx` are cloned in here (Arc-cheap). `tool_name` is a
840        // `&'static str`, Copy into the closure.
841        let tool_name = name;
842        let postprocess = self.options.result_postprocess.clone();
843        let source_roots = self.options.source_roots.clone();
844        let workspace = self.options.workspace.clone();
845
846        self.tool_router
847            .add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
848                attr,
849                move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
850                    -> DynFut<'_, Result<rmcp::model::CallToolResponse, rmcp::ErrorData>> {
851                    let handler = handler.clone();
852                    let arguments = ctx.arguments.clone();
853                    let postprocess = postprocess.clone();
854                    let source_roots = source_roots.clone();
855                    let workspace = workspace.clone();
856                    Box::pin(async move {
857                        // Preserve the raw args as JSON for the hook,
858                        // before consuming them into the typed `T`.
859                        let args_json = match &arguments {
860                            Some(map) => serde_json::Value::Object(map.clone()),
861                            None => serde_json::Value::Null,
862                        };
863                        let args: T = match arguments {
864                            Some(map) => {
865                                match serde_json::from_value(serde_json::Value::Object(map)) {
866                                    Ok(a) => a,
867                                    Err(e) => {
868                                        return Ok(rmcp::model::CallToolResult::success(vec![
869                                            rmcp::model::ContentBlock::text(format!(
870                                                "invalid arguments: {e}"
871                                            )),
872                                        ])
873                                        .into());
874                                    }
875                                }
876                            }
877                            None => T::default(),
878                        };
879                        let body = handler(args);
880                        let body = match &postprocess {
881                            Some(hook) => {
882                                let ctx = ResultCtx {
883                                    source_roots: source_roots
884                                        .as_ref()
885                                        .map(|p| p())
886                                        .unwrap_or_default(),
887                                    active_repo: workspace
888                                        .as_ref()
889                                        .and_then(|w| w.active_repo_name()),
890                                };
891                                let footer = hook(tool_name, &args_json, &body, &ctx);
892                                append_footer(body, footer)
893                            }
894                            None => body,
895                        };
896                        Ok(rmcp::model::CallToolResult::success(vec![
897                            rmcp::model::ContentBlock::text(body),
898                        ])
899                        .into())
900                    })
901                },
902            ));
903    }
904
905    fn current_source_roots(&self) -> Vec<String> {
906        match &self.options.source_roots {
907            Some(provider) => provider(),
908            None => Vec::new(),
909        }
910    }
911
912    /// Run the consumer's result-postprocess hook (if any) against a
913    /// builtin tool's text `body`, appending any returned footer. The
914    /// single application point for the static `#[tool]` methods; the
915    /// dynamic `register_typed_tool` path applies the same contract at
916    /// its own choke point via captured clones (the closure has no
917    /// `&self`).
918    fn finish(&self, tool: &str, args: &serde_json::Value, body: String) -> String {
919        let Some(hook) = &self.options.result_postprocess else {
920            return body;
921        };
922        let ctx = ResultCtx {
923            source_roots: self.current_source_roots(),
924            active_repo: self
925                .options
926                .workspace
927                .as_ref()
928                .and_then(|w| w.active_repo_name()),
929        };
930        let footer = hook(tool, args, &body, &ctx);
931        append_footer(body, footer)
932    }
933
934    /// Resolve the active repo: per-call override → configured default →
935    /// auto-detect from cwd (last-resort fallback). Returns the resolved
936    /// repo string and an `Err` (formatted user message) if none is found
937    /// or the value is malformed.
938    #[allow(dead_code)]
939    fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
940        resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
941    }
942
943    #[tool(
944        description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
945                          Use to confirm the server framework is wired correctly before \
946                          relying on graph- or source-aware tools."
947    )]
948    async fn ping(
949        &self,
950        Parameters(args): Parameters<PingArgs>,
951    ) -> Result<CallToolResult, McpError> {
952        let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
953        let body = args.message.unwrap_or_else(|| "pong".to_string());
954        let body = self.finish("ping", &args_json, body);
955        Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
956    }
957
958    #[tool(description = "Read a file from the configured source root(s). Pass \
959                       `start_line`/`end_line` to slice, `grep` to filter to matching \
960                       lines, `max_chars` to cap output. Pass `rev` (a tag, branch, or \
961                       commit SHA) to read the file's content at that git revision via \
962                       `git show` instead of the working tree — useful for comparing a \
963                       file across releases (requires a git repo source root). Path \
964                       traversal attempts are rejected. Available only when source roots \
965                       are configured.")]
966    async fn read_source(
967        &self,
968        Parameters(args): Parameters<ReadSourceArgs>,
969    ) -> Result<CallToolResult, McpError> {
970        let roots = self.current_source_roots();
971        if roots.is_empty() {
972            return Ok(CallToolResult::success(vec![ContentBlock::text(
973                "Cannot read source: no active source root. Configure source_root in your manifest \
974                 or activate one (e.g. via repo_management in workspace mode).",
975            )]));
976        }
977        let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
978        let opts = ReadOpts {
979            start_line: args.start_line,
980            end_line: args.end_line,
981            grep: args.grep,
982            grep_context: args.grep_context,
983            max_matches: args.max_matches,
984            max_chars: args.max_chars,
985            rev: args.rev,
986        };
987        let body = source::read_source(&args.file_path, &roots, &opts);
988        let body = self.finish("read_source", &args_json, body);
989        Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
990    }
991
992    #[tool(
993        description = "Search source files using ripgrep. `pattern` is a regex (Rust \
994                       syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
995                       N surrounding lines per match. Set `case_insensitive=true` for \
996                       case-insensitive matching. `max_results` caps total matches \
997                       (default 50)."
998    )]
999    async fn grep(
1000        &self,
1001        Parameters(args): Parameters<GrepArgs>,
1002    ) -> Result<CallToolResult, McpError> {
1003        let roots = self.current_source_roots();
1004        if roots.is_empty() {
1005            return Ok(CallToolResult::success(vec![ContentBlock::text(
1006                "Cannot grep: no active source root. Configure source_root in your manifest \
1007                 or activate one (e.g. via repo_management in workspace mode).",
1008            )]));
1009        }
1010        let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1011        let opts = GrepOpts {
1012            glob: args.glob,
1013            context: args.context,
1014            max_results: Some(args.max_results.unwrap_or(50)),
1015            case_insensitive: args.case_insensitive,
1016        };
1017        let body = source::grep(&roots, &args.pattern, &opts);
1018        let body = self.finish("grep", &args_json, body);
1019        Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1020    }
1021
1022    #[tool(
1023        description = "List directory contents under the configured source root. `path` \
1024                       is resolved against the first source root (\".\" lists the root \
1025                       itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
1026                       `glob` filters entry names. `dirs_only=true` shows only \
1027                       directories."
1028    )]
1029    async fn list_source(
1030        &self,
1031        Parameters(args): Parameters<ListSourceArgs>,
1032    ) -> Result<CallToolResult, McpError> {
1033        let roots = self.current_source_roots();
1034        if roots.is_empty() {
1035            return Ok(CallToolResult::success(vec![ContentBlock::text(
1036                "Cannot list source: no active source root. Configure source_root in your \
1037                 manifest or activate one (e.g. via repo_management in workspace mode).",
1038            )]));
1039        }
1040        let primary = std::path::PathBuf::from(&roots[0]);
1041        let target = match resolve_dir_under_roots(&args.path, &roots) {
1042            Some(p) => p,
1043            None => {
1044                return Ok(CallToolResult::success(vec![ContentBlock::text(format!(
1045                    "Error: path '{}' resolves outside the configured source roots.",
1046                    args.path
1047                ))]));
1048            }
1049        };
1050        let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1051        let opts = ListOpts {
1052            depth: args.depth,
1053            glob: args.glob,
1054            dirs_only: args.dirs_only,
1055        };
1056        let body = source::list_source(&target, &primary, &opts);
1057        let body = self.finish("list_source", &args_json, body);
1058        Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1059    }
1060
1061    #[tool(
1062        description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
1063                       clone (if missing) and activate it as the source root for \
1064                       read_source / grep / list_source. Pass `delete=true` to remove a \
1065                       repo. Pass `update=true` to fetch upstream changes for the active \
1066                       repo (rebuild auto-skipped when HEAD hasn't moved since the last \
1067                       build; set `force_rebuild=true` to bypass). Pass `revs` (an \
1068                       integer N, or a list of git revspecs) to load multiple revisions \
1069                       of the repo into one graph — N loads the newest N stable release \
1070                       tags of the dominant tag family plus HEAD (prereleases and \
1071                       unrelated tag families skipped); a revs request always rebuilds. \
1072                       Call with no \
1073                       arguments to list all known repos with their last-access counts. \
1074                       Idle repos auto-sweep on each call (default 7 days, configurable \
1075                       via --stale-after-days)."
1076    )]
1077    async fn repo_management(
1078        &self,
1079        Parameters(args): Parameters<RepoManagementArgs>,
1080    ) -> Result<CallToolResult, McpError> {
1081        let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1082        let body = match &self.options.workspace {
1083            Some(ws) => ws.repo_management(
1084                args.name.as_deref(),
1085                args.delete,
1086                args.update,
1087                args.force_rebuild,
1088                args.revs.as_ref(),
1089            ),
1090            None => "repo_management requires --workspace mode.".to_string(),
1091        };
1092        let body = self.finish("repo_management", &args_json, body);
1093        Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1094    }
1095}
1096
1097/// Resolve `org/repo`: per-call override → configured default →
1098/// auto-detect from cwd. Returns either the resolved repo or a
1099/// formatted user-facing error message.
1100///
1101/// Free function (not a method) so it can be called from closures
1102/// captured by [`McpServer::register_typed_tool`] which only see
1103/// `Fn(T) -> String` — no `&self`.
1104fn resolve_repo_from(
1105    default_repo: Option<&RepoProvider>,
1106    override_repo: Option<String>,
1107) -> Result<String, String> {
1108    if let Some(r) = override_repo {
1109        if let Some(err) = crate::git_refs::validate_repo(&r) {
1110            return Err(err);
1111        }
1112        return Ok(r);
1113    }
1114    if let Some(provider) = default_repo {
1115        if let Some(r) = provider() {
1116            if let Some(err) = crate::git_refs::validate_repo(&r) {
1117                return Err(err);
1118            }
1119            return Ok(r);
1120        }
1121    }
1122    if let Some(detected) = crate::github::detect_git_repo(".") {
1123        if crate::git_refs::validate_repo(&detected).is_none() {
1124            return Ok(detected);
1125        }
1126    }
1127    Err(
1128        "No active repository. Pass `repo_name='org/repo'`, configure a default in the \
1129         server, or run from a directory whose git remote points at github.com."
1130            .to_string(),
1131    )
1132}
1133
1134/// Wire a resolved skill registry into a server's `prompts/list` and
1135/// `prompts/get` surface, and apply auto-injection hints to tool
1136/// descriptions for skills whose name matches a registered tool.
1137///
1138/// Call at boot time after all tools have been registered (so the
1139/// auto-inject pass sees the final tool catalogue) and before
1140/// `serve(...)`. Idempotent in spirit but not by construction:
1141/// calling twice with the same registry would re-append the hint to
1142/// already-injected descriptions, so don't.
1143///
1144/// The function is additive and a no-op when the registry is empty
1145/// — downstream callers can wire it unconditionally without breaking
1146/// the zero-skills boot path.
1147pub fn serve_prompts(registry: &ResolvedRegistry, server: &mut McpServer) {
1148    use std::borrow::Cow;
1149    use std::collections::HashSet;
1150
1151    // Build the framework-internal predicate state once. The tool
1152    // router has the full registered-tool list; extensions come from
1153    // the manifest's builtins block (operators may have nothing
1154    // here, in which case all `extension_enabled:` predicates fail).
1155    let registered_tools: HashSet<String> = server
1156        .tool_router
1157        .list_all()
1158        .iter()
1159        .map(|t| t.name.to_string())
1160        .collect();
1161    let extensions = server.options.extensions.clone();
1162
1163    // For the auto-inject pass: skills with `auto_inject_hint` get
1164    // their `description` (routing) and `body` (methodology) embedded
1165    // into the descriptions of their name-match tool AND every tool
1166    // they list in `references_tools`. See the comment at the bottom
1167    // of the function for why this is the content, not a pointer.
1168    struct InjectSkill {
1169        name: String,
1170        description: String,
1171        body: String,
1172        references_tools: Vec<String>,
1173    }
1174    let mut auto_inject: Vec<InjectSkill> = Vec::new();
1175
1176    for name in registry.skill_names() {
1177        let Some(skill) = registry.get(&name) else {
1178            continue;
1179        };
1180
1181        // Evaluate `applies_when:` against the runtime state. Skills
1182        // with all predicates satisfied register; others are
1183        // suppressed from the agent-facing surface.
1184        let activation = registry.activation_for(skill, &registered_tools, &extensions);
1185        if !activation.active {
1186            let failed_clauses: Vec<&str> = activation
1187                .clauses
1188                .iter()
1189                .filter(|(_, outcome)| {
1190                    *outcome != crate::server::skills::PredicateOutcome::Satisfied
1191                })
1192                .map(|(clause, _)| clause.as_str())
1193                .collect();
1194            tracing::info!(
1195                skill = %name,
1196                suppressed_by = ?failed_clauses,
1197                "skill suppressed by applies_when predicates"
1198            );
1199            continue;
1200        }
1201
1202        let prompt = Prompt::new(
1203            skill.name().to_string(),
1204            Some(skill.description().to_string()),
1205            None,
1206        );
1207        let body = skill.body.clone();
1208        let route = PromptRoute::new_dyn(prompt, move |_ctx| {
1209            let body = body.clone();
1210            Box::pin(async move {
1211                Ok(
1212                    GetPromptResult::new(vec![PromptMessage::new_text(Role::Assistant, body)])
1213                        .into(),
1214                )
1215            })
1216        });
1217        server.prompt_router.add_route(route);
1218
1219        if skill.frontmatter.auto_inject_hint {
1220            auto_inject.push(InjectSkill {
1221                name: skill.name().to_string(),
1222                description: skill.description().to_string(),
1223                body: skill.body.clone(),
1224                references_tools: skill.frontmatter.references_tools.clone(),
1225            });
1226        }
1227    }
1228
1229    // Auto-inject the skill's routing + methodology into tool
1230    // descriptions.
1231    //
1232    // Background: pre-0.3.37 this loop appended a short pointer line
1233    // (`See `prompts/get` <name> for the full methodology.`) to the
1234    // tool description, assuming agents could call `prompts/get` to
1235    // fetch the body. **They can't** in real MCP clients — Claude Code,
1236    // Claude Desktop, Cursor, and Continue all expose only `tools/*`
1237    // to the model; the `prompts/` plane was designed for human-
1238    // invoked slash commands. Operators authoring against the pointer
1239    // pattern shipped methodology the agent literally could not read.
1240    //
1241    // The fix, in two parts:
1242    //   * Embed the skill's `description` under a `## When to use`
1243    //     header and its `body` under `## Methodology`. The
1244    //     description carries the TRIGGER/SKIP routing — small by
1245    //     design, so it leads and isn't subject to the body's size
1246    //     caps (4 KB soft / 16 KB hard, enforced at load). An empty
1247    //     description omits the `## When to use` block.
1248    //   * Inject into the skill's name-match tool AND every tool it
1249    //     lists in `references_tools`. This is the only way to express
1250    //     a *cross-tool* skill — one not named after any single tool.
1251    //
1252    // A tool may now carry several skills (its own plus any that
1253    // reference it). Each injection is fenced by a per-skill marker
1254    // (`<!-- mcp-skill:<name> -->`) so the pass stays idempotent per
1255    // (skill, tool) pair: a tool that is both the name-match and a
1256    // `references_tools` entry of the same skill gets one injection,
1257    // and re-running the pass never double-appends.
1258    //
1259    // Operators who want the smaller pointer-only behaviour set
1260    // `auto_inject_hint: false` per skill. `prompts/list` /
1261    // `prompts/get` continue to work for any client that does surface
1262    // them to the agent, plus CLI introspection. This pass just makes
1263    // the *primary* delivery channel a place agents actually look.
1264    for inj in &auto_inject {
1265        // The skill's name-match tool plus every tool it references,
1266        // deduped so a self-reference doesn't queue the same tool twice.
1267        let mut targets: Vec<&str> = Vec::new();
1268        let mut seen: HashSet<&str> = HashSet::new();
1269        for tool in std::iter::once(inj.name.as_str())
1270            .chain(inj.references_tools.iter().map(String::as_str))
1271        {
1272            if seen.insert(tool) {
1273                targets.push(tool);
1274            }
1275        }
1276
1277        // Build the injected block once. Marker first (idempotency
1278        // fence), then the routing, then the methodology body.
1279        let marker = format!("<!-- mcp-skill:{} -->", inj.name);
1280        let mut block = format!("\n\n{marker}");
1281        let description = inj.description.trim();
1282        if !description.is_empty() {
1283            block.push_str("\n\n## When to use\n\n");
1284            block.push_str(description);
1285        }
1286        block.push_str("\n\n## Methodology\n\n");
1287        block.push_str(inj.body.trim());
1288
1289        for tool in targets {
1290            let key = Cow::<'static, str>::Owned(tool.to_string());
1291            let Some(route) = server.tool_router.map.get_mut(&key) else {
1292                continue;
1293            };
1294            // Per-skill idempotency: never inject the same skill twice
1295            // into one tool's description.
1296            if route
1297                .attr
1298                .description
1299                .as_deref()
1300                .is_some_and(|d| d.contains(&marker))
1301            {
1302                continue;
1303            }
1304            let new_desc = match route.attr.description.take() {
1305                Some(existing) => format!("{existing}{block}"),
1306                None => block.trim_start().to_string(),
1307            };
1308            route.attr.description = Some(Cow::Owned(new_desc));
1309        }
1310    }
1311}
1312
1313#[tool_handler(router = self.tool_router)]
1314impl ServerHandler for McpServer {
1315    fn get_info(&self) -> ServerInfo {
1316        let name = self
1317            .options
1318            .name
1319            .clone()
1320            .unwrap_or_else(|| "MCP Server".to_string());
1321        // Only advertise the prompts capability when at least one skill
1322        // is registered. The zero-skills boot path is the existing
1323        // contract and must keep producing capability output that's
1324        // byte-identical to today. ServerCapabilities is `#[non_exhaustive]`
1325        // but its fields are pub, so we mutate after `build()` rather
1326        // than fighting the type-state builder.
1327        let mut caps = ServerCapabilities::builder().enable_tools().build();
1328        if !self.prompt_router.map.is_empty() {
1329            caps.prompts = Some(PromptsCapability::default());
1330        }
1331        let mut info = ServerInfo::new(caps)
1332            .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
1333            .with_protocol_version(ProtocolVersion::V_2024_11_05);
1334        if let Some(text) = &self.options.instructions {
1335            info = info.with_instructions(text.clone());
1336        }
1337        info
1338    }
1339
1340    /// `notifications/initialized` — the one point at which a
1341    /// client-advertised root can be adopted (see [`crate::server::roots`]).
1342    ///
1343    /// rmcp dispatches every peer notification on a task it spawns
1344    /// (`spawn_service_task`, which is `tokio::spawn` unless rmcp's `local`
1345    /// feature is enabled), and the response router lives in the same select
1346    /// loop, so awaiting a server→client `roots/list` request here cannot
1347    /// deadlock and cannot delay the client's session. **If rmcp's `local`
1348    /// feature is ever enabled that becomes `spawn_local` and this reasoning
1349    /// must be re-checked.**
1350    ///
1351    /// Everything about adoption is opt-in and guarded inside the `roots`
1352    /// module: with no `workspace.adopt_client_roots` this returns after two
1353    /// field reads, having sent nothing.
1354    async fn on_initialized(&self, context: rmcp::service::NotificationContext<rmcp::RoleServer>) {
1355        // Same line rmcp's default handler emits — overriding the method
1356        // must not cost an operator the log they have today.
1357        tracing::info!("client initialized");
1358        crate::server::roots::on_client_initialized(&self.options, &context.peer).await;
1359    }
1360
1361    /// `notifications/roots/list_changed` — re-run adoption, unless the
1362    /// operator has claimed the root in the meantime.
1363    async fn on_roots_list_changed(
1364        &self,
1365        context: rmcp::service::NotificationContext<rmcp::RoleServer>,
1366    ) {
1367        crate::server::roots::on_client_roots_changed(&self.options, &context.peer).await;
1368    }
1369
1370    async fn list_prompts(
1371        &self,
1372        _request: Option<PaginatedRequestParams>,
1373        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
1374    ) -> Result<ListPromptsResult, McpError> {
1375        Ok(ListPromptsResult {
1376            prompts: self.prompt_router.list_all(),
1377            ..Default::default()
1378        })
1379    }
1380
1381    async fn get_prompt(
1382        &self,
1383        request: GetPromptRequestParams,
1384        context: rmcp::service::RequestContext<rmcp::RoleServer>,
1385    ) -> Result<GetPromptResponse, McpError> {
1386        let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
1387            self,
1388            request.name,
1389            request.arguments,
1390            context,
1391        );
1392        self.prompt_router.get_prompt(prompt_context).await
1393    }
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398    use super::*;
1399
1400    #[test]
1401    fn options_from_manifest_uses_name_when_set() {
1402        let opts = ServerOptions::from_manifest(None, "Fallback");
1403        assert_eq!(opts.name.as_deref(), Some("Fallback"));
1404    }
1405
1406    #[test]
1407    fn builtins_exposed_via_server() {
1408        use crate::server::manifest::{BuiltinsConfig, TempCleanup};
1409        let opts = ServerOptions {
1410            builtins: BuiltinsConfig {
1411                save_graph: true,
1412                temp_cleanup: TempCleanup::OnOverview,
1413                ..Default::default()
1414            },
1415            ..ServerOptions::default()
1416        };
1417        let server = McpServer::new(opts);
1418        assert!(server.builtins().save_graph);
1419        assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
1420    }
1421
1422    #[test]
1423    fn server_constructs() {
1424        let _server = McpServer::new(ServerOptions::default());
1425    }
1426
1427    #[test]
1428    fn static_source_roots_provider() {
1429        let opts = ServerOptions::default()
1430            .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
1431        let server = McpServer::new(opts);
1432        assert_eq!(
1433            server.current_source_roots(),
1434            vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
1435        );
1436    }
1437
1438    #[test]
1439    fn no_provider_returns_empty_roots() {
1440        let server = McpServer::new(ServerOptions::default());
1441        assert!(server.current_source_roots().is_empty());
1442    }
1443
1444    #[test]
1445    fn repo_management_gated_to_workspace_mode() {
1446        // Bare (no workspace): repo_management should NOT be in the
1447        // router. Mirrors the gating downstream binaries apply.
1448        let server = McpServer::new(ServerOptions::default());
1449        let tools = server.tool_router.list_all();
1450        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1451        assert!(
1452            !names.contains(&"repo_management"),
1453            "repo_management should be gated out without a workspace; tools were {names:?}"
1454        );
1455    }
1456
1457    #[test]
1458    fn repo_management_present_when_workspace_bound() {
1459        // With a workspace handle bound, repo_management should be
1460        // registered.
1461        use crate::server::workspace::Workspace;
1462        let dir = tempfile::tempdir().unwrap();
1463        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1464        let opts = ServerOptions::default().with_workspace(ws);
1465        let server = McpServer::new(opts);
1466        let tools = server.tool_router.list_all();
1467        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1468        assert!(
1469            names.contains(&"repo_management"),
1470            "repo_management should be registered with a workspace; tools were {names:?}"
1471        );
1472    }
1473
1474    #[test]
1475    fn result_postprocess_appends_footer_and_sees_ctx() {
1476        use std::sync::Mutex;
1477        // Capture what the hook receives so we can assert the ctx.
1478        type Seen = Option<(String, serde_json::Value, String, Vec<String>)>;
1479        let seen: Arc<Mutex<Seen>> = Arc::new(Mutex::new(None));
1480        let seen_c = seen.clone();
1481        let hook: ResultPostprocessHook = Arc::new(move |tool, args, body, ctx| {
1482            *seen_c.lock().unwrap() = Some((
1483                tool.to_string(),
1484                args.clone(),
1485                body.to_string(),
1486                ctx.source_roots.clone(),
1487            ));
1488            // Only steer on grep — proves per-tool selectivity.
1489            if tool == "grep" {
1490                Some("↳ prefer cypher_query".to_string())
1491            } else {
1492                None
1493            }
1494        });
1495        let opts = ServerOptions::default()
1496            .with_static_source_roots(vec!["/src".to_string()])
1497            .with_result_postprocess(hook);
1498        let server = McpServer::new(opts);
1499
1500        let args = serde_json::json!({ "pattern": "^fn " });
1501        let out = server.finish("grep", &args, "match line".to_string());
1502        assert_eq!(out, "match line\n\n↳ prefer cypher_query");
1503
1504        let rec = seen.lock().unwrap().clone().unwrap();
1505        assert_eq!(rec.0, "grep");
1506        assert_eq!(rec.1, args);
1507        assert_eq!(rec.2, "match line");
1508        assert_eq!(rec.3, vec!["/src".to_string()]);
1509
1510        // A tool the hook ignores → body byte-for-byte unchanged.
1511        let out2 = server.finish("read_source", &args, "file body".to_string());
1512        assert_eq!(out2, "file body");
1513    }
1514
1515    #[test]
1516    fn no_result_postprocess_leaves_body_unchanged() {
1517        let server = McpServer::new(ServerOptions::default());
1518        let out = server.finish("grep", &serde_json::Value::Null, "x".to_string());
1519        assert_eq!(out, "x");
1520    }
1521
1522    #[test]
1523    fn append_footer_ignores_empty_footers() {
1524        assert_eq!(append_footer("a".to_string(), None), "a");
1525        assert_eq!(append_footer("a".to_string(), Some(String::new())), "a");
1526        assert_eq!(
1527            append_footer("a".to_string(), Some("b".to_string())),
1528            "a\n\nb"
1529        );
1530    }
1531
1532    #[test]
1533    fn dynamic_provider_swaps_at_call_time() {
1534        use std::sync::Mutex;
1535        let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
1536        let s2 = state.clone();
1537        let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
1538        let opts = ServerOptions::default().with_dynamic_source_roots(provider);
1539        let server = McpServer::new(opts);
1540        assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
1541        *state.lock().unwrap() = vec!["/swapped".to_string()];
1542        assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
1543    }
1544
1545    // ─── Prompt / skill wiring ────────────────────────────────────
1546
1547    fn build_test_registry(
1548        skills: &[(&str, &str, &str, bool)],
1549    ) -> crate::server::skills::ResolvedRegistry {
1550        use crate::server::skills::Registry;
1551        let dir = tempfile::tempdir().unwrap();
1552        let yaml_path = dir.path().join("manifest.yaml");
1553        let skills_dir = dir.path().join("manifest.skills");
1554        std::fs::create_dir_all(&skills_dir).unwrap();
1555        for (name, description, body, auto_inject) in skills {
1556            let auto = if *auto_inject { "true" } else { "false" };
1557            let content = format!(
1558                "---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
1559            );
1560            std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1561        }
1562        Registry::new()
1563            .auto_detect_project_layer(&yaml_path)
1564            .finalise()
1565            .unwrap()
1566    }
1567
1568    /// Like [`build_test_registry`] but lets each skill declare a
1569    /// `references_tools` list (a YAML inline array, e.g. `[ping]`) so
1570    /// the cross-tool injection path can be exercised. Every skill is
1571    /// `auto_inject_hint: true`.
1572    fn build_registry_with_refs(
1573        skills: &[(&str, &str, &str, &str)],
1574    ) -> crate::server::skills::ResolvedRegistry {
1575        use crate::server::skills::Registry;
1576        let dir = tempfile::tempdir().unwrap();
1577        let yaml_path = dir.path().join("manifest.yaml");
1578        let skills_dir = dir.path().join("manifest.skills");
1579        std::fs::create_dir_all(&skills_dir).unwrap();
1580        for (name, description, body, references_tools) in skills {
1581            let content = format!(
1582                "---\nname: {name}\ndescription: {description}\n\
1583                 auto_inject_hint: true\nreferences_tools: {references_tools}\n---\n\n{body}\n"
1584            );
1585            std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1586        }
1587        Registry::new()
1588            .auto_detect_project_layer(&yaml_path)
1589            .finalise()
1590            .unwrap()
1591    }
1592
1593    fn tool_desc(server: &McpServer, tool: &str) -> String {
1594        server
1595            .tool_router
1596            .get(tool)
1597            .and_then(|t| t.description.clone())
1598            .map(|c| c.into_owned())
1599            .unwrap_or_default()
1600    }
1601
1602    #[test]
1603    fn prompt_router_empty_by_default() {
1604        let server = McpServer::new(ServerOptions::default());
1605        assert!(server.prompt_router.map.is_empty());
1606    }
1607
1608    #[test]
1609    fn get_info_no_prompts_capability_when_empty() {
1610        // Zero-impact invariant: a server with no skills must not
1611        // advertise the prompts capability. kglite's existing
1612        // deployment depends on this byte-for-byte.
1613        let server = McpServer::new(ServerOptions::default());
1614        let info = server.get_info();
1615        assert!(
1616            info.capabilities.prompts.is_none(),
1617            "prompts capability must be absent when no skills are registered"
1618        );
1619    }
1620
1621    #[test]
1622    fn serve_prompts_registers_routes_with_metadata() {
1623        let registry = build_test_registry(&[
1624            ("alpha", "First skill.", "Alpha body.", true),
1625            ("beta", "Second skill.", "Beta body.", true),
1626        ]);
1627        let mut server = McpServer::new(ServerOptions::default());
1628        super::serve_prompts(&registry, &mut server);
1629
1630        let prompts = server.prompt_router.list_all();
1631        let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
1632        assert_eq!(names, vec!["alpha", "beta"]);
1633
1634        let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
1635        assert_eq!(alpha.description.as_deref(), Some("First skill."));
1636        assert!(alpha.arguments.is_none());
1637    }
1638
1639    #[test]
1640    fn serve_prompts_empty_registry_is_noop() {
1641        let registry = crate::server::skills::ResolvedRegistry::default();
1642        let mut server = McpServer::new(ServerOptions::default());
1643        super::serve_prompts(&registry, &mut server);
1644        assert!(server.prompt_router.map.is_empty());
1645        assert!(server.get_info().capabilities.prompts.is_none());
1646    }
1647
1648    #[test]
1649    fn get_info_advertises_prompts_when_present() {
1650        let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
1651        let mut server = McpServer::new(ServerOptions::default());
1652        super::serve_prompts(&registry, &mut server);
1653        let info = server.get_info();
1654        assert!(
1655            info.capabilities.prompts.is_some(),
1656            "prompts capability must be advertised once a skill is registered"
1657        );
1658    }
1659
1660    #[test]
1661    fn serve_prompts_auto_injects_full_body_into_matching_tool() {
1662        // `ping` is registered by every server. A skill named `ping`
1663        // with `auto_inject_hint: true` should embed its full body
1664        // under a `## Methodology` header in the ping tool's
1665        // description. Pre-0.3.37 this appended a short pointer at
1666        // `prompts/get`, but agents in real MCP clients can't reach
1667        // that surface — see the comment on the auto-inject loop in
1668        // `serve_prompts`.
1669        let registry =
1670            build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
1671        let mut server = McpServer::new(ServerOptions::default());
1672        let before = server
1673            .tool_router
1674            .get("ping")
1675            .and_then(|t| t.description.clone())
1676            .map(|c| c.into_owned())
1677            .unwrap_or_default();
1678        super::serve_prompts(&registry, &mut server);
1679        let after = server
1680            .tool_router
1681            .get("ping")
1682            .and_then(|t| t.description.clone())
1683            .map(|c| c.into_owned())
1684            .unwrap_or_default();
1685        assert!(after.starts_with(&before), "original description preserved");
1686        assert!(
1687            after.contains("## Methodology"),
1688            "inject should include a Methodology header; got: {after}"
1689        );
1690        assert!(
1691            after.contains("PING-BODY-SENTINEL"),
1692            "inject should embed the full skill body; got: {after}"
1693        );
1694        assert!(
1695            !after.contains("prompts/get"),
1696            "post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
1697        );
1698    }
1699
1700    #[test]
1701    fn serve_prompts_skips_injection_when_disabled() {
1702        let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
1703        let mut server = McpServer::new(ServerOptions::default());
1704        let before = server
1705            .tool_router
1706            .get("ping")
1707            .and_then(|t| t.description.clone())
1708            .map(|c| c.into_owned())
1709            .unwrap_or_default();
1710        super::serve_prompts(&registry, &mut server);
1711        let after = server
1712            .tool_router
1713            .get("ping")
1714            .and_then(|t| t.description.clone())
1715            .map(|c| c.into_owned())
1716            .unwrap_or_default();
1717        assert_eq!(
1718            before, after,
1719            "auto_inject_hint=false must leave tool description untouched"
1720        );
1721    }
1722
1723    #[test]
1724    fn serve_prompts_skips_injection_when_no_matching_tool() {
1725        // Skill name doesn't match any registered tool; nothing to
1726        // inject into, but the prompt route is still added.
1727        let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
1728        let mut server = McpServer::new(ServerOptions::default());
1729        super::serve_prompts(&registry, &mut server);
1730        assert!(server.prompt_router.map.contains_key("no_such_tool"));
1731        // No panic, no mutation of unrelated tools — the ping tool's
1732        // description is unchanged.
1733        let ping_desc = server
1734            .tool_router
1735            .get("ping")
1736            .and_then(|t| t.description.clone())
1737            .map(|c| c.into_owned())
1738            .unwrap_or_default();
1739        assert!(!ping_desc.contains("no_such_tool"));
1740    }
1741
1742    #[test]
1743    fn serve_prompts_injects_description_under_when_to_use() {
1744        // The skill's `description` carries the TRIGGER/SKIP routing —
1745        // it must reach the live tool-description channel under a
1746        // `## When to use` header, ahead of the methodology body.
1747        let registry = build_test_registry(&[("ping", "ROUTING-SENTINEL", "BODY-SENTINEL", true)]);
1748        let mut server = McpServer::new(ServerOptions::default());
1749        super::serve_prompts(&registry, &mut server);
1750        let desc = tool_desc(&server, "ping");
1751        assert!(
1752            desc.contains("## When to use\n\nROUTING-SENTINEL"),
1753            "description should be injected under `## When to use`; got: {desc}"
1754        );
1755        assert!(
1756            desc.contains("<!-- mcp-skill:ping -->"),
1757            "injection should carry the per-skill idempotency marker; got: {desc}"
1758        );
1759        // Routing leads, methodology follows.
1760        let when = desc.find("## When to use").unwrap();
1761        let method = desc.find("## Methodology").unwrap();
1762        assert!(when < method, "`When to use` must precede `Methodology`");
1763    }
1764
1765    #[test]
1766    fn serve_prompts_honors_references_tools() {
1767        // A cross-tool skill named after no tool injects into every
1768        // tool it lists in `references_tools`. `ping` is always
1769        // registered; the skill name (`graph_strategy`) is not a tool.
1770        let registry = build_registry_with_refs(&[(
1771            "graph_strategy",
1772            "Map structure first.",
1773            "GRAPH-BODY-SENTINEL",
1774            "[ping]",
1775        )]);
1776        let mut server = McpServer::new(ServerOptions::default());
1777        super::serve_prompts(&registry, &mut server);
1778        // The prompt route still registers under the skill name.
1779        assert!(server.prompt_router.map.contains_key("graph_strategy"));
1780        // ...and the referenced tool carries the full injection.
1781        let desc = tool_desc(&server, "ping");
1782        assert!(
1783            desc.contains("<!-- mcp-skill:graph_strategy -->"),
1784            "referenced tool should carry the skill marker; got: {desc}"
1785        );
1786        assert!(
1787            desc.contains("Map structure first."),
1788            "referenced tool should carry the skill routing; got: {desc}"
1789        );
1790        assert!(
1791            desc.contains("GRAPH-BODY-SENTINEL"),
1792            "referenced tool should carry the skill body; got: {desc}"
1793        );
1794    }
1795
1796    #[test]
1797    fn serve_prompts_idempotent_when_skill_self_references() {
1798        // A skill named after its own tool that also lists that tool in
1799        // `references_tools` must inject exactly once — the dedup of
1800        // the target set plus the per-skill marker keep the pass clean.
1801        let registry = build_registry_with_refs(&[("ping", "Routing.", "Body.", "[ping]")]);
1802        let mut server = McpServer::new(ServerOptions::default());
1803        super::serve_prompts(&registry, &mut server);
1804        let desc = tool_desc(&server, "ping");
1805        let marker_count = desc.matches("<!-- mcp-skill:ping -->").count();
1806        assert_eq!(
1807            marker_count, 1,
1808            "self-referencing skill must inject exactly once; got {marker_count}: {desc}"
1809        );
1810    }
1811
1812    #[test]
1813    fn serve_prompts_idempotent_across_repeated_passes() {
1814        // Re-running the pass over the same server must not double-
1815        // append: the per-skill marker fences each (skill, tool) pair.
1816        let registry = build_test_registry(&[("ping", "Routing.", "Body.", true)]);
1817        let mut server = McpServer::new(ServerOptions::default());
1818        super::serve_prompts(&registry, &mut server);
1819        let once = tool_desc(&server, "ping");
1820        super::serve_prompts(&registry, &mut server);
1821        let twice = tool_desc(&server, "ping");
1822        assert_eq!(
1823            once, twice,
1824            "second pass must be a no-op for an already-injected tool"
1825        );
1826    }
1827
1828    #[test]
1829    fn serve_prompts_multiple_skills_stack_on_one_tool() {
1830        // A tool can carry its own name-match skill plus a referencing
1831        // cross-tool skill — both injections coexist, each fenced by
1832        // its own marker.
1833        let registry = build_registry_with_refs(&[
1834            ("ping", "Ping routing.", "PING-BODY", "[]"),
1835            ("ping_strategy", "Strategy routing.", "STRAT-BODY", "[ping]"),
1836        ]);
1837        let mut server = McpServer::new(ServerOptions::default());
1838        super::serve_prompts(&registry, &mut server);
1839        let desc = tool_desc(&server, "ping");
1840        assert!(desc.contains("<!-- mcp-skill:ping -->"), "got: {desc}");
1841        assert!(
1842            desc.contains("<!-- mcp-skill:ping_strategy -->"),
1843            "got: {desc}"
1844        );
1845        assert!(
1846            desc.contains("PING-BODY") && desc.contains("STRAT-BODY"),
1847            "got: {desc}"
1848        );
1849    }
1850
1851    fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
1852        let dir = tempfile::tempdir().unwrap();
1853        let yaml = dir.path().join("test_mcp.yaml");
1854        std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1855        let skills_dir = dir.path().join("test_mcp.skills");
1856        std::fs::create_dir(&skills_dir).unwrap();
1857        std::fs::write(
1858            skills_dir.join("gated_skill.md"),
1859            format!(
1860                "---\n\
1861                 name: gated_skill\n\
1862                 description: A predicate-gated skill for testing.\n\
1863                 applies_when:\n\
1864                 {applies_when_yaml}\n\
1865                 ---\n\n\
1866                 Body.\n",
1867            ),
1868        )
1869        .unwrap();
1870        dir
1871    }
1872
1873    #[test]
1874    fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
1875        // `tool_registered: nonexistent_tool` — that tool isn't in
1876        // the registered catalogue, so the predicate fails and the
1877        // skill is omitted from `prompts/list`.
1878        use crate::server::skills::Registry as SkillsBuilder;
1879        let dir = write_gated_project_skill("  tool_registered: nonexistent_tool");
1880        let yaml = dir.path().join("test_mcp.yaml");
1881        let registry = SkillsBuilder::new()
1882            .auto_detect_project_layer(&yaml)
1883            .finalise()
1884            .unwrap();
1885        let mut server = McpServer::new(ServerOptions::default());
1886        super::serve_prompts(&registry, &mut server);
1887        assert!(
1888            !server.prompt_router.map.contains_key("gated_skill"),
1889            "skill with unsatisfied predicate must be suppressed"
1890        );
1891    }
1892
1893    #[test]
1894    fn serve_prompts_keeps_skill_with_satisfied_predicate() {
1895        // `tool_registered: ping` — ping is always registered, so
1896        // the predicate satisfies and the skill registers.
1897        use crate::server::skills::Registry as SkillsBuilder;
1898        let dir = write_gated_project_skill("  tool_registered: ping");
1899        let yaml = dir.path().join("test_mcp.yaml");
1900        let registry = SkillsBuilder::new()
1901            .auto_detect_project_layer(&yaml)
1902            .finalise()
1903            .unwrap();
1904        let mut server = McpServer::new(ServerOptions::default());
1905        super::serve_prompts(&registry, &mut server);
1906        assert!(
1907            server.prompt_router.map.contains_key("gated_skill"),
1908            "skill with satisfied predicate must register"
1909        );
1910    }
1911
1912    #[test]
1913    fn serve_prompts_evaluates_extension_enabled_from_manifest() {
1914        // The `extension_enabled:` predicate reads from
1915        // `ServerOptions.extensions`. Verify it integrates end-to-end
1916        // when the manifest declares the extension.
1917        use crate::server::skills::Registry as SkillsBuilder;
1918        let dir = write_gated_project_skill("  extension_enabled: csv_http_server");
1919        let yaml = dir.path().join("test_mcp.yaml");
1920        let registry = SkillsBuilder::new()
1921            .auto_detect_project_layer(&yaml)
1922            .finalise()
1923            .unwrap();
1924
1925        // Without the extension declared — suppressed.
1926        let mut server = McpServer::new(ServerOptions::default());
1927        super::serve_prompts(&registry, &mut server);
1928        assert!(!server.prompt_router.map.contains_key("gated_skill"));
1929
1930        // With the extension declared — registers.
1931        let mut extensions = serde_json::Map::new();
1932        extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1933        let opts = ServerOptions {
1934            extensions,
1935            ..ServerOptions::default()
1936        };
1937        let mut server = McpServer::new(opts);
1938        super::serve_prompts(&registry, &mut server);
1939        assert!(server.prompt_router.map.contains_key("gated_skill"));
1940    }
1941}