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