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