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