1#![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
47pub type RepoProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
51
52#[derive(Clone, Default)]
54pub struct ServerOptions {
55 pub name: Option<String>,
57 pub instructions: Option<String>,
59 pub source_roots: Option<SourceRootsProvider>,
62 pub default_repo: Option<RepoProvider>,
65 pub workspace: Option<crate::server::workspace::Workspace>,
67 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 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 #[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 pub file_path: String,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub start_line: Option<usize>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub end_line: Option<usize>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub grep: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub grep_context: Option<usize>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub max_matches: Option<usize>,
171 #[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 pub pattern: String,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub glob: Option<String>,
183 #[serde(default)]
185 pub context: usize,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub max_results: Option<usize>,
189 #[serde(default)]
191 pub case_insensitive: bool,
192}
193
194#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
195pub struct SetRootDirArgs {
196 pub path: String,
198}
199
200#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
201pub struct RepoManagementArgs {
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub name: Option<String>,
205 #[serde(default)]
207 pub delete: bool,
208 #[serde(default)]
210 pub update: bool,
211 #[serde(default)]
215 pub force_rebuild: bool,
216}
217
218#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
219pub struct GithubIssuesArgs {
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub number: Option<u64>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub repo_name: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub query: Option<String>,
229 #[serde(default = "default_kind")]
231 pub kind: String,
232 #[serde(default = "default_state")]
234 pub state: String,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub sort: Option<String>,
238 #[serde(default = "default_limit")]
240 pub limit: usize,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub labels: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub element_id: Option<String>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub lines: Option<String>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub grep: Option<String>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub context: Option<usize>,
263 #[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 pub path: String,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub repo_name: Option<String>,
308 #[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 #[serde(default = "default_path")]
317 pub path: String,
318 #[serde(default = "default_depth")]
320 pub depth: usize,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub glob: Option<String>,
324 #[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#[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 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 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 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 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 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 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 pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
503 &self.options.builtins
504 }
505
506 pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
512 &mut self.tool_router
513 }
514
515 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 #[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
734fn 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 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 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}