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#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
348pub struct ScreenStargazersArgs {
349    /// Repo whose stargazers to screen, as "owner/repo".
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub repo: Option<String>,
352    /// Alternatively, screen an explicit set of users — comma-separated
353    /// logins ("octocat,torvalds"). Takes precedence over `repo`.
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub users: Option<String>,
356    /// Focused view via a named preset: "outreach" (relevant+active by
357    /// reach), "peers" (your stack by effort), "legends" (biggest reach),
358    /// "intel" (on-domain by popularity), "adopters" (actual users).
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub preset: Option<String>,
361    /// Or rank explicitly by one axis: relatedness | popularity | effort | recency.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub rank_by: Option<String>,
364    /// Top-K for the focused/preset view (default 10).
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub top: Option<usize>,
367    /// Filter: minimum distinct keyword hits (relatedness gate).
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub min_keywords: Option<usize>,
370    /// Filter: only people active since this date (YYYY-MM-DD).
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub active_since: Option<String>,
373    /// Filter: only people who actually depend on the seed package.
374    #[serde(default)]
375    pub adopters_only: bool,
376    /// Filter: only architectural (stack) peers.
377    #[serde(default)]
378    pub stack_only: bool,
379    /// Comma-separated topic keywords for the relevance gate (e.g.
380    /// "graph,rag,agent,llm"). Matched whole-word against repo
381    /// name/topics/description; devs hitting ≥2 distinct keywords are
382    /// surfaced as leads, single-keyword hits demoted to a footnote.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub keywords: Option<String>,
385    /// Comma-separated languages defining the seed project's stack (e.g.
386    /// "Rust,Python"). Stargazers using all of them are flagged as a
387    /// keyword-invisible "stack match" to drill into.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub stack: Option<String>,
390    /// Cap the number of stargazers screened (most-recent first).
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub max_stargazers: Option<usize>,
393    /// Drill into the cached screen instead of returning the overview:
394    /// "cohort:<key>", "user:<login>", "user:<login>/repo:<name>", or
395    /// ".../readme". Requires a prior no-element_id call for the repo.
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub element_id: Option<String>,
398    /// Re-fetch from GitHub instead of reusing the cached screen.
399    #[serde(default)]
400    pub refresh: bool,
401}
402
403/// MCP server backed by the rmcp framework.
404///
405/// The struct is cloned per request by rmcp's handler dispatch; the
406/// expensive bits (provider closure) are behind an Arc so cloning is cheap.
407#[derive(Clone)]
408pub struct McpServer {
409    options: ServerOptions,
410    tool_router: ToolRouter<McpServer>,
411    /// Skill-backed prompt routes. Empty until [`serve_prompts`] is
412    /// called with a resolved skill registry; remains empty for the
413    /// existing zero-skills boot path so `prompts/list` returns the
414    /// rmcp default (empty result, no capability advertised).
415    prompt_router: PromptRouter<McpServer>,
416}
417
418#[tool_router]
419impl McpServer {
420    pub fn new(options: ServerOptions) -> Self {
421        let mut server = Self {
422            options,
423            tool_router: Self::tool_router(),
424            prompt_router: PromptRouter::new(),
425        };
426        server.register_github_tools_if_authorized();
427        server.register_local_workspace_tools();
428        server.gate_workspace_tools();
429        server
430    }
431
432    /// Drop `repo_management` from the router when no workspace is
433    /// bound — `tools/list` should reflect the actual surface, not a
434    /// tool whose handler immediately errors out with "requires
435    /// --workspace mode." Mirrors the gating downstream binaries
436    /// (e.g. `kglite-mcp-server`) apply to the same tool. Operators
437    /// comparing the bare framework against a downstream binary's
438    /// surface see consistent behaviour now.
439    fn gate_workspace_tools(&mut self) {
440        if self.options.workspace.is_none() {
441            self.tool_router.remove_route("repo_management");
442        }
443    }
444
445    /// Register `set_root_dir` when the bound workspace is local-flavoured.
446    /// Github workspaces use `repo_management(name='org/repo')` to swap
447    /// roots; local workspaces need this alternative entry point.
448    fn register_local_workspace_tools(&mut self) {
449        let Some(ws) = self.options.workspace.clone() else {
450            return;
451        };
452        if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
453            return;
454        }
455        self.register_typed_tool::<SetRootDirArgs, _>(
456            "set_root_dir",
457            "Swap the active source root (local-workspace mode only). Pass `path` \
458             to a directory; the framework canonicalises it, rebinds the source \
459             tools (`read_source`, `grep`, `list_source`), and fires the post-\
460             activate hook so any downstream graph rebuilds against the new root. \
461             Inventory persists across swaps; SHA-gating skips rebuilds when the \
462             same root is re-bound with no content changes.",
463            move |args: SetRootDirArgs| {
464                let p = std::path::PathBuf::from(&args.path);
465                ws.set_root_dir(&p)
466            },
467        );
468    }
469
470    /// Register `github_issues` + `github_api` as dynamic tools — but
471    /// only when a GitHub token is reachable. This is honest tool
472    /// listing: agents see the tool only if it can actually succeed.
473    /// Decision is boot-time; restart the server to pick up a token
474    /// that appears later.
475    fn register_github_tools_if_authorized(&mut self) {
476        if !crate::github::has_git_token() {
477            tracing::info!(
478                "GITHUB_TOKEN not set — github_issues / github_api tools hidden from the agent. \
479                 Set the env var and restart to enable them."
480            );
481            return;
482        }
483        let default_repo = self.options.default_repo.clone();
484        let repo_provider = default_repo.clone();
485        // Per-server ElementCache: stores collapsed elements (cb_1,
486        // patch_2, comment_3, overflow) emitted by FETCH so the agent
487        // can drill down via `element_id` on subsequent calls without
488        // re-fetching the whole issue. Mutex contention is negligible
489        // for MCP's serial request dispatch.
490        let cache: Arc<Mutex<crate::cache::ElementCache>> =
491            Arc::new(Mutex::new(crate::cache::ElementCache::new()));
492        let cache_for_issues = cache.clone();
493        self.register_typed_tool::<GithubIssuesArgs, _>(
494            "github_issues",
495            "Search, list, or fetch GitHub issues / pull requests / Discussions. \
496             Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
497             for SEARCH (across issues+PRs and Discussions); neither for LIST. \
498             `kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
499             `state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
500             result count (default 20). `labels` is a comma-separated string. \
501             `repo_name=\"org/repo\"` overrides the active repo for one call. \
502             FETCH responses collapse big code blocks / patches / comments into \
503             `cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
504             `element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
505             element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
506             `refresh=true` bypasses the cache for re-fetch.",
507            move |args: GithubIssuesArgs| {
508                let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
509                    Ok(r) => r,
510                    Err(msg) => return msg,
511                };
512                // FETCH / drill-down: route through ElementCache so cb_*,
513                // patch_*, overflow stays addressable. Cache.fetch_issue
514                // does both the network fetch and the drill-down branch.
515                // All paths return a status `String` — invalid-repo,
516                // fetch-failure, cached-summary, overflow, full-text.
517                if let Some(number) = args.number {
518                    let context = args.context.unwrap_or(3);
519                    let mut guard = cache_for_issues.lock().unwrap();
520                    return guard.fetch_issue(
521                        &repo,
522                        number,
523                        args.element_id.as_deref(),
524                        args.lines.as_deref(),
525                        args.grep.as_deref(),
526                        context,
527                        args.refresh,
528                    );
529                }
530                if args.element_id.is_some() {
531                    return "element_id requires `number=N` (the issue/PR being drilled into)."
532                        .to_string();
533                }
534                // SEARCH / LIST: no caching, pure delegation.
535                crate::github::github_issues_rust(
536                    Some(&repo),
537                    args.number,
538                    args.query.as_deref(),
539                    &args.kind,
540                    &args.state,
541                    args.sort.as_deref(),
542                    args.limit,
543                    args.labels.as_deref(),
544                )
545            },
546        );
547        let repo_provider = default_repo.clone();
548        let repo_for_screen = default_repo;
549        self.register_typed_tool::<GithubApiArgs, _>(
550            "github_api",
551            "Read-only GET against the GitHub REST API. `path` may be a \
552             repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
553             \"branches\", \"compare/main...feature\") which is auto-prefixed \
554             with /repos/<repo_name>/, or a top-level resource (\"search/issues?q=...\", \
555             \"users/octocat\", \"repos/owner/name\") which passes through. A \
556             leading slash is optional and accepted on either form. Returns \
557             JSON, truncated at 80 KB by default.",
558            move |args: GithubApiArgs| match resolve_repo_from(
559                repo_provider.as_ref(),
560                args.repo_name.clone(),
561            ) {
562                Ok(repo) => {
563                    let truncate_at = args.truncate_at.unwrap_or(80_000);
564                    crate::github::git_api_internal(&repo, &args.path, truncate_at)
565                }
566                Err(msg) => msg,
567            },
568        );
569
570        // screen_stargazers — bulk-screen a repo's stargazers over cheap
571        // REST into a per-server store, return a compact cohort+relevance
572        // overview, and let the agent drill via `element_id` (cache hits;
573        // only `.../readme` costs a request). The store is the stargazer
574        // analogue of `github_issues`' ElementCache. Operators can drop it
575        // (keeping the other GitHub tools) via `builtins.screen_stargazers:
576        // false`; default on.
577        if self.options.builtins.screen_stargazers {
578            let screen_store: Arc<Mutex<crate::screen::ScreenStore>> =
579                Arc::new(Mutex::new(crate::screen::ScreenStore::new()));
580            self.register_typed_tool::<ScreenStargazersArgs, _>(
581                "screen_stargazers",
582                "Screen the people around a GitHub project to find relevant developers, \
583             notable/legendary devs, architectural peers, and actual users — cheaply. \
584             Seed on a repo (`repo=\"owner/repo\"` → screens its stargazers) OR an \
585             explicit user list (`users=\"alice,bob\"` → screens them directly). With \
586             just a repo it auto-derives relevance keywords + tech stack from the repo \
587             itself, bulk-fetches each person's public repo portfolio over plain REST \
588             (~1 request per person, no GraphQL, no READMEs), classifies them, and \
589             enriches a bounded shortlist with follower counts, dependency-adoption, \
590             stack co-location, and contributions. Every person gets a normalized \
591             0–100 score vector on four axes — relatedness, popularity, effort, \
592             recency. RANK/FILTER: pass a `preset` (\"outreach\"=relevant+active by \
593             reach, \"peers\"=your stack by effort, \"legends\"=biggest reach any \
594             domain, \"intel\"=on-domain by popularity, \"adopters\"=actual users), or \
595             `rank_by`=relatedness|popularity|effort|recency with filters \
596             (`min_keywords`, `active_since`, `adopters_only`, `stack_only`) and \
597             `top`=N (rank-then-take-N, default 10) for a focused filter→rank→take \
598             view; with none, the full multi-lens browse: \
599             `✅ ADOPTERS` (stargazers whose repos actually declare your package as a \
600             dependency — real users, not just watchers), `★ MOST RELEVANT` \
601             (relatedness — repos matching your topic keywords, with follower counts \
602             and external contributions), `🏆 NOTABLE` (popularity/reach lens — your \
603             highest-traction stargazers, flagged `LEGEND` for big audiences/projects), \
604             `✦ QUALITY` (best-kept maintained projects), `⚙ STACK MATCH` (architectural \
605             peers who build in your stack — co-location-confirmed where possible), and \
606             a cohort inventory. Override the auto-config with `keywords=\"graph,rag,agent\"` \
607             (single words — \"knowledge,graph\" not \"knowledge-graph\") and \
608             `stack=\"Rust,Python\"`; re-calling with new values re-ranks the cached \
609             fetch for free. Treat description-based leads as candidates to verify by \
610             drilling. DRILL via `element_id`: `\"cohort:<key>\"` (established / single / \
611             prolific / casual / dormant / consumers — the overview lists each key), \
612             `\"user:<login>\"` (portfolio), `\"user:<login>/repo:<name>\"` (repo profile), \
613             or `\"user:<login>/repo:<name>/readme\"` (README gist — the only drill that \
614             costs a request). `max_stargazers` samples the most-recent N (the overview \
615             reports if results are partial); `refresh=true` re-fetches.",
616                move |args: ScreenStargazersArgs| {
617                    use crate::screen::{self, Filters, RankBy, Seed, Selection};
618                    let split_csv = |s: Option<String>| -> Vec<String> {
619                        s.map(|v| {
620                            v.split(',')
621                                .map(|t| t.trim().to_string())
622                                .filter(|t| !t.is_empty())
623                                .collect()
624                        })
625                        .unwrap_or_default()
626                    };
627                    // Seed: explicit user list wins; else the repo (or active repo).
628                    let seed = if let Some(u) = &args.users {
629                        Seed::Users(split_csv(Some(u.clone())))
630                    } else {
631                        let repo =
632                            match resolve_repo_from(repo_for_screen.as_ref(), args.repo.clone()) {
633                                Ok(r) => r,
634                                Err(msg) => return msg,
635                            };
636                        if let Some(err) = crate::git_refs::validate_repo(&repo) {
637                            return err;
638                        }
639                        Seed::Repo(repo)
640                    };
641                    let cfg = screen::ScreenConfig {
642                        max_stargazers: args.max_stargazers,
643                        max_repos_per_user: 100,
644                        relevance_keywords: split_csv(args.keywords)
645                            .into_iter()
646                            .map(|k| k.to_lowercase())
647                            .collect(),
648                        stack_languages: split_csv(args.stack),
649                    };
650                    // Selection: preset, else explicit rank/filters, else none.
651                    let top = args.top.unwrap_or(10);
652                    let filters = Filters {
653                        min_keywords: args.min_keywords,
654                        active_since: args.active_since.clone(),
655                        adopters_only: args.adopters_only,
656                        stack_only: args.stack_only,
657                        ..Default::default()
658                    };
659                    let filters_active = filters.min_keywords.is_some()
660                        || filters.active_since.is_some()
661                        || filters.adopters_only
662                        || filters.stack_only;
663                    let selection: Option<Selection> = if let Some(name) = &args.preset {
664                        screen::preset(name, top)
665                    } else if args.rank_by.is_some() || filters_active {
666                        Some(Selection {
667                            filters,
668                            rank: args
669                                .rank_by
670                                .as_deref()
671                                .and_then(RankBy::parse)
672                                .unwrap_or(RankBy::Relatedness),
673                            label: "SELECTION".into(),
674                            take: top,
675                        })
676                    } else {
677                        None
678                    };
679                    screen::screen_dispatch(
680                        &screen_store,
681                        &seed,
682                        &cfg,
683                        selection.as_ref(),
684                        args.element_id.as_deref(),
685                        args.refresh,
686                    )
687                },
688            );
689        }
690    }
691
692    /// Read the manifest-declared `builtins:` config. Downstream
693    /// consumers (e.g. a `graph_overview` tool that wipes a `temp/`
694    /// directory when `temp_cleanup: on_overview` is set) call this
695    /// to discover what flags the operator asked for. The framework
696    /// itself does not act on this — that would force it to interpret
697    /// graph-specific semantics it shouldn't know about.
698    pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
699        &self.options.builtins
700    }
701
702    /// Mutable access to the tool router for dynamic tool registration.
703    ///
704    /// Use only at server-construction time (before [`serve`](rmcp::ServiceExt::serve)).
705    /// Once dispatching starts, the router is cloned per request and
706    /// mutation would race.
707    pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
708        &mut self.tool_router
709    }
710
711    /// Mutable access to the prompt router for dynamic skill / prompt
712    /// registration. Same lifecycle contract as [`tool_router_mut`]:
713    /// boot-time only. Most operators reach prompts via
714    /// [`serve_prompts`] rather than touching the router directly.
715    pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
716        &mut self.prompt_router
717    }
718
719    /// Register a typed dynamic tool. Compresses the boilerplate of:
720    /// 1. Generating a JSON Schema for the args type via `schemars`.
721    /// 2. Building a [`rmcp::model::Tool`] attr from the schema +
722    ///    name + description.
723    /// 3. Deserialising the per-call JSON arguments via serde.
724    /// 4. Wrapping the handler in a [`rmcp::handler::server::router::tool::ToolRoute::new_dyn`]
725    ///    closure suitable for [`tool_router_mut`](Self::tool_router_mut).
726    ///
727    /// The handler is `Fn(T) -> String`; it owns whatever state it
728    /// needs through the closure environment (typically an Arc-clone
729    /// of a domain-specific state handle). Returning a string means
730    /// the tool reports a clean text body to the agent rather than
731    /// exposing a tool-error envelope — matches the framework's
732    /// "errors as values" convention for source / GitHub tools.
733    pub fn register_typed_tool<T, F>(
734        &mut self,
735        name: &'static str,
736        description: &'static str,
737        handler: F,
738    ) where
739        T: for<'de> serde::Deserialize<'de>
740            + schemars::JsonSchema
741            + Default
742            + Send
743            + Sync
744            + 'static,
745        F: Fn(T) -> String + Send + Sync + 'static,
746    {
747        use std::pin::Pin;
748        type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
749
750        let schema_obj = serde_json::to_value(schemars::schema_for!(T))
751            .ok()
752            .and_then(|v| v.as_object().cloned())
753            .unwrap_or_default();
754        let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
755        let handler = std::sync::Arc::new(handler);
756
757        self.tool_router
758            .add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
759                attr,
760                move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
761                    -> DynFut<'_, Result<rmcp::model::CallToolResult, rmcp::ErrorData>> {
762                    let handler = handler.clone();
763                    let arguments = ctx.arguments.clone();
764                    Box::pin(async move {
765                        let args: T = match arguments {
766                            Some(map) => {
767                                match serde_json::from_value(serde_json::Value::Object(map)) {
768                                    Ok(a) => a,
769                                    Err(e) => {
770                                        return Ok(rmcp::model::CallToolResult::success(vec![
771                                            rmcp::model::Content::text(format!(
772                                                "invalid arguments: {e}"
773                                            )),
774                                        ]));
775                                    }
776                                }
777                            }
778                            None => T::default(),
779                        };
780                        let body = handler(args);
781                        Ok(rmcp::model::CallToolResult::success(vec![
782                            rmcp::model::Content::text(body),
783                        ]))
784                    })
785                },
786            ));
787    }
788
789    fn current_source_roots(&self) -> Vec<String> {
790        match &self.options.source_roots {
791            Some(provider) => provider(),
792            None => Vec::new(),
793        }
794    }
795
796    /// Resolve the active repo: per-call override → configured default →
797    /// auto-detect from cwd (last-resort fallback). Returns the resolved
798    /// repo string and an `Err` (formatted user message) if none is found
799    /// or the value is malformed.
800    #[allow(dead_code)]
801    fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
802        resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
803    }
804
805    #[tool(
806        description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
807                          Use to confirm the server framework is wired correctly before \
808                          relying on graph- or source-aware tools."
809    )]
810    async fn ping(
811        &self,
812        Parameters(args): Parameters<PingArgs>,
813    ) -> Result<CallToolResult, McpError> {
814        let body = args.message.unwrap_or_else(|| "pong".to_string());
815        Ok(CallToolResult::success(vec![Content::text(body)]))
816    }
817
818    #[tool(description = "Read a file from the configured source root(s). Pass \
819                       `start_line`/`end_line` to slice, `grep` to filter to matching \
820                       lines, `max_chars` to cap output. Path traversal attempts are \
821                       rejected. Available only when source roots are configured.")]
822    async fn read_source(
823        &self,
824        Parameters(args): Parameters<ReadSourceArgs>,
825    ) -> Result<CallToolResult, McpError> {
826        let roots = self.current_source_roots();
827        if roots.is_empty() {
828            return Ok(CallToolResult::success(vec![Content::text(
829                "Cannot read source: no active source root. Configure source_root in your manifest \
830                 or activate one (e.g. via repo_management in workspace mode).",
831            )]));
832        }
833        let opts = ReadOpts {
834            start_line: args.start_line,
835            end_line: args.end_line,
836            grep: args.grep,
837            grep_context: args.grep_context,
838            max_matches: args.max_matches,
839            max_chars: args.max_chars,
840        };
841        let body = source::read_source(&args.file_path, &roots, &opts);
842        Ok(CallToolResult::success(vec![Content::text(body)]))
843    }
844
845    #[tool(
846        description = "Search source files using ripgrep. `pattern` is a regex (Rust \
847                       syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
848                       N surrounding lines per match. Set `case_insensitive=true` for \
849                       case-insensitive matching. `max_results` caps total matches \
850                       (default 50)."
851    )]
852    async fn grep(
853        &self,
854        Parameters(args): Parameters<GrepArgs>,
855    ) -> Result<CallToolResult, McpError> {
856        let roots = self.current_source_roots();
857        if roots.is_empty() {
858            return Ok(CallToolResult::success(vec![Content::text(
859                "Cannot grep: no active source root. Configure source_root in your manifest \
860                 or activate one (e.g. via repo_management in workspace mode).",
861            )]));
862        }
863        let opts = GrepOpts {
864            glob: args.glob,
865            context: args.context,
866            max_results: Some(args.max_results.unwrap_or(50)),
867            case_insensitive: args.case_insensitive,
868        };
869        let body = source::grep(&roots, &args.pattern, &opts);
870        Ok(CallToolResult::success(vec![Content::text(body)]))
871    }
872
873    #[tool(
874        description = "List directory contents under the configured source root. `path` \
875                       is resolved against the first source root (\".\" lists the root \
876                       itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
877                       `glob` filters entry names. `dirs_only=true` shows only \
878                       directories."
879    )]
880    async fn list_source(
881        &self,
882        Parameters(args): Parameters<ListSourceArgs>,
883    ) -> Result<CallToolResult, McpError> {
884        let roots = self.current_source_roots();
885        if roots.is_empty() {
886            return Ok(CallToolResult::success(vec![Content::text(
887                "Cannot list source: no active source root. Configure source_root in your \
888                 manifest or activate one (e.g. via repo_management in workspace mode).",
889            )]));
890        }
891        let primary = std::path::PathBuf::from(&roots[0]);
892        let target = match resolve_dir_under_roots(&args.path, &roots) {
893            Some(p) => p,
894            None => {
895                return Ok(CallToolResult::success(vec![Content::text(format!(
896                    "Error: path '{}' resolves outside the configured source roots.",
897                    args.path
898                ))]));
899            }
900        };
901        let opts = ListOpts {
902            depth: args.depth,
903            glob: args.glob,
904            dirs_only: args.dirs_only,
905        };
906        let body = source::list_source(&target, &primary, &opts);
907        Ok(CallToolResult::success(vec![Content::text(body)]))
908    }
909
910    #[tool(
911        description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
912                       clone (if missing) and activate it as the source root for \
913                       read_source / grep / list_source. Pass `delete=true` to remove a \
914                       repo. Pass `update=true` to fetch upstream changes for the active \
915                       repo (rebuild auto-skipped when HEAD hasn't moved since the last \
916                       build; set `force_rebuild=true` to bypass). Call with no \
917                       arguments to list all known repos with their last-access counts. \
918                       Idle repos auto-sweep on each call (default 7 days, configurable \
919                       via --stale-after-days)."
920    )]
921    async fn repo_management(
922        &self,
923        Parameters(args): Parameters<RepoManagementArgs>,
924    ) -> Result<CallToolResult, McpError> {
925        let body = match &self.options.workspace {
926            Some(ws) => ws.repo_management(
927                args.name.as_deref(),
928                args.delete,
929                args.update,
930                args.force_rebuild,
931            ),
932            None => "repo_management requires --workspace mode.".to_string(),
933        };
934        Ok(CallToolResult::success(vec![Content::text(body)]))
935    }
936}
937
938/// Resolve `org/repo`: per-call override → configured default →
939/// auto-detect from cwd. Returns either the resolved repo or a
940/// formatted user-facing error message.
941///
942/// Free function (not a method) so it can be called from closures
943/// captured by [`McpServer::register_typed_tool`] which only see
944/// `Fn(T) -> String` — no `&self`.
945fn resolve_repo_from(
946    default_repo: Option<&RepoProvider>,
947    override_repo: Option<String>,
948) -> Result<String, String> {
949    if let Some(r) = override_repo {
950        if let Some(err) = crate::git_refs::validate_repo(&r) {
951            return Err(err);
952        }
953        return Ok(r);
954    }
955    if let Some(provider) = default_repo {
956        if let Some(r) = provider() {
957            if let Some(err) = crate::git_refs::validate_repo(&r) {
958                return Err(err);
959            }
960            return Ok(r);
961        }
962    }
963    if let Some(detected) = crate::github::detect_git_repo(".") {
964        if crate::git_refs::validate_repo(&detected).is_none() {
965            return Ok(detected);
966        }
967    }
968    Err(
969        "No active repository. Pass `repo_name='org/repo'`, configure a default in the \
970         server, or run from a directory whose git remote points at github.com."
971            .to_string(),
972    )
973}
974
975/// Wire a resolved skill registry into a server's `prompts/list` and
976/// `prompts/get` surface, and apply auto-injection hints to tool
977/// descriptions for skills whose name matches a registered tool.
978///
979/// Call at boot time after all tools have been registered (so the
980/// auto-inject pass sees the final tool catalogue) and before
981/// `serve(...)`. Idempotent in spirit but not by construction:
982/// calling twice with the same registry would re-append the hint to
983/// already-injected descriptions, so don't.
984///
985/// The function is additive and a no-op when the registry is empty
986/// — downstream callers can wire it unconditionally without breaking
987/// the zero-skills boot path.
988pub fn serve_prompts(registry: &ResolvedRegistry, server: &mut McpServer) {
989    use std::borrow::Cow;
990    use std::collections::HashSet;
991
992    // Build the framework-internal predicate state once. The tool
993    // router has the full registered-tool list; extensions come from
994    // the manifest's builtins block (operators may have nothing
995    // here, in which case all `extension_enabled:` predicates fail).
996    let registered_tools: HashSet<String> = server
997        .tool_router
998        .list_all()
999        .iter()
1000        .map(|t| t.name.to_string())
1001        .collect();
1002    let extensions = server.options.extensions.clone();
1003
1004    // For the auto-inject pass: skills with `auto_inject_hint` get
1005    // their `description` (routing) and `body` (methodology) embedded
1006    // into the descriptions of their name-match tool AND every tool
1007    // they list in `references_tools`. See the comment at the bottom
1008    // of the function for why this is the content, not a pointer.
1009    struct InjectSkill {
1010        name: String,
1011        description: String,
1012        body: String,
1013        references_tools: Vec<String>,
1014    }
1015    let mut auto_inject: Vec<InjectSkill> = Vec::new();
1016
1017    for name in registry.skill_names() {
1018        let Some(skill) = registry.get(&name) else {
1019            continue;
1020        };
1021
1022        // Evaluate `applies_when:` against the runtime state. Skills
1023        // with all predicates satisfied register; others are
1024        // suppressed from the agent-facing surface.
1025        let activation = registry.activation_for(skill, &registered_tools, &extensions);
1026        if !activation.active {
1027            let failed_clauses: Vec<&str> = activation
1028                .clauses
1029                .iter()
1030                .filter(|(_, outcome)| {
1031                    *outcome != crate::server::skills::PredicateOutcome::Satisfied
1032                })
1033                .map(|(clause, _)| clause.as_str())
1034                .collect();
1035            tracing::info!(
1036                skill = %name,
1037                suppressed_by = ?failed_clauses,
1038                "skill suppressed by applies_when predicates"
1039            );
1040            continue;
1041        }
1042
1043        let prompt = Prompt::new(
1044            skill.name().to_string(),
1045            Some(skill.description().to_string()),
1046            None,
1047        );
1048        let body = skill.body.clone();
1049        let route = PromptRoute::new_dyn(prompt, move |_ctx| {
1050            let body = body.clone();
1051            Box::pin(async move {
1052                Ok(GetPromptResult::new(vec![PromptMessage::new_text(
1053                    PromptMessageRole::Assistant,
1054                    body,
1055                )]))
1056            })
1057        });
1058        server.prompt_router.add_route(route);
1059
1060        if skill.frontmatter.auto_inject_hint {
1061            auto_inject.push(InjectSkill {
1062                name: skill.name().to_string(),
1063                description: skill.description().to_string(),
1064                body: skill.body.clone(),
1065                references_tools: skill.frontmatter.references_tools.clone(),
1066            });
1067        }
1068    }
1069
1070    // Auto-inject the skill's routing + methodology into tool
1071    // descriptions.
1072    //
1073    // Background: pre-0.3.37 this loop appended a short pointer line
1074    // (`See `prompts/get` <name> for the full methodology.`) to the
1075    // tool description, assuming agents could call `prompts/get` to
1076    // fetch the body. **They can't** in real MCP clients — Claude Code,
1077    // Claude Desktop, Cursor, and Continue all expose only `tools/*`
1078    // to the model; the `prompts/` plane was designed for human-
1079    // invoked slash commands. Operators authoring against the pointer
1080    // pattern shipped methodology the agent literally could not read.
1081    //
1082    // The fix, in two parts:
1083    //   * Embed the skill's `description` under a `## When to use`
1084    //     header and its `body` under `## Methodology`. The
1085    //     description carries the TRIGGER/SKIP routing — small by
1086    //     design, so it leads and isn't subject to the body's size
1087    //     caps (4 KB soft / 16 KB hard, enforced at load). An empty
1088    //     description omits the `## When to use` block.
1089    //   * Inject into the skill's name-match tool AND every tool it
1090    //     lists in `references_tools`. This is the only way to express
1091    //     a *cross-tool* skill — one not named after any single tool.
1092    //
1093    // A tool may now carry several skills (its own plus any that
1094    // reference it). Each injection is fenced by a per-skill marker
1095    // (`<!-- mcp-skill:<name> -->`) so the pass stays idempotent per
1096    // (skill, tool) pair: a tool that is both the name-match and a
1097    // `references_tools` entry of the same skill gets one injection,
1098    // and re-running the pass never double-appends.
1099    //
1100    // Operators who want the smaller pointer-only behaviour set
1101    // `auto_inject_hint: false` per skill. `prompts/list` /
1102    // `prompts/get` continue to work for any client that does surface
1103    // them to the agent, plus CLI introspection. This pass just makes
1104    // the *primary* delivery channel a place agents actually look.
1105    for inj in &auto_inject {
1106        // The skill's name-match tool plus every tool it references,
1107        // deduped so a self-reference doesn't queue the same tool twice.
1108        let mut targets: Vec<&str> = Vec::new();
1109        let mut seen: HashSet<&str> = HashSet::new();
1110        for tool in std::iter::once(inj.name.as_str())
1111            .chain(inj.references_tools.iter().map(String::as_str))
1112        {
1113            if seen.insert(tool) {
1114                targets.push(tool);
1115            }
1116        }
1117
1118        // Build the injected block once. Marker first (idempotency
1119        // fence), then the routing, then the methodology body.
1120        let marker = format!("<!-- mcp-skill:{} -->", inj.name);
1121        let mut block = format!("\n\n{marker}");
1122        let description = inj.description.trim();
1123        if !description.is_empty() {
1124            block.push_str("\n\n## When to use\n\n");
1125            block.push_str(description);
1126        }
1127        block.push_str("\n\n## Methodology\n\n");
1128        block.push_str(inj.body.trim());
1129
1130        for tool in targets {
1131            let key = Cow::<'static, str>::Owned(tool.to_string());
1132            let Some(route) = server.tool_router.map.get_mut(&key) else {
1133                continue;
1134            };
1135            // Per-skill idempotency: never inject the same skill twice
1136            // into one tool's description.
1137            if route
1138                .attr
1139                .description
1140                .as_deref()
1141                .is_some_and(|d| d.contains(&marker))
1142            {
1143                continue;
1144            }
1145            let new_desc = match route.attr.description.take() {
1146                Some(existing) => format!("{existing}{block}"),
1147                None => block.trim_start().to_string(),
1148            };
1149            route.attr.description = Some(Cow::Owned(new_desc));
1150        }
1151    }
1152}
1153
1154#[tool_handler(router = self.tool_router)]
1155impl ServerHandler for McpServer {
1156    fn get_info(&self) -> ServerInfo {
1157        let name = self
1158            .options
1159            .name
1160            .clone()
1161            .unwrap_or_else(|| "MCP Server".to_string());
1162        // Only advertise the prompts capability when at least one skill
1163        // is registered. The zero-skills boot path is the existing
1164        // contract and must keep producing capability output that's
1165        // byte-identical to today. ServerCapabilities is `#[non_exhaustive]`
1166        // but its fields are pub, so we mutate after `build()` rather
1167        // than fighting the type-state builder.
1168        let mut caps = ServerCapabilities::builder().enable_tools().build();
1169        if !self.prompt_router.map.is_empty() {
1170            caps.prompts = Some(PromptsCapability::default());
1171        }
1172        let mut info = ServerInfo::new(caps)
1173            .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
1174            .with_protocol_version(ProtocolVersion::V_2024_11_05);
1175        if let Some(text) = &self.options.instructions {
1176            info = info.with_instructions(text.clone());
1177        }
1178        info
1179    }
1180
1181    async fn list_prompts(
1182        &self,
1183        _request: Option<PaginatedRequestParams>,
1184        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
1185    ) -> Result<ListPromptsResult, McpError> {
1186        Ok(ListPromptsResult {
1187            meta: None,
1188            next_cursor: None,
1189            prompts: self.prompt_router.list_all(),
1190        })
1191    }
1192
1193    async fn get_prompt(
1194        &self,
1195        request: GetPromptRequestParams,
1196        context: rmcp::service::RequestContext<rmcp::RoleServer>,
1197    ) -> Result<GetPromptResult, McpError> {
1198        let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
1199            self,
1200            request.name,
1201            request.arguments,
1202            context,
1203        );
1204        self.prompt_router.get_prompt(prompt_context).await
1205    }
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210    use super::*;
1211
1212    #[test]
1213    fn options_from_manifest_uses_name_when_set() {
1214        let opts = ServerOptions::from_manifest(None, "Fallback");
1215        assert_eq!(opts.name.as_deref(), Some("Fallback"));
1216    }
1217
1218    #[test]
1219    fn builtins_exposed_via_server() {
1220        use crate::server::manifest::{BuiltinsConfig, TempCleanup};
1221        let opts = ServerOptions {
1222            builtins: BuiltinsConfig {
1223                save_graph: true,
1224                temp_cleanup: TempCleanup::OnOverview,
1225                ..Default::default()
1226            },
1227            ..ServerOptions::default()
1228        };
1229        let server = McpServer::new(opts);
1230        assert!(server.builtins().save_graph);
1231        assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
1232    }
1233
1234    #[test]
1235    fn server_constructs() {
1236        let _server = McpServer::new(ServerOptions::default());
1237    }
1238
1239    #[test]
1240    fn static_source_roots_provider() {
1241        let opts = ServerOptions::default()
1242            .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
1243        let server = McpServer::new(opts);
1244        assert_eq!(
1245            server.current_source_roots(),
1246            vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
1247        );
1248    }
1249
1250    #[test]
1251    fn no_provider_returns_empty_roots() {
1252        let server = McpServer::new(ServerOptions::default());
1253        assert!(server.current_source_roots().is_empty());
1254    }
1255
1256    #[test]
1257    fn repo_management_gated_to_workspace_mode() {
1258        // Bare (no workspace): repo_management should NOT be in the
1259        // router. Mirrors the gating downstream binaries apply.
1260        let server = McpServer::new(ServerOptions::default());
1261        let tools = server.tool_router.list_all();
1262        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1263        assert!(
1264            !names.contains(&"repo_management"),
1265            "repo_management should be gated out without a workspace; tools were {names:?}"
1266        );
1267    }
1268
1269    #[test]
1270    fn repo_management_present_when_workspace_bound() {
1271        // With a workspace handle bound, repo_management should be
1272        // registered.
1273        use crate::server::workspace::Workspace;
1274        let dir = tempfile::tempdir().unwrap();
1275        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1276        let opts = ServerOptions::default().with_workspace(ws);
1277        let server = McpServer::new(opts);
1278        let tools = server.tool_router.list_all();
1279        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1280        assert!(
1281            names.contains(&"repo_management"),
1282            "repo_management should be registered with a workspace; tools were {names:?}"
1283        );
1284    }
1285
1286    #[test]
1287    fn dynamic_provider_swaps_at_call_time() {
1288        use std::sync::Mutex;
1289        let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
1290        let s2 = state.clone();
1291        let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
1292        let opts = ServerOptions::default().with_dynamic_source_roots(provider);
1293        let server = McpServer::new(opts);
1294        assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
1295        *state.lock().unwrap() = vec!["/swapped".to_string()];
1296        assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
1297    }
1298
1299    // ─── Prompt / skill wiring ────────────────────────────────────
1300
1301    fn build_test_registry(
1302        skills: &[(&str, &str, &str, bool)],
1303    ) -> crate::server::skills::ResolvedRegistry {
1304        use crate::server::skills::Registry;
1305        let dir = tempfile::tempdir().unwrap();
1306        let yaml_path = dir.path().join("manifest.yaml");
1307        let skills_dir = dir.path().join("manifest.skills");
1308        std::fs::create_dir_all(&skills_dir).unwrap();
1309        for (name, description, body, auto_inject) in skills {
1310            let auto = if *auto_inject { "true" } else { "false" };
1311            let content = format!(
1312                "---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
1313            );
1314            std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1315        }
1316        Registry::new()
1317            .auto_detect_project_layer(&yaml_path)
1318            .finalise()
1319            .unwrap()
1320    }
1321
1322    /// Like [`build_test_registry`] but lets each skill declare a
1323    /// `references_tools` list (a YAML inline array, e.g. `[ping]`) so
1324    /// the cross-tool injection path can be exercised. Every skill is
1325    /// `auto_inject_hint: true`.
1326    fn build_registry_with_refs(
1327        skills: &[(&str, &str, &str, &str)],
1328    ) -> crate::server::skills::ResolvedRegistry {
1329        use crate::server::skills::Registry;
1330        let dir = tempfile::tempdir().unwrap();
1331        let yaml_path = dir.path().join("manifest.yaml");
1332        let skills_dir = dir.path().join("manifest.skills");
1333        std::fs::create_dir_all(&skills_dir).unwrap();
1334        for (name, description, body, references_tools) in skills {
1335            let content = format!(
1336                "---\nname: {name}\ndescription: {description}\n\
1337                 auto_inject_hint: true\nreferences_tools: {references_tools}\n---\n\n{body}\n"
1338            );
1339            std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1340        }
1341        Registry::new()
1342            .auto_detect_project_layer(&yaml_path)
1343            .finalise()
1344            .unwrap()
1345    }
1346
1347    fn tool_desc(server: &McpServer, tool: &str) -> String {
1348        server
1349            .tool_router
1350            .get(tool)
1351            .and_then(|t| t.description.clone())
1352            .map(|c| c.into_owned())
1353            .unwrap_or_default()
1354    }
1355
1356    #[test]
1357    fn prompt_router_empty_by_default() {
1358        let server = McpServer::new(ServerOptions::default());
1359        assert!(server.prompt_router.map.is_empty());
1360    }
1361
1362    #[test]
1363    fn get_info_no_prompts_capability_when_empty() {
1364        // Zero-impact invariant: a server with no skills must not
1365        // advertise the prompts capability. kglite's existing
1366        // deployment depends on this byte-for-byte.
1367        let server = McpServer::new(ServerOptions::default());
1368        let info = server.get_info();
1369        assert!(
1370            info.capabilities.prompts.is_none(),
1371            "prompts capability must be absent when no skills are registered"
1372        );
1373    }
1374
1375    #[test]
1376    fn serve_prompts_registers_routes_with_metadata() {
1377        let registry = build_test_registry(&[
1378            ("alpha", "First skill.", "Alpha body.", true),
1379            ("beta", "Second skill.", "Beta body.", true),
1380        ]);
1381        let mut server = McpServer::new(ServerOptions::default());
1382        super::serve_prompts(&registry, &mut server);
1383
1384        let prompts = server.prompt_router.list_all();
1385        let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
1386        assert_eq!(names, vec!["alpha", "beta"]);
1387
1388        let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
1389        assert_eq!(alpha.description.as_deref(), Some("First skill."));
1390        assert!(alpha.arguments.is_none());
1391    }
1392
1393    #[test]
1394    fn serve_prompts_empty_registry_is_noop() {
1395        let registry = crate::server::skills::ResolvedRegistry::default();
1396        let mut server = McpServer::new(ServerOptions::default());
1397        super::serve_prompts(&registry, &mut server);
1398        assert!(server.prompt_router.map.is_empty());
1399        assert!(server.get_info().capabilities.prompts.is_none());
1400    }
1401
1402    #[test]
1403    fn get_info_advertises_prompts_when_present() {
1404        let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
1405        let mut server = McpServer::new(ServerOptions::default());
1406        super::serve_prompts(&registry, &mut server);
1407        let info = server.get_info();
1408        assert!(
1409            info.capabilities.prompts.is_some(),
1410            "prompts capability must be advertised once a skill is registered"
1411        );
1412    }
1413
1414    #[test]
1415    fn serve_prompts_auto_injects_full_body_into_matching_tool() {
1416        // `ping` is registered by every server. A skill named `ping`
1417        // with `auto_inject_hint: true` should embed its full body
1418        // under a `## Methodology` header in the ping tool's
1419        // description. Pre-0.3.37 this appended a short pointer at
1420        // `prompts/get`, but agents in real MCP clients can't reach
1421        // that surface — see the comment on the auto-inject loop in
1422        // `serve_prompts`.
1423        let registry =
1424            build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
1425        let mut server = McpServer::new(ServerOptions::default());
1426        let before = server
1427            .tool_router
1428            .get("ping")
1429            .and_then(|t| t.description.clone())
1430            .map(|c| c.into_owned())
1431            .unwrap_or_default();
1432        super::serve_prompts(&registry, &mut server);
1433        let after = server
1434            .tool_router
1435            .get("ping")
1436            .and_then(|t| t.description.clone())
1437            .map(|c| c.into_owned())
1438            .unwrap_or_default();
1439        assert!(after.starts_with(&before), "original description preserved");
1440        assert!(
1441            after.contains("## Methodology"),
1442            "inject should include a Methodology header; got: {after}"
1443        );
1444        assert!(
1445            after.contains("PING-BODY-SENTINEL"),
1446            "inject should embed the full skill body; got: {after}"
1447        );
1448        assert!(
1449            !after.contains("prompts/get"),
1450            "post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
1451        );
1452    }
1453
1454    #[test]
1455    fn serve_prompts_skips_injection_when_disabled() {
1456        let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
1457        let mut server = McpServer::new(ServerOptions::default());
1458        let before = server
1459            .tool_router
1460            .get("ping")
1461            .and_then(|t| t.description.clone())
1462            .map(|c| c.into_owned())
1463            .unwrap_or_default();
1464        super::serve_prompts(&registry, &mut server);
1465        let after = server
1466            .tool_router
1467            .get("ping")
1468            .and_then(|t| t.description.clone())
1469            .map(|c| c.into_owned())
1470            .unwrap_or_default();
1471        assert_eq!(
1472            before, after,
1473            "auto_inject_hint=false must leave tool description untouched"
1474        );
1475    }
1476
1477    #[test]
1478    fn serve_prompts_skips_injection_when_no_matching_tool() {
1479        // Skill name doesn't match any registered tool; nothing to
1480        // inject into, but the prompt route is still added.
1481        let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
1482        let mut server = McpServer::new(ServerOptions::default());
1483        super::serve_prompts(&registry, &mut server);
1484        assert!(server.prompt_router.map.contains_key("no_such_tool"));
1485        // No panic, no mutation of unrelated tools — the ping tool's
1486        // description is unchanged.
1487        let ping_desc = server
1488            .tool_router
1489            .get("ping")
1490            .and_then(|t| t.description.clone())
1491            .map(|c| c.into_owned())
1492            .unwrap_or_default();
1493        assert!(!ping_desc.contains("no_such_tool"));
1494    }
1495
1496    #[test]
1497    fn serve_prompts_injects_description_under_when_to_use() {
1498        // The skill's `description` carries the TRIGGER/SKIP routing —
1499        // it must reach the live tool-description channel under a
1500        // `## When to use` header, ahead of the methodology body.
1501        let registry = build_test_registry(&[("ping", "ROUTING-SENTINEL", "BODY-SENTINEL", true)]);
1502        let mut server = McpServer::new(ServerOptions::default());
1503        super::serve_prompts(&registry, &mut server);
1504        let desc = tool_desc(&server, "ping");
1505        assert!(
1506            desc.contains("## When to use\n\nROUTING-SENTINEL"),
1507            "description should be injected under `## When to use`; got: {desc}"
1508        );
1509        assert!(
1510            desc.contains("<!-- mcp-skill:ping -->"),
1511            "injection should carry the per-skill idempotency marker; got: {desc}"
1512        );
1513        // Routing leads, methodology follows.
1514        let when = desc.find("## When to use").unwrap();
1515        let method = desc.find("## Methodology").unwrap();
1516        assert!(when < method, "`When to use` must precede `Methodology`");
1517    }
1518
1519    #[test]
1520    fn serve_prompts_honors_references_tools() {
1521        // A cross-tool skill named after no tool injects into every
1522        // tool it lists in `references_tools`. `ping` is always
1523        // registered; the skill name (`graph_strategy`) is not a tool.
1524        let registry = build_registry_with_refs(&[(
1525            "graph_strategy",
1526            "Map structure first.",
1527            "GRAPH-BODY-SENTINEL",
1528            "[ping]",
1529        )]);
1530        let mut server = McpServer::new(ServerOptions::default());
1531        super::serve_prompts(&registry, &mut server);
1532        // The prompt route still registers under the skill name.
1533        assert!(server.prompt_router.map.contains_key("graph_strategy"));
1534        // ...and the referenced tool carries the full injection.
1535        let desc = tool_desc(&server, "ping");
1536        assert!(
1537            desc.contains("<!-- mcp-skill:graph_strategy -->"),
1538            "referenced tool should carry the skill marker; got: {desc}"
1539        );
1540        assert!(
1541            desc.contains("Map structure first."),
1542            "referenced tool should carry the skill routing; got: {desc}"
1543        );
1544        assert!(
1545            desc.contains("GRAPH-BODY-SENTINEL"),
1546            "referenced tool should carry the skill body; got: {desc}"
1547        );
1548    }
1549
1550    #[test]
1551    fn serve_prompts_idempotent_when_skill_self_references() {
1552        // A skill named after its own tool that also lists that tool in
1553        // `references_tools` must inject exactly once — the dedup of
1554        // the target set plus the per-skill marker keep the pass clean.
1555        let registry = build_registry_with_refs(&[("ping", "Routing.", "Body.", "[ping]")]);
1556        let mut server = McpServer::new(ServerOptions::default());
1557        super::serve_prompts(&registry, &mut server);
1558        let desc = tool_desc(&server, "ping");
1559        let marker_count = desc.matches("<!-- mcp-skill:ping -->").count();
1560        assert_eq!(
1561            marker_count, 1,
1562            "self-referencing skill must inject exactly once; got {marker_count}: {desc}"
1563        );
1564    }
1565
1566    #[test]
1567    fn serve_prompts_idempotent_across_repeated_passes() {
1568        // Re-running the pass over the same server must not double-
1569        // append: the per-skill marker fences each (skill, tool) pair.
1570        let registry = build_test_registry(&[("ping", "Routing.", "Body.", true)]);
1571        let mut server = McpServer::new(ServerOptions::default());
1572        super::serve_prompts(&registry, &mut server);
1573        let once = tool_desc(&server, "ping");
1574        super::serve_prompts(&registry, &mut server);
1575        let twice = tool_desc(&server, "ping");
1576        assert_eq!(
1577            once, twice,
1578            "second pass must be a no-op for an already-injected tool"
1579        );
1580    }
1581
1582    #[test]
1583    fn serve_prompts_multiple_skills_stack_on_one_tool() {
1584        // A tool can carry its own name-match skill plus a referencing
1585        // cross-tool skill — both injections coexist, each fenced by
1586        // its own marker.
1587        let registry = build_registry_with_refs(&[
1588            ("ping", "Ping routing.", "PING-BODY", "[]"),
1589            ("ping_strategy", "Strategy routing.", "STRAT-BODY", "[ping]"),
1590        ]);
1591        let mut server = McpServer::new(ServerOptions::default());
1592        super::serve_prompts(&registry, &mut server);
1593        let desc = tool_desc(&server, "ping");
1594        assert!(desc.contains("<!-- mcp-skill:ping -->"), "got: {desc}");
1595        assert!(
1596            desc.contains("<!-- mcp-skill:ping_strategy -->"),
1597            "got: {desc}"
1598        );
1599        assert!(
1600            desc.contains("PING-BODY") && desc.contains("STRAT-BODY"),
1601            "got: {desc}"
1602        );
1603    }
1604
1605    fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
1606        let dir = tempfile::tempdir().unwrap();
1607        let yaml = dir.path().join("test_mcp.yaml");
1608        std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1609        let skills_dir = dir.path().join("test_mcp.skills");
1610        std::fs::create_dir(&skills_dir).unwrap();
1611        std::fs::write(
1612            skills_dir.join("gated_skill.md"),
1613            format!(
1614                "---\n\
1615                 name: gated_skill\n\
1616                 description: A predicate-gated skill for testing.\n\
1617                 applies_when:\n\
1618                 {applies_when_yaml}\n\
1619                 ---\n\n\
1620                 Body.\n",
1621            ),
1622        )
1623        .unwrap();
1624        dir
1625    }
1626
1627    #[test]
1628    fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
1629        // `tool_registered: nonexistent_tool` — that tool isn't in
1630        // the registered catalogue, so the predicate fails and the
1631        // skill is omitted from `prompts/list`.
1632        use crate::server::skills::Registry as SkillsBuilder;
1633        let dir = write_gated_project_skill("  tool_registered: nonexistent_tool");
1634        let yaml = dir.path().join("test_mcp.yaml");
1635        let registry = SkillsBuilder::new()
1636            .auto_detect_project_layer(&yaml)
1637            .finalise()
1638            .unwrap();
1639        let mut server = McpServer::new(ServerOptions::default());
1640        super::serve_prompts(&registry, &mut server);
1641        assert!(
1642            !server.prompt_router.map.contains_key("gated_skill"),
1643            "skill with unsatisfied predicate must be suppressed"
1644        );
1645    }
1646
1647    #[test]
1648    fn serve_prompts_keeps_skill_with_satisfied_predicate() {
1649        // `tool_registered: ping` — ping is always registered, so
1650        // the predicate satisfies and the skill registers.
1651        use crate::server::skills::Registry as SkillsBuilder;
1652        let dir = write_gated_project_skill("  tool_registered: ping");
1653        let yaml = dir.path().join("test_mcp.yaml");
1654        let registry = SkillsBuilder::new()
1655            .auto_detect_project_layer(&yaml)
1656            .finalise()
1657            .unwrap();
1658        let mut server = McpServer::new(ServerOptions::default());
1659        super::serve_prompts(&registry, &mut server);
1660        assert!(
1661            server.prompt_router.map.contains_key("gated_skill"),
1662            "skill with satisfied predicate must register"
1663        );
1664    }
1665
1666    #[test]
1667    fn serve_prompts_evaluates_extension_enabled_from_manifest() {
1668        // The `extension_enabled:` predicate reads from
1669        // `ServerOptions.extensions`. Verify it integrates end-to-end
1670        // when the manifest declares the extension.
1671        use crate::server::skills::Registry as SkillsBuilder;
1672        let dir = write_gated_project_skill("  extension_enabled: csv_http_server");
1673        let yaml = dir.path().join("test_mcp.yaml");
1674        let registry = SkillsBuilder::new()
1675            .auto_detect_project_layer(&yaml)
1676            .finalise()
1677            .unwrap();
1678
1679        // Without the extension declared — suppressed.
1680        let mut server = McpServer::new(ServerOptions::default());
1681        super::serve_prompts(&registry, &mut server);
1682        assert!(!server.prompt_router.map.contains_key("gated_skill"));
1683
1684        // With the extension declared — registers.
1685        let mut extensions = serde_json::Map::new();
1686        extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1687        let opts = ServerOptions {
1688            extensions,
1689            ..ServerOptions::default()
1690        };
1691        let mut server = McpServer::new(opts);
1692        super::serve_prompts(&registry, &mut server);
1693        assert!(server.prompt_router.map.contains_key("gated_skill"));
1694    }
1695}