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::tool::ToolRouter;
37use rmcp::handler::server::wrapper::Parameters;
38use rmcp::model::*;
39use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
40use serde::{Deserialize, Serialize};
41
42use crate::server::manifest::Manifest;
43use crate::server::source::{
44    self, resolve_dir_under_roots, GrepOpts, ListOpts, ReadOpts, SourceRootsProvider,
45};
46
47/// Provider returning the active GitHub repo (e.g. `"pydata/xarray"`)
48/// or `None` when nothing is bound. Workspace mode wires this to the
49/// active workspace repo; single-graph mode can pin a fixed value.
50pub type RepoProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
51
52/// Per-server runtime state shared by every tool dispatch.
53#[derive(Clone, Default)]
54pub struct ServerOptions {
55    /// Server display name surfaced via initialize.
56    pub name: Option<String>,
57    /// Free-form text shown to the agent at session start.
58    pub instructions: Option<String>,
59    /// Dynamic provider returning the active source roots, if any.
60    /// `None` disables the source tools entirely.
61    pub source_roots: Option<SourceRootsProvider>,
62    /// Dynamic provider returning the active GitHub repo (org/repo).
63    /// When `None`, github tools require a per-call `repo_name=` arg.
64    pub default_repo: Option<RepoProvider>,
65    /// Workspace handle (when `--workspace` mode is active).
66    pub workspace: Option<crate::server::workspace::Workspace>,
67    /// Manifest-declared `builtins:` block. Surfaced verbatim so
68    /// downstream consumers (kglite's `graph_overview` tool, for
69    /// example) can read `temp_cleanup` / `save_graph` settings and
70    /// implement the corresponding behaviour without re-parsing YAML.
71    pub builtins: crate::server::manifest::BuiltinsConfig,
72}
73
74impl std::fmt::Debug for ServerOptions {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("ServerOptions")
77            .field("name", &self.name)
78            .field("instructions", &self.instructions)
79            .field(
80                "source_roots",
81                &self.source_roots.as_ref().map(|_| "<provider>"),
82            )
83            .field(
84                "default_repo",
85                &self.default_repo.as_ref().map(|_| "<provider>"),
86            )
87            .finish()
88    }
89}
90
91impl ServerOptions {
92    pub fn from_manifest(manifest: Option<&Manifest>, fallback_name: &str) -> Self {
93        Self {
94            name: manifest
95                .and_then(|m| m.name.clone())
96                .or_else(|| Some(fallback_name.to_string())),
97            instructions: manifest.and_then(|m| m.instructions.clone()),
98            source_roots: None,
99            default_repo: None,
100            workspace: None,
101            builtins: manifest.map(|m| m.builtins.clone()).unwrap_or_default(),
102        }
103    }
104
105    pub fn with_static_source_roots(mut self, roots: Vec<String>) -> Self {
106        let captured = Arc::new(roots);
107        self.source_roots = Some(Arc::new(move || captured.as_ref().clone()));
108        self
109    }
110
111    pub fn with_dynamic_source_roots(mut self, provider: SourceRootsProvider) -> Self {
112        self.source_roots = Some(provider);
113        self
114    }
115
116    pub fn with_static_repo(mut self, repo: String) -> Self {
117        self.default_repo = Some(Arc::new(move || Some(repo.clone())));
118        self
119    }
120
121    pub fn with_dynamic_repo(mut self, provider: RepoProvider) -> Self {
122        self.default_repo = Some(provider);
123        self
124    }
125
126    /// Bind a workspace handle. Source roots and default repo become
127    /// dynamic — both are read from the workspace's active-repo state
128    /// at every tool call, so `repo_management` swapping the active
129    /// repo immediately re-points the source tools.
130    pub fn with_workspace(mut self, ws: crate::server::workspace::Workspace) -> Self {
131        let ws_for_roots = ws.clone();
132        let ws_for_repo = ws.clone();
133        self.workspace = Some(ws);
134        self.source_roots = Some(Arc::new(move || {
135            ws_for_roots
136                .active_repo_path()
137                .map(|p| vec![p.to_string_lossy().into_owned()])
138                .unwrap_or_default()
139        }));
140        self.default_repo = Some(Arc::new(move || ws_for_repo.active_repo_name()));
141        self
142    }
143}
144
145#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
146pub struct PingArgs {
147    /// Optional message to echo back. Defaults to "pong".
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub message: Option<String>,
150}
151
152#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
153pub struct ReadSourceArgs {
154    /// File path relative to the configured source root(s).
155    pub file_path: String,
156    /// Start line (1-indexed). Defaults to start-of-file.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub start_line: Option<usize>,
159    /// End line (1-indexed, inclusive). Defaults to end-of-file.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub end_line: Option<usize>,
162    /// Regex pattern to filter lines. Returns matching lines plus context.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub grep: Option<String>,
165    /// Lines of context around each grep match (default 2).
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub grep_context: Option<usize>,
168    /// Cap the number of matches returned.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub max_matches: Option<usize>,
171    /// Cap output size in characters.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub max_chars: Option<usize>,
174}
175
176#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
177pub struct GrepArgs {
178    /// Regex pattern (Rust regex syntax).
179    pub pattern: String,
180    /// File-name glob (e.g. ``"*.py"``). Defaults to all files.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub glob: Option<String>,
183    /// Lines of context around each match (default 0).
184    #[serde(default)]
185    pub context: usize,
186    /// Cap the number of matches (default 50; pass null/None for unlimited).
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub max_results: Option<usize>,
189    /// Case-insensitive matching.
190    #[serde(default)]
191    pub case_insensitive: bool,
192}
193
194#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
195pub struct SetRootDirArgs {
196    /// Absolute or relative path to bind as the new source root.
197    pub path: String,
198}
199
200#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
201pub struct RepoManagementArgs {
202    /// org/repo to clone and activate. Omit for list mode.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub name: Option<String>,
205    /// Delete the repo + inventory entry instead of activating.
206    #[serde(default)]
207    pub delete: bool,
208    /// Refresh the active repo (no name required).
209    #[serde(default)]
210    pub update: bool,
211    /// Bypass the auto-rebuild gate: re-run the post-activate hook
212    /// even when the HEAD SHA matches the last successful build.
213    /// Useful after upgrading the builder code itself.
214    #[serde(default)]
215    pub force_rebuild: bool,
216}
217
218#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
219pub struct GithubIssuesArgs {
220    /// GitHub issue / PR / Discussion number (FETCH mode).
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub number: Option<u64>,
223    /// org/repo override; defaults to the active server repo.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub repo_name: Option<String>,
226    /// Free-text query (SEARCH mode). When set, `number` is ignored.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub query: Option<String>,
229    /// "issue" | "pr" | "discussion" | "all" (default).
230    #[serde(default = "default_kind")]
231    pub kind: String,
232    /// "open" (default) | "closed" | "all".
233    #[serde(default = "default_state")]
234    pub state: String,
235    /// Sort key. Default "created" for list mode, relevance for search.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub sort: Option<String>,
238    /// Max results to return (default 20).
239    #[serde(default = "default_limit")]
240    pub limit: usize,
241    /// Comma-separated label filter (e.g. "bug,P0").
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub labels: Option<String>,
244    /// Drill-down: cached collapsed-element ID returned by a previous
245    /// FETCH (e.g. ``"cb_1"``, ``"comment_3"``, ``"overflow"``). When
246    /// set, `number` is required and the call returns the cached
247    /// element instead of re-fetching.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub element_id: Option<String>,
250    /// Line range filter for drill-down (``"N-M"`` 1-indexed). Only
251    /// meaningful alongside `element_id`. For comment segments,
252    /// interpreted as comment-index range.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub lines: Option<String>,
255    /// Regex pattern for drill-down. Only meaningful alongside
256    /// `element_id`. Returns matching lines/items plus context.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub grep: Option<String>,
259    /// Context lines around each grep match in drill-down mode
260    /// (default 3).
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub context: Option<usize>,
263    /// Force a re-fetch (skip cache) when in FETCH mode. Useful after
264    /// an issue has been updated upstream.
265    #[serde(default)]
266    pub refresh: bool,
267}
268
269fn default_kind() -> String {
270    "all".to_string()
271}
272fn default_state() -> String {
273    "open".to_string()
274}
275fn default_limit() -> usize {
276    20
277}
278
279impl Default for GithubIssuesArgs {
280    fn default() -> Self {
281        Self {
282            number: None,
283            repo_name: None,
284            query: None,
285            kind: default_kind(),
286            state: default_state(),
287            sort: None,
288            limit: default_limit(),
289            labels: None,
290            element_id: None,
291            lines: None,
292            grep: None,
293            context: None,
294            refresh: false,
295        }
296    }
297}
298
299#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
300pub struct GithubApiArgs {
301    /// API path. Relative paths (e.g. "pulls?state=open", "commits/abc",
302    /// "branches", "compare/main...x") are prefixed with /repos/<repo_name>/.
303    /// Absolute resources ("search/issues?q=...", "users/octocat") pass through.
304    pub path: String,
305    /// org/repo override; defaults to the active server repo.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub repo_name: Option<String>,
308    /// Truncate response body at N chars (default 80,000).
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub truncate_at: Option<usize>,
311}
312
313#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
314pub struct ListSourceArgs {
315    /// Subdirectory relative to the source root (default ``"."``).
316    #[serde(default = "default_path")]
317    pub path: String,
318    /// Recursion depth (1 = flat ls; 2+ = tree).
319    #[serde(default = "default_depth")]
320    pub depth: usize,
321    /// Glob filter for entry names.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub glob: Option<String>,
324    /// Show only directories.
325    #[serde(default)]
326    pub dirs_only: bool,
327}
328
329fn default_path() -> String {
330    ".".to_string()
331}
332fn default_depth() -> usize {
333    1
334}
335
336/// MCP server backed by the rmcp framework.
337///
338/// The struct is cloned per request by rmcp's handler dispatch; the
339/// expensive bits (provider closure) are behind an Arc so cloning is cheap.
340#[derive(Clone)]
341pub struct McpServer {
342    options: ServerOptions,
343    tool_router: ToolRouter<McpServer>,
344}
345
346#[tool_router]
347impl McpServer {
348    pub fn new(options: ServerOptions) -> Self {
349        let mut server = Self {
350            options,
351            tool_router: Self::tool_router(),
352        };
353        server.register_github_tools_if_authorized();
354        server.register_local_workspace_tools();
355        server.gate_workspace_tools();
356        server
357    }
358
359    /// Drop `repo_management` from the router when no workspace is
360    /// bound — `tools/list` should reflect the actual surface, not a
361    /// tool whose handler immediately errors out with "requires
362    /// --workspace mode." Mirrors the gating downstream binaries
363    /// (e.g. `kglite-mcp-server`) apply to the same tool. Operators
364    /// comparing the bare framework against a downstream binary's
365    /// surface see consistent behaviour now.
366    fn gate_workspace_tools(&mut self) {
367        if self.options.workspace.is_none() {
368            self.tool_router.remove_route("repo_management");
369        }
370    }
371
372    /// Register `set_root_dir` when the bound workspace is local-flavoured.
373    /// Github workspaces use `repo_management(name='org/repo')` to swap
374    /// roots; local workspaces need this alternative entry point.
375    fn register_local_workspace_tools(&mut self) {
376        let Some(ws) = self.options.workspace.clone() else {
377            return;
378        };
379        if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
380            return;
381        }
382        self.register_typed_tool::<SetRootDirArgs, _>(
383            "set_root_dir",
384            "Swap the active source root (local-workspace mode only). Pass `path` \
385             to a directory; the framework canonicalises it, rebinds the source \
386             tools (`read_source`, `grep`, `list_source`), and fires the post-\
387             activate hook so any downstream graph rebuilds against the new root. \
388             Inventory persists across swaps; SHA-gating skips rebuilds when the \
389             same root is re-bound with no content changes.",
390            move |args: SetRootDirArgs| {
391                let p = std::path::PathBuf::from(&args.path);
392                ws.set_root_dir(&p)
393            },
394        );
395    }
396
397    /// Register `github_issues` + `github_api` as dynamic tools — but
398    /// only when a GitHub token is reachable. This is honest tool
399    /// listing: agents see the tool only if it can actually succeed.
400    /// Decision is boot-time; restart the server to pick up a token
401    /// that appears later.
402    fn register_github_tools_if_authorized(&mut self) {
403        if !crate::github::has_git_token() {
404            tracing::info!(
405                "GITHUB_TOKEN not set — github_issues / github_api tools hidden from the agent. \
406                 Set the env var and restart to enable them."
407            );
408            return;
409        }
410        let default_repo = self.options.default_repo.clone();
411        let repo_provider = default_repo.clone();
412        // Per-server ElementCache: stores collapsed elements (cb_1,
413        // patch_2, comment_3, overflow) emitted by FETCH so the agent
414        // can drill down via `element_id` on subsequent calls without
415        // re-fetching the whole issue. Mutex contention is negligible
416        // for MCP's serial request dispatch.
417        let cache: Arc<Mutex<crate::cache::ElementCache>> =
418            Arc::new(Mutex::new(crate::cache::ElementCache::new()));
419        let cache_for_issues = cache.clone();
420        self.register_typed_tool::<GithubIssuesArgs, _>(
421            "github_issues",
422            "Search, list, or fetch GitHub issues / pull requests / Discussions. \
423             Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
424             for SEARCH (across issues+PRs and Discussions); neither for LIST. \
425             `kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
426             `state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
427             result count (default 20). `labels` is a comma-separated string. \
428             `repo_name=\"org/repo\"` overrides the active repo for one call. \
429             FETCH responses collapse big code blocks / patches / comments into \
430             `cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
431             `element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
432             element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
433             `refresh=true` bypasses the cache for re-fetch.",
434            move |args: GithubIssuesArgs| {
435                let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
436                    Ok(r) => r,
437                    Err(msg) => return msg,
438                };
439                // FETCH / drill-down: route through ElementCache so cb_*,
440                // patch_*, overflow stays addressable. Cache.fetch_issue
441                // does both the network fetch and the drill-down branch.
442                // All paths return a status `String` — invalid-repo,
443                // fetch-failure, cached-summary, overflow, full-text.
444                if let Some(number) = args.number {
445                    let context = args.context.unwrap_or(3);
446                    let mut guard = cache_for_issues.lock().unwrap();
447                    return guard.fetch_issue(
448                        &repo,
449                        number,
450                        args.element_id.as_deref(),
451                        args.lines.as_deref(),
452                        args.grep.as_deref(),
453                        context,
454                        args.refresh,
455                    );
456                }
457                if args.element_id.is_some() {
458                    return "element_id requires `number=N` (the issue/PR being drilled into)."
459                        .to_string();
460                }
461                // SEARCH / LIST: no caching, pure delegation.
462                crate::github::github_issues_rust(
463                    Some(&repo),
464                    args.number,
465                    args.query.as_deref(),
466                    &args.kind,
467                    &args.state,
468                    args.sort.as_deref(),
469                    args.limit,
470                    args.labels.as_deref(),
471                )
472            },
473        );
474        let repo_provider = default_repo;
475        self.register_typed_tool::<GithubApiArgs, _>(
476            "github_api",
477            "Read-only GET against the GitHub REST API. `path` may be a \
478             repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
479             \"branches\", \"compare/main...feature\") which is auto-prefixed \
480             with /repos/<repo_name>/, or an absolute resource (\"search/issues?q=...\", \
481             \"users/octocat\") which passes through. Returns JSON, truncated at \
482             80 KB by default.",
483            move |args: GithubApiArgs| match resolve_repo_from(
484                repo_provider.as_ref(),
485                args.repo_name.clone(),
486            ) {
487                Ok(repo) => {
488                    let truncate_at = args.truncate_at.unwrap_or(80_000);
489                    crate::github::git_api_internal(&repo, &args.path, truncate_at)
490                }
491                Err(msg) => msg,
492            },
493        );
494    }
495
496    /// Read the manifest-declared `builtins:` config. Downstream
497    /// consumers (e.g. a `graph_overview` tool that wipes a `temp/`
498    /// directory when `temp_cleanup: on_overview` is set) call this
499    /// to discover what flags the operator asked for. The framework
500    /// itself does not act on this — that would force it to interpret
501    /// graph-specific semantics it shouldn't know about.
502    pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
503        &self.options.builtins
504    }
505
506    /// Mutable access to the tool router for dynamic tool registration.
507    ///
508    /// Use only at server-construction time (before [`serve`](rmcp::ServiceExt::serve)).
509    /// Once dispatching starts, the router is cloned per request and
510    /// mutation would race.
511    pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
512        &mut self.tool_router
513    }
514
515    /// Register a typed dynamic tool. Compresses the boilerplate of:
516    /// 1. Generating a JSON Schema for the args type via `schemars`.
517    /// 2. Building a [`rmcp::model::Tool`] attr from the schema +
518    ///    name + description.
519    /// 3. Deserialising the per-call JSON arguments via serde.
520    /// 4. Wrapping the handler in a [`rmcp::handler::server::router::tool::ToolRoute::new_dyn`]
521    ///    closure suitable for [`tool_router_mut`](Self::tool_router_mut).
522    ///
523    /// The handler is `Fn(T) -> String`; it owns whatever state it
524    /// needs through the closure environment (typically an Arc-clone
525    /// of a domain-specific state handle). Returning a string means
526    /// the tool reports a clean text body to the agent rather than
527    /// exposing a tool-error envelope — matches the framework's
528    /// "errors as values" convention for source / GitHub tools.
529    pub fn register_typed_tool<T, F>(
530        &mut self,
531        name: &'static str,
532        description: &'static str,
533        handler: F,
534    ) where
535        T: for<'de> serde::Deserialize<'de>
536            + schemars::JsonSchema
537            + Default
538            + Send
539            + Sync
540            + 'static,
541        F: Fn(T) -> String + Send + Sync + 'static,
542    {
543        use std::pin::Pin;
544        type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
545
546        let schema_obj = serde_json::to_value(schemars::schema_for!(T))
547            .ok()
548            .and_then(|v| v.as_object().cloned())
549            .unwrap_or_default();
550        let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
551        let handler = std::sync::Arc::new(handler);
552
553        self.tool_router
554            .add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
555                attr,
556                move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
557                    -> DynFut<'_, Result<rmcp::model::CallToolResult, rmcp::ErrorData>> {
558                    let handler = handler.clone();
559                    let arguments = ctx.arguments.clone();
560                    Box::pin(async move {
561                        let args: T = match arguments {
562                            Some(map) => {
563                                match serde_json::from_value(serde_json::Value::Object(map)) {
564                                    Ok(a) => a,
565                                    Err(e) => {
566                                        return Ok(rmcp::model::CallToolResult::success(vec![
567                                            rmcp::model::Content::text(format!(
568                                                "invalid arguments: {e}"
569                                            )),
570                                        ]));
571                                    }
572                                }
573                            }
574                            None => T::default(),
575                        };
576                        let body = handler(args);
577                        Ok(rmcp::model::CallToolResult::success(vec![
578                            rmcp::model::Content::text(body),
579                        ]))
580                    })
581                },
582            ));
583    }
584
585    fn current_source_roots(&self) -> Vec<String> {
586        match &self.options.source_roots {
587            Some(provider) => provider(),
588            None => Vec::new(),
589        }
590    }
591
592    /// Resolve the active repo: per-call override → configured default →
593    /// auto-detect from cwd (last-resort fallback). Returns the resolved
594    /// repo string and an `Err` (formatted user message) if none is found
595    /// or the value is malformed.
596    #[allow(dead_code)]
597    fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
598        resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
599    }
600
601    #[tool(
602        description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
603                          Use to confirm the server framework is wired correctly before \
604                          relying on graph- or source-aware tools."
605    )]
606    async fn ping(
607        &self,
608        Parameters(args): Parameters<PingArgs>,
609    ) -> Result<CallToolResult, McpError> {
610        let body = args.message.unwrap_or_else(|| "pong".to_string());
611        Ok(CallToolResult::success(vec![Content::text(body)]))
612    }
613
614    #[tool(description = "Read a file from the configured source root(s). Pass \
615                       `start_line`/`end_line` to slice, `grep` to filter to matching \
616                       lines, `max_chars` to cap output. Path traversal attempts are \
617                       rejected. Available only when source roots are configured.")]
618    async fn read_source(
619        &self,
620        Parameters(args): Parameters<ReadSourceArgs>,
621    ) -> Result<CallToolResult, McpError> {
622        let roots = self.current_source_roots();
623        if roots.is_empty() {
624            return Ok(CallToolResult::success(vec![Content::text(
625                "Cannot read source: no active source root. Configure source_root in your manifest \
626                 or activate one (e.g. via repo_management in workspace mode).",
627            )]));
628        }
629        let opts = ReadOpts {
630            start_line: args.start_line,
631            end_line: args.end_line,
632            grep: args.grep,
633            grep_context: args.grep_context,
634            max_matches: args.max_matches,
635            max_chars: args.max_chars,
636        };
637        let body = source::read_source(&args.file_path, &roots, &opts);
638        Ok(CallToolResult::success(vec![Content::text(body)]))
639    }
640
641    #[tool(
642        description = "Search source files using ripgrep. `pattern` is a regex (Rust \
643                       syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
644                       N surrounding lines per match. Set `case_insensitive=true` for \
645                       case-insensitive matching. `max_results` caps total matches \
646                       (default 50)."
647    )]
648    async fn grep(
649        &self,
650        Parameters(args): Parameters<GrepArgs>,
651    ) -> Result<CallToolResult, McpError> {
652        let roots = self.current_source_roots();
653        if roots.is_empty() {
654            return Ok(CallToolResult::success(vec![Content::text(
655                "Cannot grep: no active source root. Configure source_root in your manifest \
656                 or activate one (e.g. via repo_management in workspace mode).",
657            )]));
658        }
659        let opts = GrepOpts {
660            glob: args.glob,
661            context: args.context,
662            max_results: Some(args.max_results.unwrap_or(50)),
663            case_insensitive: args.case_insensitive,
664        };
665        let body = source::grep(&roots, &args.pattern, &opts);
666        Ok(CallToolResult::success(vec![Content::text(body)]))
667    }
668
669    #[tool(
670        description = "List directory contents under the configured source root. `path` \
671                       is resolved against the first source root (\".\" lists the root \
672                       itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
673                       `glob` filters entry names. `dirs_only=true` shows only \
674                       directories."
675    )]
676    async fn list_source(
677        &self,
678        Parameters(args): Parameters<ListSourceArgs>,
679    ) -> Result<CallToolResult, McpError> {
680        let roots = self.current_source_roots();
681        if roots.is_empty() {
682            return Ok(CallToolResult::success(vec![Content::text(
683                "Cannot list source: no active source root. Configure source_root in your \
684                 manifest or activate one (e.g. via repo_management in workspace mode).",
685            )]));
686        }
687        let primary = std::path::PathBuf::from(&roots[0]);
688        let target = match resolve_dir_under_roots(&args.path, &roots) {
689            Some(p) => p,
690            None => {
691                return Ok(CallToolResult::success(vec![Content::text(format!(
692                    "Error: path '{}' resolves outside the configured source roots.",
693                    args.path
694                ))]));
695            }
696        };
697        let opts = ListOpts {
698            depth: args.depth,
699            glob: args.glob,
700            dirs_only: args.dirs_only,
701        };
702        let body = source::list_source(&target, &primary, &opts);
703        Ok(CallToolResult::success(vec![Content::text(body)]))
704    }
705
706    #[tool(
707        description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
708                       clone (if missing) and activate it as the source root for \
709                       read_source / grep / list_source. Pass `delete=true` to remove a \
710                       repo. Pass `update=true` to fetch upstream changes for the active \
711                       repo (rebuild auto-skipped when HEAD hasn't moved since the last \
712                       build; set `force_rebuild=true` to bypass). Call with no \
713                       arguments to list all known repos with their last-access counts. \
714                       Idle repos auto-sweep on each call (default 7 days, configurable \
715                       via --stale-after-days)."
716    )]
717    async fn repo_management(
718        &self,
719        Parameters(args): Parameters<RepoManagementArgs>,
720    ) -> Result<CallToolResult, McpError> {
721        let body = match &self.options.workspace {
722            Some(ws) => ws.repo_management(
723                args.name.as_deref(),
724                args.delete,
725                args.update,
726                args.force_rebuild,
727            ),
728            None => "repo_management requires --workspace mode.".to_string(),
729        };
730        Ok(CallToolResult::success(vec![Content::text(body)]))
731    }
732}
733
734/// Resolve `org/repo`: per-call override → configured default →
735/// auto-detect from cwd. Returns either the resolved repo or a
736/// formatted user-facing error message.
737///
738/// Free function (not a method) so it can be called from closures
739/// captured by [`McpServer::register_typed_tool`] which only see
740/// `Fn(T) -> String` — no `&self`.
741fn resolve_repo_from(
742    default_repo: Option<&RepoProvider>,
743    override_repo: Option<String>,
744) -> Result<String, String> {
745    if let Some(r) = override_repo {
746        if let Some(err) = crate::git_refs::validate_repo(&r) {
747            return Err(err);
748        }
749        return Ok(r);
750    }
751    if let Some(provider) = default_repo {
752        if let Some(r) = provider() {
753            if let Some(err) = crate::git_refs::validate_repo(&r) {
754                return Err(err);
755            }
756            return Ok(r);
757        }
758    }
759    if let Some(detected) = crate::github::detect_git_repo(".") {
760        if crate::git_refs::validate_repo(&detected).is_none() {
761            return Ok(detected);
762        }
763    }
764    Err(
765        "No active repository. Pass `repo_name='org/repo'`, configure a default in the \
766         server, or run from a directory whose git remote points at github.com."
767            .to_string(),
768    )
769}
770
771#[tool_handler(router = self.tool_router)]
772impl ServerHandler for McpServer {
773    fn get_info(&self) -> ServerInfo {
774        let name = self
775            .options
776            .name
777            .clone()
778            .unwrap_or_else(|| "MCP Server".to_string());
779        let mut info = ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
780            .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
781            .with_protocol_version(ProtocolVersion::V_2024_11_05);
782        if let Some(text) = &self.options.instructions {
783            info = info.with_instructions(text.clone());
784        }
785        info
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    #[test]
794    fn options_from_manifest_uses_name_when_set() {
795        let opts = ServerOptions::from_manifest(None, "Fallback");
796        assert_eq!(opts.name.as_deref(), Some("Fallback"));
797    }
798
799    #[test]
800    fn builtins_exposed_via_server() {
801        use crate::server::manifest::{BuiltinsConfig, TempCleanup};
802        let mut opts = ServerOptions::default();
803        opts.builtins = BuiltinsConfig {
804            save_graph: true,
805            temp_cleanup: TempCleanup::OnOverview,
806        };
807        let server = McpServer::new(opts);
808        assert!(server.builtins().save_graph);
809        assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
810    }
811
812    #[test]
813    fn server_constructs() {
814        let _server = McpServer::new(ServerOptions::default());
815    }
816
817    #[test]
818    fn static_source_roots_provider() {
819        let opts = ServerOptions::default()
820            .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
821        let server = McpServer::new(opts);
822        assert_eq!(
823            server.current_source_roots(),
824            vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
825        );
826    }
827
828    #[test]
829    fn no_provider_returns_empty_roots() {
830        let server = McpServer::new(ServerOptions::default());
831        assert!(server.current_source_roots().is_empty());
832    }
833
834    #[test]
835    fn repo_management_gated_to_workspace_mode() {
836        // Bare (no workspace): repo_management should NOT be in the
837        // router. Mirrors the gating downstream binaries apply.
838        let server = McpServer::new(ServerOptions::default());
839        let tools = server.tool_router.list_all();
840        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
841        assert!(
842            !names.contains(&"repo_management"),
843            "repo_management should be gated out without a workspace; tools were {names:?}"
844        );
845    }
846
847    #[test]
848    fn repo_management_present_when_workspace_bound() {
849        // With a workspace handle bound, repo_management should be
850        // registered.
851        use crate::server::workspace::Workspace;
852        let dir = tempfile::tempdir().unwrap();
853        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
854        let opts = ServerOptions::default().with_workspace(ws);
855        let server = McpServer::new(opts);
856        let tools = server.tool_router.list_all();
857        let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
858        assert!(
859            names.contains(&"repo_management"),
860            "repo_management should be registered with a workspace; tools were {names:?}"
861        );
862    }
863
864    #[test]
865    fn dynamic_provider_swaps_at_call_time() {
866        use std::sync::Mutex;
867        let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
868        let s2 = state.clone();
869        let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
870        let opts = ServerOptions::default().with_dynamic_source_roots(provider);
871        let server = McpServer::new(opts);
872        assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
873        *state.lock().unwrap() = vec!["/swapped".to_string()];
874        assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
875    }
876}