1#![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
49pub type RepoProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
53
54#[derive(Clone, Default)]
56pub struct ServerOptions {
57 pub name: Option<String>,
59 pub instructions: Option<String>,
61 pub source_roots: Option<SourceRootsProvider>,
64 pub default_repo: Option<RepoProvider>,
67 pub workspace: Option<crate::server::workspace::Workspace>,
69 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 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 #[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 pub file_path: String,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub start_line: Option<usize>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub end_line: Option<usize>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub grep: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub grep_context: Option<usize>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub max_matches: Option<usize>,
173 #[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 pub pattern: String,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub glob: Option<String>,
185 #[serde(default)]
187 pub context: usize,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub max_results: Option<usize>,
191 #[serde(default)]
193 pub case_insensitive: bool,
194}
195
196#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
197pub struct SetRootDirArgs {
198 pub path: String,
200}
201
202#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
203pub struct RepoManagementArgs {
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub name: Option<String>,
207 #[serde(default)]
209 pub delete: bool,
210 #[serde(default)]
212 pub update: bool,
213 #[serde(default)]
217 pub force_rebuild: bool,
218}
219
220#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
221pub struct GithubIssuesArgs {
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub number: Option<u64>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub repo_name: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub query: Option<String>,
231 #[serde(default = "default_kind")]
233 pub kind: String,
234 #[serde(default = "default_state")]
236 pub state: String,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub sort: Option<String>,
240 #[serde(default = "default_limit")]
242 pub limit: usize,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub labels: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub element_id: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub lines: Option<String>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub grep: Option<String>,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub context: Option<usize>,
265 #[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 pub path: String,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub repo_name: Option<String>,
310 #[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 #[serde(default = "default_path")]
319 pub path: String,
320 #[serde(default = "default_depth")]
322 pub depth: usize,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub glob: Option<String>,
326 #[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#[derive(Clone)]
343pub struct McpServer {
344 options: ServerOptions,
345 tool_router: ToolRouter<McpServer>,
346 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 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 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 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 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 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 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 pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
511 &self.options.builtins
512 }
513
514 pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
520 &mut self.tool_router
521 }
522
523 pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
528 &mut self.prompt_router
529 }
530
531 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 #[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
750fn 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
787pub 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 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 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 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 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 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 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(®istry, &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(®istry, &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(®istry, &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 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(®istry, &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(®istry, &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 let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
1130 let mut server = McpServer::new(ServerOptions::default());
1131 super::serve_prompts(®istry, &mut server);
1132 assert!(server.prompt_router.map.contains_key("no_such_tool"));
1133 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}