Skip to main content

mcp_methods/server/
server.rs

1//! MCP `ServerHandler` implementation.
2//!
3//! Tool surface, top to bottom:
4//!
5//! - **Always registered**: `ping`; the source tools (`read_source`,
6//!   `grep`, `list_source`) gated on an active source-roots provider;
7//!   `repo_management` (no-ops outside `--workspace` mode).
8//! - **Conditionally registered at boot** (dynamic):
9//!   - `github_issues` and `github_api` — only when `GITHUB_TOKEN` is
10//!     reachable. This is "honest tool listing": agents see the tools
11//!     only when they can succeed. Decision is boot-time; restart the
12//!     server to pick up a token that appears later.
13//!   - `set_root_dir` — only when the bound workspace is local-flavoured
14//!     (`workspace.kind: local`); swaps the active root at runtime.
15//!   - Manifest-declared `python:` tools and `cypher:` tools — added by
16//!     downstream binaries through `apply_python_extensions`.
17//!
18//! The source-roots provider is dynamic — workspace mode swaps it as
19//! the active repo changes; source-root and watch modes wire it to a
20//! fixed root; local-workspace mode rebinds it on `set_root_dir`. An
21//! empty list signals "no active source" and the tools return a
22//! friendly error rather than failing the call.
23//!
24//! Per-server state held on `McpServer` (cloned per request via `Arc`):
25//! a `ServerOptions` struct (providers + workspace handle + manifest
26//! builtins) and the rmcp `ToolRouter`. The `github_issues` closure
27//! additionally captures an `Arc<Mutex<ElementCache>>` so FETCH calls
28//! can cache collapsed elements (`cb_N`, `patch_N`, `comment_N`,
29//! `overflow`) for the agent to drill into via `element_id` on
30//! subsequent calls — no re-fetching.
31
32#![allow(dead_code)]
33
34use std::sync::{Arc, Mutex};
35
36use rmcp::handler::server::router::prompt::{PromptRoute, PromptRouter};
37use rmcp::handler::server::router::tool::ToolRouter;
38use rmcp::handler::server::wrapper::Parameters;
39use rmcp::model::*;
40use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
41use serde::{Deserialize, Serialize};
42
43use crate::server::manifest::Manifest;
44use crate::server::skills::ResolvedRegistry;
45use crate::server::source::{
46    self, resolve_dir_under_roots, GrepOpts, ListOpts, ReadOpts, SourceRootsProvider,
47};
48
49/// Provider returning the active GitHub repo (e.g. `"pydata/xarray"`)
50/// or `None` when nothing is bound. Workspace mode wires this to the
51/// active workspace repo; single-graph mode can pin a fixed value.
52pub type RepoProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
53
54/// Per-server runtime state shared by every tool dispatch.
55#[derive(Clone, Default)]
56pub struct ServerOptions {
57    /// Server display name surfaced via initialize.
58    pub name: Option<String>,
59    /// Free-form text shown to the agent at session start.
60    pub instructions: Option<String>,
61    /// Dynamic provider returning the active source roots, if any.
62    /// `None` disables the source tools entirely.
63    pub source_roots: Option<SourceRootsProvider>,
64    /// Dynamic provider returning the active GitHub repo (org/repo).
65    /// When `None`, github tools require a per-call `repo_name=` arg.
66    pub default_repo: Option<RepoProvider>,
67    /// Workspace handle (when `--workspace` mode is active).
68    pub workspace: Option<crate::server::workspace::Workspace>,
69    /// Manifest-declared `builtins:` block. Surfaced verbatim so
70    /// downstream consumers (kglite's `graph_overview` tool, for
71    /// example) can read `temp_cleanup` / `save_graph` settings and
72    /// implement the corresponding behaviour without re-parsing YAML.
73    pub builtins: crate::server::manifest::BuiltinsConfig,
74    /// Manifest-declared `extensions:` block. The framework uses this
75    /// for the `extension_enabled:` skill predicate; downstream
76    /// consumers can also read it for their own per-extension config.
77    /// Empty map when no `extensions:` block is present.
78    pub extensions: serde_json::Map<String, serde_json::Value>,
79}
80
81impl std::fmt::Debug for ServerOptions {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("ServerOptions")
84            .field("name", &self.name)
85            .field("instructions", &self.instructions)
86            .field(
87                "source_roots",
88                &self.source_roots.as_ref().map(|_| "<provider>"),
89            )
90            .field(
91                "default_repo",
92                &self.default_repo.as_ref().map(|_| "<provider>"),
93            )
94            .finish()
95    }
96}
97
98impl ServerOptions {
99    pub fn from_manifest(manifest: Option<&Manifest>, fallback_name: &str) -> Self {
100        Self {
101            name: manifest
102                .and_then(|m| m.name.clone())
103                .or_else(|| Some(fallback_name.to_string())),
104            instructions: manifest.and_then(|m| m.instructions.clone()),
105            source_roots: None,
106            default_repo: None,
107            workspace: None,
108            builtins: manifest.map(|m| m.builtins.clone()).unwrap_or_default(),
109            extensions: manifest.map(|m| m.extensions.clone()).unwrap_or_default(),
110        }
111    }
112
113    pub fn with_static_source_roots(mut self, roots: Vec<String>) -> Self {
114        let captured = Arc::new(roots);
115        self.source_roots = Some(Arc::new(move || captured.as_ref().clone()));
116        self
117    }
118
119    pub fn with_dynamic_source_roots(mut self, provider: SourceRootsProvider) -> Self {
120        self.source_roots = Some(provider);
121        self
122    }
123
124    pub fn with_static_repo(mut self, repo: String) -> Self {
125        self.default_repo = Some(Arc::new(move || Some(repo.clone())));
126        self
127    }
128
129    pub fn with_dynamic_repo(mut self, provider: RepoProvider) -> Self {
130        self.default_repo = Some(provider);
131        self
132    }
133
134    /// Bind a workspace handle. Source roots and default repo become
135    /// dynamic — both are read from the workspace's active-repo state
136    /// at every tool call, so `repo_management` swapping the active
137    /// repo immediately re-points the source tools.
138    pub fn with_workspace(mut self, ws: crate::server::workspace::Workspace) -> Self {
139        let ws_for_roots = ws.clone();
140        let ws_for_repo = ws.clone();
141        self.workspace = Some(ws);
142        self.source_roots = Some(Arc::new(move || {
143            ws_for_roots
144                .active_repo_path()
145                .map(|p| vec![p.to_string_lossy().into_owned()])
146                .unwrap_or_default()
147        }));
148        self.default_repo = Some(Arc::new(move || ws_for_repo.default_github_repo()));
149        self
150    }
151}
152
153#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
154pub struct PingArgs {
155    /// Optional message to echo back. Defaults to "pong".
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub message: Option<String>,
158}
159
160#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
161pub struct ReadSourceArgs {
162    /// File path relative to the configured source root(s).
163    pub file_path: String,
164    /// Start line (1-indexed). Defaults to start-of-file.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub start_line: Option<usize>,
167    /// End line (1-indexed, inclusive). Defaults to end-of-file.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub end_line: Option<usize>,
170    /// Regex pattern to filter lines. Returns matching lines plus context.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub grep: Option<String>,
173    /// Lines of context around each grep match (default 2).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub grep_context: Option<usize>,
176    /// Cap the number of matches returned.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub max_matches: Option<usize>,
179    /// Cap output size in characters.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub max_chars: Option<usize>,
182}
183
184#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
185pub struct GrepArgs {
186    /// Regex pattern (Rust regex syntax).
187    pub pattern: String,
188    /// File-name glob (e.g. ``"*.py"``). Defaults to all files.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub glob: Option<String>,
191    /// Lines of context around each match (default 0).
192    #[serde(default)]
193    pub context: usize,
194    /// Cap the number of matches (default 50; pass null/None for unlimited).
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub max_results: Option<usize>,
197    /// Case-insensitive matching.
198    #[serde(default)]
199    pub case_insensitive: bool,
200}
201
202#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
203pub struct SetRootDirArgs {
204    /// Absolute or relative path to bind as the new source root.
205    pub path: String,
206}
207
208#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
209pub struct RepoManagementArgs {
210    /// org/repo to clone and activate. Omit for list mode.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub name: Option<String>,
213    /// Delete the repo + inventory entry instead of activating.
214    #[serde(default)]
215    pub delete: bool,
216    /// Refresh the active repo (no name required).
217    #[serde(default)]
218    pub update: bool,
219    /// Bypass the auto-rebuild gate: re-run the post-activate hook
220    /// even when the HEAD SHA matches the last successful build.
221    /// Useful after upgrading the builder code itself.
222    #[serde(default)]
223    pub force_rebuild: bool,
224}
225
226#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
227pub struct GithubIssuesArgs {
228    /// GitHub issue / PR / Discussion number (FETCH mode).
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub number: Option<u64>,
231    /// org/repo override; defaults to the active server repo.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub repo_name: Option<String>,
234    /// Free-text query (SEARCH mode). When set, `number` is ignored.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub query: Option<String>,
237    /// "issue" | "pr" | "discussion" | "all" (default).
238    #[serde(default = "default_kind")]
239    pub kind: String,
240    /// "open" (default) | "closed" | "all".
241    #[serde(default = "default_state")]
242    pub state: String,
243    /// Sort key. Default "created" for list mode, relevance for search.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub sort: Option<String>,
246    /// Max results to return (default 20).
247    #[serde(default = "default_limit")]
248    pub limit: usize,
249    /// Comma-separated label filter (e.g. "bug,P0").
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub labels: Option<String>,
252    /// Drill-down: cached collapsed-element ID returned by a previous
253    /// FETCH (e.g. ``"cb_1"``, ``"comment_3"``, ``"overflow"``). When
254    /// set, `number` is required and the call returns the cached
255    /// element instead of re-fetching.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub element_id: Option<String>,
258    /// Line range filter for drill-down (``"N-M"`` 1-indexed). Only
259    /// meaningful alongside `element_id`. For comment segments,
260    /// interpreted as comment-index range.
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub lines: Option<String>,
263    /// Regex pattern for drill-down. Only meaningful alongside
264    /// `element_id`. Returns matching lines/items plus context.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub grep: Option<String>,
267    /// Context lines around each grep match in drill-down mode
268    /// (default 3).
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub context: Option<usize>,
271    /// Force a re-fetch (skip cache) when in FETCH mode. Useful after
272    /// an issue has been updated upstream.
273    #[serde(default)]
274    pub refresh: bool,
275}
276
277fn default_kind() -> String {
278    "all".to_string()
279}
280fn default_state() -> String {
281    "open".to_string()
282}
283fn default_limit() -> usize {
284    20
285}
286
287impl Default for GithubIssuesArgs {
288    fn default() -> Self {
289        Self {
290            number: None,
291            repo_name: None,
292            query: None,
293            kind: default_kind(),
294            state: default_state(),
295            sort: None,
296            limit: default_limit(),
297            labels: None,
298            element_id: None,
299            lines: None,
300            grep: None,
301            context: None,
302            refresh: false,
303        }
304    }
305}
306
307#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
308pub struct GithubApiArgs {
309    /// API path, with or without a leading slash. Repo-relative paths
310    /// (e.g. "pulls?state=open", "commits/abc", "branches",
311    /// "compare/main...x") are prefixed with /repos/<repo_name>/. Top-level
312    /// resources ("search/issues?q=...", "users/octocat", "repos/o/r") pass
313    /// through. A leading slash is accepted on either form — "/repos/o/r"
314    /// and "repos/o/r" resolve identically.
315    pub path: String,
316    /// org/repo override; defaults to the active server repo.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub repo_name: Option<String>,
319    /// Truncate response body at N chars (default 80,000).
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub truncate_at: Option<usize>,
322}
323
324#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
325pub struct ListSourceArgs {
326    /// Subdirectory relative to the source root (default ``"."``).
327    #[serde(default = "default_path")]
328    pub path: String,
329    /// Recursion depth (1 = flat ls; 2+ = tree).
330    #[serde(default = "default_depth")]
331    pub depth: usize,
332    /// Glob filter for entry names.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub glob: Option<String>,
335    /// Show only directories.
336    #[serde(default)]
337    pub dirs_only: bool,
338}
339
340fn default_path() -> String {
341    ".".to_string()
342}
343fn default_depth() -> usize {
344    1
345}
346
347/// MCP server backed by the rmcp framework.
348///
349/// The struct is cloned per request by rmcp's handler dispatch; the
350/// expensive bits (provider closure) are behind an Arc so cloning is cheap.
351#[derive(Clone)]
352pub struct McpServer {
353    options: ServerOptions,
354    tool_router: ToolRouter<McpServer>,
355    /// Skill-backed prompt routes. Empty until [`serve_prompts`] is
356    /// called with a resolved skill registry; remains empty for the
357    /// existing zero-skills boot path so `prompts/list` returns the
358    /// rmcp default (empty result, no capability advertised).
359    prompt_router: PromptRouter<McpServer>,
360}
361
362#[tool_router]
363impl McpServer {
364    pub fn new(options: ServerOptions) -> Self {
365        let mut server = Self {
366            options,
367            tool_router: Self::tool_router(),
368            prompt_router: PromptRouter::new(),
369        };
370        server.register_github_tools_if_authorized();
371        server.register_local_workspace_tools();
372        server.gate_workspace_tools();
373        server
374    }
375
376    /// Drop `repo_management` from the router when no workspace is
377    /// bound — `tools/list` should reflect the actual surface, not a
378    /// tool whose handler immediately errors out with "requires
379    /// --workspace mode." Mirrors the gating downstream binaries
380    /// (e.g. `kglite-mcp-server`) apply to the same tool. Operators
381    /// comparing the bare framework against a downstream binary's
382    /// surface see consistent behaviour now.
383    fn gate_workspace_tools(&mut self) {
384        if self.options.workspace.is_none() {
385            self.tool_router.remove_route("repo_management");
386        }
387    }
388
389    /// Register `set_root_dir` when the bound workspace is local-flavoured.
390    /// Github workspaces use `repo_management(name='org/repo')` to swap
391    /// roots; local workspaces need this alternative entry point.
392    fn register_local_workspace_tools(&mut self) {
393        let Some(ws) = self.options.workspace.clone() else {
394            return;
395        };
396        if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
397            return;
398        }
399        self.register_typed_tool::<SetRootDirArgs, _>(
400            "set_root_dir",
401            "Swap the active source root (local-workspace mode only). Pass `path` \
402             to a directory; the framework canonicalises it, rebinds the source \
403             tools (`read_source`, `grep`, `list_source`), and fires the post-\
404             activate hook so any downstream graph rebuilds against the new root. \
405             Inventory persists across swaps; SHA-gating skips rebuilds when the \
406             same root is re-bound with no content changes.",
407            move |args: SetRootDirArgs| {
408                let p = std::path::PathBuf::from(&args.path);
409                ws.set_root_dir(&p)
410            },
411        );
412    }
413
414    /// Register `github_issues` + `github_api` as dynamic tools — but
415    /// only when a GitHub token is reachable. This is honest tool
416    /// listing: agents see the tool only if it can actually succeed.
417    /// Decision is boot-time; restart the server to pick up a token
418    /// that appears later.
419    fn register_github_tools_if_authorized(&mut self) {
420        if !crate::github::has_git_token() {
421            tracing::info!(
422                "GITHUB_TOKEN not set — github_issues / github_api tools hidden from the agent. \
423                 Set the env var and restart to enable them."
424            );
425            return;
426        }
427        let default_repo = self.options.default_repo.clone();
428        let repo_provider = default_repo.clone();
429        // Per-server ElementCache: stores collapsed elements (cb_1,
430        // patch_2, comment_3, overflow) emitted by FETCH so the agent
431        // can drill down via `element_id` on subsequent calls without
432        // re-fetching the whole issue. Mutex contention is negligible
433        // for MCP's serial request dispatch.
434        let cache: Arc<Mutex<crate::cache::ElementCache>> =
435            Arc::new(Mutex::new(crate::cache::ElementCache::new()));
436        let cache_for_issues = cache.clone();
437        self.register_typed_tool::<GithubIssuesArgs, _>(
438            "github_issues",
439            "Search, list, or fetch GitHub issues / pull requests / Discussions. \
440             Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
441             for SEARCH (across issues+PRs and Discussions); neither for LIST. \
442             `kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
443             `state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
444             result count (default 20). `labels` is a comma-separated string. \
445             `repo_name=\"org/repo\"` overrides the active repo for one call. \
446             FETCH responses collapse big code blocks / patches / comments into \
447             `cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
448             `element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
449             element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
450             `refresh=true` bypasses the cache for re-fetch.",
451            move |args: GithubIssuesArgs| {
452                let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
453                    Ok(r) => r,
454                    Err(msg) => return msg,
455                };
456                // FETCH / drill-down: route through ElementCache so cb_*,
457                // patch_*, overflow stays addressable. Cache.fetch_issue
458                // does both the network fetch and the drill-down branch.
459                // All paths return a status `String` — invalid-repo,
460                // fetch-failure, cached-summary, overflow, full-text.
461                if let Some(number) = args.number {
462                    let context = args.context.unwrap_or(3);
463                    let mut guard = cache_for_issues.lock().unwrap();
464                    return guard.fetch_issue(
465                        &repo,
466                        number,
467                        args.element_id.as_deref(),
468                        args.lines.as_deref(),
469                        args.grep.as_deref(),
470                        context,
471                        args.refresh,
472                    );
473                }
474                if args.element_id.is_some() {
475                    return "element_id requires `number=N` (the issue/PR being drilled into)."
476                        .to_string();
477                }
478                // SEARCH / LIST: no caching, pure delegation.
479                crate::github::github_issues_rust(
480                    Some(&repo),
481                    args.number,
482                    args.query.as_deref(),
483                    &args.kind,
484                    &args.state,
485                    args.sort.as_deref(),
486                    args.limit,
487                    args.labels.as_deref(),
488                )
489            },
490        );
491        let repo_provider = default_repo;
492        self.register_typed_tool::<GithubApiArgs, _>(
493            "github_api",
494            "Read-only GET against the GitHub REST API. `path` may be a \
495             repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
496             \"branches\", \"compare/main...feature\") which is auto-prefixed \
497             with /repos/<repo_name>/, or a top-level resource (\"search/issues?q=...\", \
498             \"users/octocat\", \"repos/owner/name\") which passes through. A \
499             leading slash is optional and accepted on either form. Returns \
500             JSON, truncated at 80 KB by default.",
501            move |args: GithubApiArgs| match resolve_repo_from(
502                repo_provider.as_ref(),
503                args.repo_name.clone(),
504            ) {
505                Ok(repo) => {
506                    let truncate_at = args.truncate_at.unwrap_or(80_000);
507                    crate::github::git_api_internal(&repo, &args.path, truncate_at)
508                }
509                Err(msg) => msg,
510            },
511        );
512    }
513
514    /// Read the manifest-declared `builtins:` config. Downstream
515    /// consumers (e.g. a `graph_overview` tool that wipes a `temp/`
516    /// directory when `temp_cleanup: on_overview` is set) call this
517    /// to discover what flags the operator asked for. The framework
518    /// itself does not act on this — that would force it to interpret
519    /// graph-specific semantics it shouldn't know about.
520    pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
521        &self.options.builtins
522    }
523
524    /// Mutable access to the tool router for dynamic tool registration.
525    ///
526    /// Use only at server-construction time (before [`serve`](rmcp::ServiceExt::serve)).
527    /// Once dispatching starts, the router is cloned per request and
528    /// mutation would race.
529    pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
530        &mut self.tool_router
531    }
532
533    /// Mutable access to the prompt router for dynamic skill / prompt
534    /// registration. Same lifecycle contract as [`tool_router_mut`]:
535    /// boot-time only. Most operators reach prompts via
536    /// [`serve_prompts`] rather than touching the router directly.
537    pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
538        &mut self.prompt_router
539    }
540
541    /// Register a typed dynamic tool. Compresses the boilerplate of:
542    /// 1. Generating a JSON Schema for the args type via `schemars`.
543    /// 2. Building a [`rmcp::model::Tool`] attr from the schema +
544    ///    name + description.
545    /// 3. Deserialising the per-call JSON arguments via serde.
546    /// 4. Wrapping the handler in a [`rmcp::handler::server::router::tool::ToolRoute::new_dyn`]
547    ///    closure suitable for [`tool_router_mut`](Self::tool_router_mut).
548    ///
549    /// The handler is `Fn(T) -> String`; it owns whatever state it
550    /// needs through the closure environment (typically an Arc-clone
551    /// of a domain-specific state handle). Returning a string means
552    /// the tool reports a clean text body to the agent rather than
553    /// exposing a tool-error envelope — matches the framework's
554    /// "errors as values" convention for source / GitHub tools.
555    pub fn register_typed_tool<T, F>(
556        &mut self,
557        name: &'static str,
558        description: &'static str,
559        handler: F,
560    ) where
561        T: for<'de> serde::Deserialize<'de>
562            + schemars::JsonSchema
563            + Default
564            + Send
565            + Sync
566            + 'static,
567        F: Fn(T) -> String + Send + Sync + 'static,
568    {
569        use std::pin::Pin;
570        type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
571
572        let schema_obj = serde_json::to_value(schemars::schema_for!(T))
573            .ok()
574            .and_then(|v| v.as_object().cloned())
575            .unwrap_or_default();
576        let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
577        let handler = std::sync::Arc::new(handler);
578
579        self.tool_router
580            .add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
581                attr,
582                move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
583                    -> DynFut<'_, Result<rmcp::model::CallToolResult, rmcp::ErrorData>> {
584                    let handler = handler.clone();
585                    let arguments = ctx.arguments.clone();
586                    Box::pin(async move {
587                        let args: T = match arguments {
588                            Some(map) => {
589                                match serde_json::from_value(serde_json::Value::Object(map)) {
590                                    Ok(a) => a,
591                                    Err(e) => {
592                                        return Ok(rmcp::model::CallToolResult::success(vec![
593                                            rmcp::model::Content::text(format!(
594                                                "invalid arguments: {e}"
595                                            )),
596                                        ]));
597                                    }
598                                }
599                            }
600                            None => T::default(),
601                        };
602                        let body = handler(args);
603                        Ok(rmcp::model::CallToolResult::success(vec![
604                            rmcp::model::Content::text(body),
605                        ]))
606                    })
607                },
608            ));
609    }
610
611    fn current_source_roots(&self) -> Vec<String> {
612        match &self.options.source_roots {
613            Some(provider) => provider(),
614            None => Vec::new(),
615        }
616    }
617
618    /// Resolve the active repo: per-call override → configured default →
619    /// auto-detect from cwd (last-resort fallback). Returns the resolved
620    /// repo string and an `Err` (formatted user message) if none is found
621    /// or the value is malformed.
622    #[allow(dead_code)]
623    fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
624        resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
625    }
626
627    #[tool(
628        description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
629                          Use to confirm the server framework is wired correctly before \
630                          relying on graph- or source-aware tools."
631    )]
632    async fn ping(
633        &self,
634        Parameters(args): Parameters<PingArgs>,
635    ) -> Result<CallToolResult, McpError> {
636        let body = args.message.unwrap_or_else(|| "pong".to_string());
637        Ok(CallToolResult::success(vec![Content::text(body)]))
638    }
639
640    #[tool(description = "Read a file from the configured source root(s). Pass \
641                       `start_line`/`end_line` to slice, `grep` to filter to matching \
642                       lines, `max_chars` to cap output. Path traversal attempts are \
643                       rejected. Available only when source roots are configured.")]
644    async fn read_source(
645        &self,
646        Parameters(args): Parameters<ReadSourceArgs>,
647    ) -> Result<CallToolResult, McpError> {
648        let roots = self.current_source_roots();
649        if roots.is_empty() {
650            return Ok(CallToolResult::success(vec![Content::text(
651                "Cannot read source: no active source root. Configure source_root in your manifest \
652                 or activate one (e.g. via repo_management in workspace mode).",
653            )]));
654        }
655        let opts = ReadOpts {
656            start_line: args.start_line,
657            end_line: args.end_line,
658            grep: args.grep,
659            grep_context: args.grep_context,
660            max_matches: args.max_matches,
661            max_chars: args.max_chars,
662        };
663        let body = source::read_source(&args.file_path, &roots, &opts);
664        Ok(CallToolResult::success(vec![Content::text(body)]))
665    }
666
667    #[tool(
668        description = "Search source files using ripgrep. `pattern` is a regex (Rust \
669                       syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
670                       N surrounding lines per match. Set `case_insensitive=true` for \
671                       case-insensitive matching. `max_results` caps total matches \
672                       (default 50)."
673    )]
674    async fn grep(
675        &self,
676        Parameters(args): Parameters<GrepArgs>,
677    ) -> Result<CallToolResult, McpError> {
678        let roots = self.current_source_roots();
679        if roots.is_empty() {
680            return Ok(CallToolResult::success(vec![Content::text(
681                "Cannot grep: no active source root. Configure source_root in your manifest \
682                 or activate one (e.g. via repo_management in workspace mode).",
683            )]));
684        }
685        let opts = GrepOpts {
686            glob: args.glob,
687            context: args.context,
688            max_results: Some(args.max_results.unwrap_or(50)),
689            case_insensitive: args.case_insensitive,
690        };
691        let body = source::grep(&roots, &args.pattern, &opts);
692        Ok(CallToolResult::success(vec![Content::text(body)]))
693    }
694
695    #[tool(
696        description = "List directory contents under the configured source root. `path` \
697                       is resolved against the first source root (\".\" lists the root \
698                       itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
699                       `glob` filters entry names. `dirs_only=true` shows only \
700                       directories."
701    )]
702    async fn list_source(
703        &self,
704        Parameters(args): Parameters<ListSourceArgs>,
705    ) -> Result<CallToolResult, McpError> {
706        let roots = self.current_source_roots();
707        if roots.is_empty() {
708            return Ok(CallToolResult::success(vec![Content::text(
709                "Cannot list source: no active source root. Configure source_root in your \
710                 manifest or activate one (e.g. via repo_management in workspace mode).",
711            )]));
712        }
713        let primary = std::path::PathBuf::from(&roots[0]);
714        let target = match resolve_dir_under_roots(&args.path, &roots) {
715            Some(p) => p,
716            None => {
717                return Ok(CallToolResult::success(vec![Content::text(format!(
718                    "Error: path '{}' resolves outside the configured source roots.",
719                    args.path
720                ))]));
721            }
722        };
723        let opts = ListOpts {
724            depth: args.depth,
725            glob: args.glob,
726            dirs_only: args.dirs_only,
727        };
728        let body = source::list_source(&target, &primary, &opts);
729        Ok(CallToolResult::success(vec![Content::text(body)]))
730    }
731
732    #[tool(
733        description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
734                       clone (if missing) and activate it as the source root for \
735                       read_source / grep / list_source. Pass `delete=true` to remove a \
736                       repo. Pass `update=true` to fetch upstream changes for the active \
737                       repo (rebuild auto-skipped when HEAD hasn't moved since the last \
738                       build; set `force_rebuild=true` to bypass). Call with no \
739                       arguments to list all known repos with their last-access counts. \
740                       Idle repos auto-sweep on each call (default 7 days, configurable \
741                       via --stale-after-days)."
742    )]
743    async fn repo_management(
744        &self,
745        Parameters(args): Parameters<RepoManagementArgs>,
746    ) -> Result<CallToolResult, McpError> {
747        let body = match &self.options.workspace {
748            Some(ws) => ws.repo_management(
749                args.name.as_deref(),
750                args.delete,
751                args.update,
752                args.force_rebuild,
753            ),
754            None => "repo_management requires --workspace mode.".to_string(),
755        };
756        Ok(CallToolResult::success(vec![Content::text(body)]))
757    }
758}
759
760/// Resolve `org/repo`: per-call override → configured default →
761/// auto-detect from cwd. Returns either the resolved repo or a
762/// formatted user-facing error message.
763///
764/// Free function (not a method) so it can be called from closures
765/// captured by [`McpServer::register_typed_tool`] which only see
766/// `Fn(T) -> String` — no `&self`.
767fn resolve_repo_from(
768    default_repo: Option<&RepoProvider>,
769    override_repo: Option<String>,
770) -> Result<String, String> {
771    if let Some(r) = override_repo {
772        if let Some(err) = crate::git_refs::validate_repo(&r) {
773            return Err(err);
774        }
775        return Ok(r);
776    }
777    if let Some(provider) = default_repo {
778        if let Some(r) = provider() {
779            if let Some(err) = crate::git_refs::validate_repo(&r) {
780                return Err(err);
781            }
782            return Ok(r);
783        }
784    }
785    if let Some(detected) = crate::github::detect_git_repo(".") {
786        if crate::git_refs::validate_repo(&detected).is_none() {
787            return Ok(detected);
788        }
789    }
790    Err(
791        "No active repository. Pass `repo_name='org/repo'`, configure a default in the \
792         server, or run from a directory whose git remote points at github.com."
793            .to_string(),
794    )
795}
796
797/// Wire a resolved skill registry into a server's `prompts/list` and
798/// `prompts/get` surface, and apply auto-injection hints to tool
799/// descriptions for skills whose name matches a registered tool.
800///
801/// Call at boot time after all tools have been registered (so the
802/// auto-inject pass sees the final tool catalogue) and before
803/// `serve(...)`. Idempotent in spirit but not by construction:
804/// calling twice with the same registry would re-append the hint to
805/// already-injected descriptions, so don't.
806///
807/// The function is additive and a no-op when the registry is empty
808/// — downstream callers can wire it unconditionally without breaking
809/// the zero-skills boot path.
810pub fn serve_prompts(registry: &ResolvedRegistry, server: &mut McpServer) {
811    use std::borrow::Cow;
812    use std::collections::HashSet;
813
814    // Build the framework-internal predicate state once. The tool
815    // router has the full registered-tool list; extensions come from
816    // the manifest's builtins block (operators may have nothing
817    // here, in which case all `extension_enabled:` predicates fail).
818    let registered_tools: HashSet<String> = server
819        .tool_router
820        .list_all()
821        .iter()
822        .map(|t| t.name.to_string())
823        .collect();
824    let extensions = server.options.extensions.clone();
825
826    // For the auto-inject pass: skills whose name matches a registered
827    // tool get their full body embedded into that tool's description.
828    // Tracking (name, body) — see the comment at the bottom of the
829    // function for why this is the body, not a pointer.
830    let mut auto_inject: Vec<(String, String)> = Vec::new();
831
832    for name in registry.skill_names() {
833        let Some(skill) = registry.get(&name) else {
834            continue;
835        };
836
837        // Evaluate `applies_when:` against the runtime state. Skills
838        // with all predicates satisfied register; others are
839        // suppressed from the agent-facing surface.
840        let activation = registry.activation_for(skill, &registered_tools, &extensions);
841        if !activation.active {
842            let failed_clauses: Vec<&str> = activation
843                .clauses
844                .iter()
845                .filter(|(_, outcome)| {
846                    *outcome != crate::server::skills::PredicateOutcome::Satisfied
847                })
848                .map(|(clause, _)| clause.as_str())
849                .collect();
850            tracing::info!(
851                skill = %name,
852                suppressed_by = ?failed_clauses,
853                "skill suppressed by applies_when predicates"
854            );
855            continue;
856        }
857
858        let prompt = Prompt::new(
859            skill.name().to_string(),
860            Some(skill.description().to_string()),
861            None,
862        );
863        let body = skill.body.clone();
864        let route = PromptRoute::new_dyn(prompt, move |_ctx| {
865            let body = body.clone();
866            Box::pin(async move {
867                Ok(GetPromptResult::new(vec![PromptMessage::new_text(
868                    PromptMessageRole::Assistant,
869                    body,
870                )]))
871            })
872        });
873        server.prompt_router.add_route(route);
874
875        if skill.frontmatter.auto_inject_hint {
876            auto_inject.push((skill.name().to_string(), skill.body.clone()));
877        }
878    }
879
880    // Auto-inject the full skill body into matching tool descriptions.
881    //
882    // Background: pre-0.3.37 this loop appended a short pointer line
883    // (`See `prompts/get` <name> for the full methodology.`) to the
884    // tool description, assuming agents could call `prompts/get` to
885    // fetch the body. **They can't** in real MCP clients — Claude Code,
886    // Claude Desktop, Cursor, and Continue all expose only `tools/*`
887    // to the model; the `prompts/` plane was designed for human-
888    // invoked slash commands. Operators authoring against the pointer
889    // pattern shipped methodology the agent literally could not read.
890    //
891    // The fix: when `auto_inject_hint: true` AND a registered tool
892    // shares the skill's name, embed the full body under a
893    // `## Methodology` header. Bounded by the 4 KB soft / 16 KB hard
894    // size caps the framework already enforces per skill. Operators
895    // who want the smaller pointer-only behaviour set
896    // `auto_inject_hint: false` per skill.
897    //
898    // `prompts/list` / `prompts/get` continue to work for any client
899    // that does surface them to the agent, plus CLI introspection.
900    // This pass just makes the *primary* delivery channel a place
901    // agents actually look.
902    for (skill_name, body) in &auto_inject {
903        let key = Cow::<'static, str>::Owned(skill_name.clone());
904        if let Some(route) = server.tool_router.map.get_mut(&key) {
905            let trimmed_body = body.trim();
906            let inject = format!("\n\n## Methodology\n\n{trimmed_body}");
907            let new_desc = match route.attr.description.take() {
908                Some(existing) => format!("{existing}{inject}"),
909                None => inject.trim_start().to_string(),
910            };
911            route.attr.description = Some(Cow::Owned(new_desc));
912        }
913    }
914}
915
916#[tool_handler(router = self.tool_router)]
917impl ServerHandler for McpServer {
918    fn get_info(&self) -> ServerInfo {
919        let name = self
920            .options
921            .name
922            .clone()
923            .unwrap_or_else(|| "MCP Server".to_string());
924        // Only advertise the prompts capability when at least one skill
925        // is registered. The zero-skills boot path is the existing
926        // contract and must keep producing capability output that's
927        // byte-identical to today. ServerCapabilities is `#[non_exhaustive]`
928        // but its fields are pub, so we mutate after `build()` rather
929        // than fighting the type-state builder.
930        let mut caps = ServerCapabilities::builder().enable_tools().build();
931        if !self.prompt_router.map.is_empty() {
932            caps.prompts = Some(PromptsCapability::default());
933        }
934        let mut info = ServerInfo::new(caps)
935            .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
936            .with_protocol_version(ProtocolVersion::V_2024_11_05);
937        if let Some(text) = &self.options.instructions {
938            info = info.with_instructions(text.clone());
939        }
940        info
941    }
942
943    async fn list_prompts(
944        &self,
945        _request: Option<PaginatedRequestParams>,
946        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
947    ) -> Result<ListPromptsResult, McpError> {
948        Ok(ListPromptsResult {
949            meta: None,
950            next_cursor: None,
951            prompts: self.prompt_router.list_all(),
952        })
953    }
954
955    async fn get_prompt(
956        &self,
957        request: GetPromptRequestParams,
958        context: rmcp::service::RequestContext<rmcp::RoleServer>,
959    ) -> Result<GetPromptResult, McpError> {
960        let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
961            self,
962            request.name,
963            request.arguments,
964            context,
965        );
966        self.prompt_router.get_prompt(prompt_context).await
967    }
968}
969
970#[cfg(test)]
971mod tests {
972    use super::*;
973
974    #[test]
975    fn options_from_manifest_uses_name_when_set() {
976        let opts = ServerOptions::from_manifest(None, "Fallback");
977        assert_eq!(opts.name.as_deref(), Some("Fallback"));
978    }
979
980    #[test]
981    fn builtins_exposed_via_server() {
982        use crate::server::manifest::{BuiltinsConfig, TempCleanup};
983        let opts = ServerOptions {
984            builtins: BuiltinsConfig {
985                save_graph: true,
986                temp_cleanup: TempCleanup::OnOverview,
987            },
988            ..ServerOptions::default()
989        };
990        let server = McpServer::new(opts);
991        assert!(server.builtins().save_graph);
992        assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
993    }
994
995    #[test]
996    fn server_constructs() {
997        let _server = McpServer::new(ServerOptions::default());
998    }
999
1000    #[test]
1001    fn static_source_roots_provider() {
1002        let opts = ServerOptions::default()
1003            .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
1004        let server = McpServer::new(opts);
1005        assert_eq!(
1006            server.current_source_roots(),
1007            vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
1008        );
1009    }
1010
1011    #[test]
1012    fn no_provider_returns_empty_roots() {
1013        let server = McpServer::new(ServerOptions::default());
1014        assert!(server.current_source_roots().is_empty());
1015    }
1016
1017    #[test]
1018    fn repo_management_gated_to_workspace_mode() {
1019        // Bare (no workspace): repo_management should NOT be in the
1020        // router. Mirrors the gating downstream binaries apply.
1021        let server = McpServer::new(ServerOptions::default());
1022        let tools = server.tool_router.list_all();
1023        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1024        assert!(
1025            !names.contains(&"repo_management"),
1026            "repo_management should be gated out without a workspace; tools were {names:?}"
1027        );
1028    }
1029
1030    #[test]
1031    fn repo_management_present_when_workspace_bound() {
1032        // With a workspace handle bound, repo_management should be
1033        // registered.
1034        use crate::server::workspace::Workspace;
1035        let dir = tempfile::tempdir().unwrap();
1036        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1037        let opts = ServerOptions::default().with_workspace(ws);
1038        let server = McpServer::new(opts);
1039        let tools = server.tool_router.list_all();
1040        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1041        assert!(
1042            names.contains(&"repo_management"),
1043            "repo_management should be registered with a workspace; tools were {names:?}"
1044        );
1045    }
1046
1047    #[test]
1048    fn dynamic_provider_swaps_at_call_time() {
1049        use std::sync::Mutex;
1050        let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
1051        let s2 = state.clone();
1052        let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
1053        let opts = ServerOptions::default().with_dynamic_source_roots(provider);
1054        let server = McpServer::new(opts);
1055        assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
1056        *state.lock().unwrap() = vec!["/swapped".to_string()];
1057        assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
1058    }
1059
1060    // ─── Prompt / skill wiring ────────────────────────────────────
1061
1062    fn build_test_registry(
1063        skills: &[(&str, &str, &str, bool)],
1064    ) -> crate::server::skills::ResolvedRegistry {
1065        use crate::server::skills::Registry;
1066        let dir = tempfile::tempdir().unwrap();
1067        let yaml_path = dir.path().join("manifest.yaml");
1068        let skills_dir = dir.path().join("manifest.skills");
1069        std::fs::create_dir_all(&skills_dir).unwrap();
1070        for (name, description, body, auto_inject) in skills {
1071            let auto = if *auto_inject { "true" } else { "false" };
1072            let content = format!(
1073                "---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
1074            );
1075            std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1076        }
1077        Registry::new()
1078            .auto_detect_project_layer(&yaml_path)
1079            .finalise()
1080            .unwrap()
1081    }
1082
1083    #[test]
1084    fn prompt_router_empty_by_default() {
1085        let server = McpServer::new(ServerOptions::default());
1086        assert!(server.prompt_router.map.is_empty());
1087    }
1088
1089    #[test]
1090    fn get_info_no_prompts_capability_when_empty() {
1091        // Zero-impact invariant: a server with no skills must not
1092        // advertise the prompts capability. kglite's existing
1093        // deployment depends on this byte-for-byte.
1094        let server = McpServer::new(ServerOptions::default());
1095        let info = server.get_info();
1096        assert!(
1097            info.capabilities.prompts.is_none(),
1098            "prompts capability must be absent when no skills are registered"
1099        );
1100    }
1101
1102    #[test]
1103    fn serve_prompts_registers_routes_with_metadata() {
1104        let registry = build_test_registry(&[
1105            ("alpha", "First skill.", "Alpha body.", true),
1106            ("beta", "Second skill.", "Beta body.", true),
1107        ]);
1108        let mut server = McpServer::new(ServerOptions::default());
1109        super::serve_prompts(&registry, &mut server);
1110
1111        let prompts = server.prompt_router.list_all();
1112        let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
1113        assert_eq!(names, vec!["alpha", "beta"]);
1114
1115        let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
1116        assert_eq!(alpha.description.as_deref(), Some("First skill."));
1117        assert!(alpha.arguments.is_none());
1118    }
1119
1120    #[test]
1121    fn serve_prompts_empty_registry_is_noop() {
1122        let registry = crate::server::skills::ResolvedRegistry::default();
1123        let mut server = McpServer::new(ServerOptions::default());
1124        super::serve_prompts(&registry, &mut server);
1125        assert!(server.prompt_router.map.is_empty());
1126        assert!(server.get_info().capabilities.prompts.is_none());
1127    }
1128
1129    #[test]
1130    fn get_info_advertises_prompts_when_present() {
1131        let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
1132        let mut server = McpServer::new(ServerOptions::default());
1133        super::serve_prompts(&registry, &mut server);
1134        let info = server.get_info();
1135        assert!(
1136            info.capabilities.prompts.is_some(),
1137            "prompts capability must be advertised once a skill is registered"
1138        );
1139    }
1140
1141    #[test]
1142    fn serve_prompts_auto_injects_full_body_into_matching_tool() {
1143        // `ping` is registered by every server. A skill named `ping`
1144        // with `auto_inject_hint: true` should embed its full body
1145        // under a `## Methodology` header in the ping tool's
1146        // description. Pre-0.3.37 this appended a short pointer at
1147        // `prompts/get`, but agents in real MCP clients can't reach
1148        // that surface — see the comment on the auto-inject loop in
1149        // `serve_prompts`.
1150        let registry =
1151            build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
1152        let mut server = McpServer::new(ServerOptions::default());
1153        let before = server
1154            .tool_router
1155            .get("ping")
1156            .and_then(|t| t.description.clone())
1157            .map(|c| c.into_owned())
1158            .unwrap_or_default();
1159        super::serve_prompts(&registry, &mut server);
1160        let after = server
1161            .tool_router
1162            .get("ping")
1163            .and_then(|t| t.description.clone())
1164            .map(|c| c.into_owned())
1165            .unwrap_or_default();
1166        assert!(after.starts_with(&before), "original description preserved");
1167        assert!(
1168            after.contains("## Methodology"),
1169            "inject should include a Methodology header; got: {after}"
1170        );
1171        assert!(
1172            after.contains("PING-BODY-SENTINEL"),
1173            "inject should embed the full skill body; got: {after}"
1174        );
1175        assert!(
1176            !after.contains("prompts/get"),
1177            "post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
1178        );
1179    }
1180
1181    #[test]
1182    fn serve_prompts_skips_injection_when_disabled() {
1183        let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
1184        let mut server = McpServer::new(ServerOptions::default());
1185        let before = server
1186            .tool_router
1187            .get("ping")
1188            .and_then(|t| t.description.clone())
1189            .map(|c| c.into_owned())
1190            .unwrap_or_default();
1191        super::serve_prompts(&registry, &mut server);
1192        let after = server
1193            .tool_router
1194            .get("ping")
1195            .and_then(|t| t.description.clone())
1196            .map(|c| c.into_owned())
1197            .unwrap_or_default();
1198        assert_eq!(
1199            before, after,
1200            "auto_inject_hint=false must leave tool description untouched"
1201        );
1202    }
1203
1204    #[test]
1205    fn serve_prompts_skips_injection_when_no_matching_tool() {
1206        // Skill name doesn't match any registered tool; nothing to
1207        // inject into, but the prompt route is still added.
1208        let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
1209        let mut server = McpServer::new(ServerOptions::default());
1210        super::serve_prompts(&registry, &mut server);
1211        assert!(server.prompt_router.map.contains_key("no_such_tool"));
1212        // No panic, no mutation of unrelated tools — the ping tool's
1213        // description is unchanged.
1214        let ping_desc = server
1215            .tool_router
1216            .get("ping")
1217            .and_then(|t| t.description.clone())
1218            .map(|c| c.into_owned())
1219            .unwrap_or_default();
1220        assert!(!ping_desc.contains("no_such_tool"));
1221    }
1222
1223    fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
1224        let dir = tempfile::tempdir().unwrap();
1225        let yaml = dir.path().join("test_mcp.yaml");
1226        std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1227        let skills_dir = dir.path().join("test_mcp.skills");
1228        std::fs::create_dir(&skills_dir).unwrap();
1229        std::fs::write(
1230            skills_dir.join("gated_skill.md"),
1231            format!(
1232                "---\n\
1233                 name: gated_skill\n\
1234                 description: A predicate-gated skill for testing.\n\
1235                 applies_when:\n\
1236                 {applies_when_yaml}\n\
1237                 ---\n\n\
1238                 Body.\n",
1239            ),
1240        )
1241        .unwrap();
1242        dir
1243    }
1244
1245    #[test]
1246    fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
1247        // `tool_registered: nonexistent_tool` — that tool isn't in
1248        // the registered catalogue, so the predicate fails and the
1249        // skill is omitted from `prompts/list`.
1250        use crate::server::skills::Registry as SkillsBuilder;
1251        let dir = write_gated_project_skill("  tool_registered: nonexistent_tool");
1252        let yaml = dir.path().join("test_mcp.yaml");
1253        let registry = SkillsBuilder::new()
1254            .auto_detect_project_layer(&yaml)
1255            .finalise()
1256            .unwrap();
1257        let mut server = McpServer::new(ServerOptions::default());
1258        super::serve_prompts(&registry, &mut server);
1259        assert!(
1260            !server.prompt_router.map.contains_key("gated_skill"),
1261            "skill with unsatisfied predicate must be suppressed"
1262        );
1263    }
1264
1265    #[test]
1266    fn serve_prompts_keeps_skill_with_satisfied_predicate() {
1267        // `tool_registered: ping` — ping is always registered, so
1268        // the predicate satisfies and the skill registers.
1269        use crate::server::skills::Registry as SkillsBuilder;
1270        let dir = write_gated_project_skill("  tool_registered: ping");
1271        let yaml = dir.path().join("test_mcp.yaml");
1272        let registry = SkillsBuilder::new()
1273            .auto_detect_project_layer(&yaml)
1274            .finalise()
1275            .unwrap();
1276        let mut server = McpServer::new(ServerOptions::default());
1277        super::serve_prompts(&registry, &mut server);
1278        assert!(
1279            server.prompt_router.map.contains_key("gated_skill"),
1280            "skill with satisfied predicate must register"
1281        );
1282    }
1283
1284    #[test]
1285    fn serve_prompts_evaluates_extension_enabled_from_manifest() {
1286        // The `extension_enabled:` predicate reads from
1287        // `ServerOptions.extensions`. Verify it integrates end-to-end
1288        // when the manifest declares the extension.
1289        use crate::server::skills::Registry as SkillsBuilder;
1290        let dir = write_gated_project_skill("  extension_enabled: csv_http_server");
1291        let yaml = dir.path().join("test_mcp.yaml");
1292        let registry = SkillsBuilder::new()
1293            .auto_detect_project_layer(&yaml)
1294            .finalise()
1295            .unwrap();
1296
1297        // Without the extension declared — suppressed.
1298        let mut server = McpServer::new(ServerOptions::default());
1299        super::serve_prompts(&registry, &mut server);
1300        assert!(!server.prompt_router.map.contains_key("gated_skill"));
1301
1302        // With the extension declared — registers.
1303        let mut extensions = serde_json::Map::new();
1304        extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1305        let opts = ServerOptions {
1306            extensions,
1307            ..ServerOptions::default()
1308        };
1309        let mut server = McpServer::new(opts);
1310        super::serve_prompts(&registry, &mut server);
1311        assert!(server.prompt_router.map.contains_key("gated_skill"));
1312    }
1313}