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 pub extensions: serde_json::Map<String, serde_json::Value>,
79}
80
81impl std::fmt::Debug for ServerOptions {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct("ServerOptions")
84 .field("name", &self.name)
85 .field("instructions", &self.instructions)
86 .field(
87 "source_roots",
88 &self.source_roots.as_ref().map(|_| "<provider>"),
89 )
90 .field(
91 "default_repo",
92 &self.default_repo.as_ref().map(|_| "<provider>"),
93 )
94 .finish()
95 }
96}
97
98impl ServerOptions {
99 pub fn from_manifest(manifest: Option<&Manifest>, fallback_name: &str) -> Self {
100 Self {
101 name: manifest
102 .and_then(|m| m.name.clone())
103 .or_else(|| Some(fallback_name.to_string())),
104 instructions: manifest.and_then(|m| m.instructions.clone()),
105 source_roots: None,
106 default_repo: None,
107 workspace: None,
108 builtins: manifest.map(|m| m.builtins.clone()).unwrap_or_default(),
109 extensions: manifest.map(|m| m.extensions.clone()).unwrap_or_default(),
110 }
111 }
112
113 pub fn with_static_source_roots(mut self, roots: Vec<String>) -> Self {
114 let captured = Arc::new(roots);
115 self.source_roots = Some(Arc::new(move || captured.as_ref().clone()));
116 self
117 }
118
119 pub fn with_dynamic_source_roots(mut self, provider: SourceRootsProvider) -> Self {
120 self.source_roots = Some(provider);
121 self
122 }
123
124 pub fn with_static_repo(mut self, repo: String) -> Self {
125 self.default_repo = Some(Arc::new(move || Some(repo.clone())));
126 self
127 }
128
129 pub fn with_dynamic_repo(mut self, provider: RepoProvider) -> Self {
130 self.default_repo = Some(provider);
131 self
132 }
133
134 pub fn with_workspace(mut self, ws: crate::server::workspace::Workspace) -> Self {
139 let ws_for_roots = ws.clone();
140 let ws_for_repo = ws.clone();
141 self.workspace = Some(ws);
142 self.source_roots = Some(Arc::new(move || {
143 ws_for_roots
144 .active_repo_path()
145 .map(|p| vec![p.to_string_lossy().into_owned()])
146 .unwrap_or_default()
147 }));
148 self.default_repo = Some(Arc::new(move || ws_for_repo.active_repo_name()));
149 self
150 }
151}
152
153#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
154pub struct PingArgs {
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub message: Option<String>,
158}
159
160#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
161pub struct ReadSourceArgs {
162 pub file_path: String,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub start_line: Option<usize>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub end_line: Option<usize>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub grep: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub grep_context: Option<usize>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub max_matches: Option<usize>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub max_chars: Option<usize>,
182}
183
184#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
185pub struct GrepArgs {
186 pub pattern: String,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub glob: Option<String>,
191 #[serde(default)]
193 pub context: usize,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub max_results: Option<usize>,
197 #[serde(default)]
199 pub case_insensitive: bool,
200}
201
202#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
203pub struct SetRootDirArgs {
204 pub path: String,
206}
207
208#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
209pub struct RepoManagementArgs {
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub name: Option<String>,
213 #[serde(default)]
215 pub delete: bool,
216 #[serde(default)]
218 pub update: bool,
219 #[serde(default)]
223 pub force_rebuild: bool,
224}
225
226#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
227pub struct GithubIssuesArgs {
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub number: Option<u64>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub repo_name: Option<String>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub query: Option<String>,
237 #[serde(default = "default_kind")]
239 pub kind: String,
240 #[serde(default = "default_state")]
242 pub state: String,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub sort: Option<String>,
246 #[serde(default = "default_limit")]
248 pub limit: usize,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub labels: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub element_id: Option<String>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub lines: Option<String>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub grep: Option<String>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub context: Option<usize>,
271 #[serde(default)]
274 pub refresh: bool,
275}
276
277fn default_kind() -> String {
278 "all".to_string()
279}
280fn default_state() -> String {
281 "open".to_string()
282}
283fn default_limit() -> usize {
284 20
285}
286
287impl Default for GithubIssuesArgs {
288 fn default() -> Self {
289 Self {
290 number: None,
291 repo_name: None,
292 query: None,
293 kind: default_kind(),
294 state: default_state(),
295 sort: None,
296 limit: default_limit(),
297 labels: None,
298 element_id: None,
299 lines: None,
300 grep: None,
301 context: None,
302 refresh: false,
303 }
304 }
305}
306
307#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
308pub struct GithubApiArgs {
309 pub path: String,
313 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub repo_name: Option<String>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub truncate_at: Option<usize>,
319}
320
321#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
322pub struct ListSourceArgs {
323 #[serde(default = "default_path")]
325 pub path: String,
326 #[serde(default = "default_depth")]
328 pub depth: usize,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub glob: Option<String>,
332 #[serde(default)]
334 pub dirs_only: bool,
335}
336
337fn default_path() -> String {
338 ".".to_string()
339}
340fn default_depth() -> usize {
341 1
342}
343
344#[derive(Clone)]
349pub struct McpServer {
350 options: ServerOptions,
351 tool_router: ToolRouter<McpServer>,
352 prompt_router: PromptRouter<McpServer>,
357}
358
359#[tool_router]
360impl McpServer {
361 pub fn new(options: ServerOptions) -> Self {
362 let mut server = Self {
363 options,
364 tool_router: Self::tool_router(),
365 prompt_router: PromptRouter::new(),
366 };
367 server.register_github_tools_if_authorized();
368 server.register_local_workspace_tools();
369 server.gate_workspace_tools();
370 server
371 }
372
373 fn gate_workspace_tools(&mut self) {
381 if self.options.workspace.is_none() {
382 self.tool_router.remove_route("repo_management");
383 }
384 }
385
386 fn register_local_workspace_tools(&mut self) {
390 let Some(ws) = self.options.workspace.clone() else {
391 return;
392 };
393 if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
394 return;
395 }
396 self.register_typed_tool::<SetRootDirArgs, _>(
397 "set_root_dir",
398 "Swap the active source root (local-workspace mode only). Pass `path` \
399 to a directory; the framework canonicalises it, rebinds the source \
400 tools (`read_source`, `grep`, `list_source`), and fires the post-\
401 activate hook so any downstream graph rebuilds against the new root. \
402 Inventory persists across swaps; SHA-gating skips rebuilds when the \
403 same root is re-bound with no content changes.",
404 move |args: SetRootDirArgs| {
405 let p = std::path::PathBuf::from(&args.path);
406 ws.set_root_dir(&p)
407 },
408 );
409 }
410
411 fn register_github_tools_if_authorized(&mut self) {
417 if !crate::github::has_git_token() {
418 tracing::info!(
419 "GITHUB_TOKEN not set — github_issues / github_api tools hidden from the agent. \
420 Set the env var and restart to enable them."
421 );
422 return;
423 }
424 let default_repo = self.options.default_repo.clone();
425 let repo_provider = default_repo.clone();
426 let cache: Arc<Mutex<crate::cache::ElementCache>> =
432 Arc::new(Mutex::new(crate::cache::ElementCache::new()));
433 let cache_for_issues = cache.clone();
434 self.register_typed_tool::<GithubIssuesArgs, _>(
435 "github_issues",
436 "Search, list, or fetch GitHub issues / pull requests / Discussions. \
437 Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
438 for SEARCH (across issues+PRs and Discussions); neither for LIST. \
439 `kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
440 `state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
441 result count (default 20). `labels` is a comma-separated string. \
442 `repo_name=\"org/repo\"` overrides the active repo for one call. \
443 FETCH responses collapse big code blocks / patches / comments into \
444 `cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
445 `element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
446 element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
447 `refresh=true` bypasses the cache for re-fetch.",
448 move |args: GithubIssuesArgs| {
449 let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
450 Ok(r) => r,
451 Err(msg) => return msg,
452 };
453 if let Some(number) = args.number {
459 let context = args.context.unwrap_or(3);
460 let mut guard = cache_for_issues.lock().unwrap();
461 return guard.fetch_issue(
462 &repo,
463 number,
464 args.element_id.as_deref(),
465 args.lines.as_deref(),
466 args.grep.as_deref(),
467 context,
468 args.refresh,
469 );
470 }
471 if args.element_id.is_some() {
472 return "element_id requires `number=N` (the issue/PR being drilled into)."
473 .to_string();
474 }
475 crate::github::github_issues_rust(
477 Some(&repo),
478 args.number,
479 args.query.as_deref(),
480 &args.kind,
481 &args.state,
482 args.sort.as_deref(),
483 args.limit,
484 args.labels.as_deref(),
485 )
486 },
487 );
488 let repo_provider = default_repo;
489 self.register_typed_tool::<GithubApiArgs, _>(
490 "github_api",
491 "Read-only GET against the GitHub REST API. `path` may be a \
492 repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
493 \"branches\", \"compare/main...feature\") which is auto-prefixed \
494 with /repos/<repo_name>/, or an absolute resource (\"search/issues?q=...\", \
495 \"users/octocat\") which passes through. Returns JSON, truncated at \
496 80 KB by default.",
497 move |args: GithubApiArgs| match resolve_repo_from(
498 repo_provider.as_ref(),
499 args.repo_name.clone(),
500 ) {
501 Ok(repo) => {
502 let truncate_at = args.truncate_at.unwrap_or(80_000);
503 crate::github::git_api_internal(&repo, &args.path, truncate_at)
504 }
505 Err(msg) => msg,
506 },
507 );
508 }
509
510 pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
517 &self.options.builtins
518 }
519
520 pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
526 &mut self.tool_router
527 }
528
529 pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
534 &mut self.prompt_router
535 }
536
537 pub fn register_typed_tool<T, F>(
552 &mut self,
553 name: &'static str,
554 description: &'static str,
555 handler: F,
556 ) where
557 T: for<'de> serde::Deserialize<'de>
558 + schemars::JsonSchema
559 + Default
560 + Send
561 + Sync
562 + 'static,
563 F: Fn(T) -> String + Send + Sync + 'static,
564 {
565 use std::pin::Pin;
566 type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
567
568 let schema_obj = serde_json::to_value(schemars::schema_for!(T))
569 .ok()
570 .and_then(|v| v.as_object().cloned())
571 .unwrap_or_default();
572 let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
573 let handler = std::sync::Arc::new(handler);
574
575 self.tool_router
576 .add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
577 attr,
578 move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
579 -> DynFut<'_, Result<rmcp::model::CallToolResult, rmcp::ErrorData>> {
580 let handler = handler.clone();
581 let arguments = ctx.arguments.clone();
582 Box::pin(async move {
583 let args: T = match arguments {
584 Some(map) => {
585 match serde_json::from_value(serde_json::Value::Object(map)) {
586 Ok(a) => a,
587 Err(e) => {
588 return Ok(rmcp::model::CallToolResult::success(vec![
589 rmcp::model::Content::text(format!(
590 "invalid arguments: {e}"
591 )),
592 ]));
593 }
594 }
595 }
596 None => T::default(),
597 };
598 let body = handler(args);
599 Ok(rmcp::model::CallToolResult::success(vec![
600 rmcp::model::Content::text(body),
601 ]))
602 })
603 },
604 ));
605 }
606
607 fn current_source_roots(&self) -> Vec<String> {
608 match &self.options.source_roots {
609 Some(provider) => provider(),
610 None => Vec::new(),
611 }
612 }
613
614 #[allow(dead_code)]
619 fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
620 resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
621 }
622
623 #[tool(
624 description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
625 Use to confirm the server framework is wired correctly before \
626 relying on graph- or source-aware tools."
627 )]
628 async fn ping(
629 &self,
630 Parameters(args): Parameters<PingArgs>,
631 ) -> Result<CallToolResult, McpError> {
632 let body = args.message.unwrap_or_else(|| "pong".to_string());
633 Ok(CallToolResult::success(vec![Content::text(body)]))
634 }
635
636 #[tool(description = "Read a file from the configured source root(s). Pass \
637 `start_line`/`end_line` to slice, `grep` to filter to matching \
638 lines, `max_chars` to cap output. Path traversal attempts are \
639 rejected. Available only when source roots are configured.")]
640 async fn read_source(
641 &self,
642 Parameters(args): Parameters<ReadSourceArgs>,
643 ) -> Result<CallToolResult, McpError> {
644 let roots = self.current_source_roots();
645 if roots.is_empty() {
646 return Ok(CallToolResult::success(vec![Content::text(
647 "Cannot read source: no active source root. Configure source_root in your manifest \
648 or activate one (e.g. via repo_management in workspace mode).",
649 )]));
650 }
651 let opts = ReadOpts {
652 start_line: args.start_line,
653 end_line: args.end_line,
654 grep: args.grep,
655 grep_context: args.grep_context,
656 max_matches: args.max_matches,
657 max_chars: args.max_chars,
658 };
659 let body = source::read_source(&args.file_path, &roots, &opts);
660 Ok(CallToolResult::success(vec![Content::text(body)]))
661 }
662
663 #[tool(
664 description = "Search source files using ripgrep. `pattern` is a regex (Rust \
665 syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
666 N surrounding lines per match. Set `case_insensitive=true` for \
667 case-insensitive matching. `max_results` caps total matches \
668 (default 50)."
669 )]
670 async fn grep(
671 &self,
672 Parameters(args): Parameters<GrepArgs>,
673 ) -> Result<CallToolResult, McpError> {
674 let roots = self.current_source_roots();
675 if roots.is_empty() {
676 return Ok(CallToolResult::success(vec![Content::text(
677 "Cannot grep: no active source root. Configure source_root in your manifest \
678 or activate one (e.g. via repo_management in workspace mode).",
679 )]));
680 }
681 let opts = GrepOpts {
682 glob: args.glob,
683 context: args.context,
684 max_results: Some(args.max_results.unwrap_or(50)),
685 case_insensitive: args.case_insensitive,
686 };
687 let body = source::grep(&roots, &args.pattern, &opts);
688 Ok(CallToolResult::success(vec![Content::text(body)]))
689 }
690
691 #[tool(
692 description = "List directory contents under the configured source root. `path` \
693 is resolved against the first source root (\".\" lists the root \
694 itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
695 `glob` filters entry names. `dirs_only=true` shows only \
696 directories."
697 )]
698 async fn list_source(
699 &self,
700 Parameters(args): Parameters<ListSourceArgs>,
701 ) -> Result<CallToolResult, McpError> {
702 let roots = self.current_source_roots();
703 if roots.is_empty() {
704 return Ok(CallToolResult::success(vec![Content::text(
705 "Cannot list source: no active source root. Configure source_root in your \
706 manifest or activate one (e.g. via repo_management in workspace mode).",
707 )]));
708 }
709 let primary = std::path::PathBuf::from(&roots[0]);
710 let target = match resolve_dir_under_roots(&args.path, &roots) {
711 Some(p) => p,
712 None => {
713 return Ok(CallToolResult::success(vec![Content::text(format!(
714 "Error: path '{}' resolves outside the configured source roots.",
715 args.path
716 ))]));
717 }
718 };
719 let opts = ListOpts {
720 depth: args.depth,
721 glob: args.glob,
722 dirs_only: args.dirs_only,
723 };
724 let body = source::list_source(&target, &primary, &opts);
725 Ok(CallToolResult::success(vec![Content::text(body)]))
726 }
727
728 #[tool(
729 description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
730 clone (if missing) and activate it as the source root for \
731 read_source / grep / list_source. Pass `delete=true` to remove a \
732 repo. Pass `update=true` to fetch upstream changes for the active \
733 repo (rebuild auto-skipped when HEAD hasn't moved since the last \
734 build; set `force_rebuild=true` to bypass). Call with no \
735 arguments to list all known repos with their last-access counts. \
736 Idle repos auto-sweep on each call (default 7 days, configurable \
737 via --stale-after-days)."
738 )]
739 async fn repo_management(
740 &self,
741 Parameters(args): Parameters<RepoManagementArgs>,
742 ) -> Result<CallToolResult, McpError> {
743 let body = match &self.options.workspace {
744 Some(ws) => ws.repo_management(
745 args.name.as_deref(),
746 args.delete,
747 args.update,
748 args.force_rebuild,
749 ),
750 None => "repo_management requires --workspace mode.".to_string(),
751 };
752 Ok(CallToolResult::success(vec![Content::text(body)]))
753 }
754}
755
756fn resolve_repo_from(
764 default_repo: Option<&RepoProvider>,
765 override_repo: Option<String>,
766) -> Result<String, String> {
767 if let Some(r) = override_repo {
768 if let Some(err) = crate::git_refs::validate_repo(&r) {
769 return Err(err);
770 }
771 return Ok(r);
772 }
773 if let Some(provider) = default_repo {
774 if let Some(r) = provider() {
775 if let Some(err) = crate::git_refs::validate_repo(&r) {
776 return Err(err);
777 }
778 return Ok(r);
779 }
780 }
781 if let Some(detected) = crate::github::detect_git_repo(".") {
782 if crate::git_refs::validate_repo(&detected).is_none() {
783 return Ok(detected);
784 }
785 }
786 Err(
787 "No active repository. Pass `repo_name='org/repo'`, configure a default in the \
788 server, or run from a directory whose git remote points at github.com."
789 .to_string(),
790 )
791}
792
793pub fn serve_prompts(registry: &ResolvedRegistry, server: &mut McpServer) {
807 use std::borrow::Cow;
808 use std::collections::HashSet;
809
810 let registered_tools: HashSet<String> = server
815 .tool_router
816 .list_all()
817 .iter()
818 .map(|t| t.name.to_string())
819 .collect();
820 let extensions = server.options.extensions.clone();
821
822 let mut auto_inject: Vec<(String, String)> = Vec::new();
827
828 for name in registry.skill_names() {
829 let Some(skill) = registry.get(&name) else {
830 continue;
831 };
832
833 let activation = registry.activation_for(skill, ®istered_tools, &extensions);
837 if !activation.active {
838 let failed_clauses: Vec<&str> = activation
839 .clauses
840 .iter()
841 .filter(|(_, outcome)| {
842 *outcome != crate::server::skills::PredicateOutcome::Satisfied
843 })
844 .map(|(clause, _)| clause.as_str())
845 .collect();
846 tracing::info!(
847 skill = %name,
848 suppressed_by = ?failed_clauses,
849 "skill suppressed by applies_when predicates"
850 );
851 continue;
852 }
853
854 let prompt = Prompt::new(
855 skill.name().to_string(),
856 Some(skill.description().to_string()),
857 None,
858 );
859 let body = skill.body.clone();
860 let route = PromptRoute::new_dyn(prompt, move |_ctx| {
861 let body = body.clone();
862 Box::pin(async move {
863 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
864 PromptMessageRole::Assistant,
865 body,
866 )]))
867 })
868 });
869 server.prompt_router.add_route(route);
870
871 if skill.frontmatter.auto_inject_hint {
872 auto_inject.push((skill.name().to_string(), skill.body.clone()));
873 }
874 }
875
876 for (skill_name, body) in &auto_inject {
899 let key = Cow::<'static, str>::Owned(skill_name.clone());
900 if let Some(route) = server.tool_router.map.get_mut(&key) {
901 let trimmed_body = body.trim();
902 let inject = format!("\n\n## Methodology\n\n{trimmed_body}");
903 let new_desc = match route.attr.description.take() {
904 Some(existing) => format!("{existing}{inject}"),
905 None => inject.trim_start().to_string(),
906 };
907 route.attr.description = Some(Cow::Owned(new_desc));
908 }
909 }
910}
911
912#[tool_handler(router = self.tool_router)]
913impl ServerHandler for McpServer {
914 fn get_info(&self) -> ServerInfo {
915 let name = self
916 .options
917 .name
918 .clone()
919 .unwrap_or_else(|| "MCP Server".to_string());
920 let mut caps = ServerCapabilities::builder().enable_tools().build();
927 if !self.prompt_router.map.is_empty() {
928 caps.prompts = Some(PromptsCapability::default());
929 }
930 let mut info = ServerInfo::new(caps)
931 .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
932 .with_protocol_version(ProtocolVersion::V_2024_11_05);
933 if let Some(text) = &self.options.instructions {
934 info = info.with_instructions(text.clone());
935 }
936 info
937 }
938
939 async fn list_prompts(
940 &self,
941 _request: Option<PaginatedRequestParams>,
942 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
943 ) -> Result<ListPromptsResult, McpError> {
944 Ok(ListPromptsResult {
945 meta: None,
946 next_cursor: None,
947 prompts: self.prompt_router.list_all(),
948 })
949 }
950
951 async fn get_prompt(
952 &self,
953 request: GetPromptRequestParams,
954 context: rmcp::service::RequestContext<rmcp::RoleServer>,
955 ) -> Result<GetPromptResult, McpError> {
956 let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
957 self,
958 request.name,
959 request.arguments,
960 context,
961 );
962 self.prompt_router.get_prompt(prompt_context).await
963 }
964}
965
966#[cfg(test)]
967mod tests {
968 use super::*;
969
970 #[test]
971 fn options_from_manifest_uses_name_when_set() {
972 let opts = ServerOptions::from_manifest(None, "Fallback");
973 assert_eq!(opts.name.as_deref(), Some("Fallback"));
974 }
975
976 #[test]
977 fn builtins_exposed_via_server() {
978 use crate::server::manifest::{BuiltinsConfig, TempCleanup};
979 let opts = ServerOptions {
980 builtins: BuiltinsConfig {
981 save_graph: true,
982 temp_cleanup: TempCleanup::OnOverview,
983 },
984 ..ServerOptions::default()
985 };
986 let server = McpServer::new(opts);
987 assert!(server.builtins().save_graph);
988 assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
989 }
990
991 #[test]
992 fn server_constructs() {
993 let _server = McpServer::new(ServerOptions::default());
994 }
995
996 #[test]
997 fn static_source_roots_provider() {
998 let opts = ServerOptions::default()
999 .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
1000 let server = McpServer::new(opts);
1001 assert_eq!(
1002 server.current_source_roots(),
1003 vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
1004 );
1005 }
1006
1007 #[test]
1008 fn no_provider_returns_empty_roots() {
1009 let server = McpServer::new(ServerOptions::default());
1010 assert!(server.current_source_roots().is_empty());
1011 }
1012
1013 #[test]
1014 fn repo_management_gated_to_workspace_mode() {
1015 let server = McpServer::new(ServerOptions::default());
1018 let tools = server.tool_router.list_all();
1019 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1020 assert!(
1021 !names.contains(&"repo_management"),
1022 "repo_management should be gated out without a workspace; tools were {names:?}"
1023 );
1024 }
1025
1026 #[test]
1027 fn repo_management_present_when_workspace_bound() {
1028 use crate::server::workspace::Workspace;
1031 let dir = tempfile::tempdir().unwrap();
1032 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1033 let opts = ServerOptions::default().with_workspace(ws);
1034 let server = McpServer::new(opts);
1035 let tools = server.tool_router.list_all();
1036 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1037 assert!(
1038 names.contains(&"repo_management"),
1039 "repo_management should be registered with a workspace; tools were {names:?}"
1040 );
1041 }
1042
1043 #[test]
1044 fn dynamic_provider_swaps_at_call_time() {
1045 use std::sync::Mutex;
1046 let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
1047 let s2 = state.clone();
1048 let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
1049 let opts = ServerOptions::default().with_dynamic_source_roots(provider);
1050 let server = McpServer::new(opts);
1051 assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
1052 *state.lock().unwrap() = vec!["/swapped".to_string()];
1053 assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
1054 }
1055
1056 fn build_test_registry(
1059 skills: &[(&str, &str, &str, bool)],
1060 ) -> crate::server::skills::ResolvedRegistry {
1061 use crate::server::skills::Registry;
1062 let dir = tempfile::tempdir().unwrap();
1063 let yaml_path = dir.path().join("manifest.yaml");
1064 let skills_dir = dir.path().join("manifest.skills");
1065 std::fs::create_dir_all(&skills_dir).unwrap();
1066 for (name, description, body, auto_inject) in skills {
1067 let auto = if *auto_inject { "true" } else { "false" };
1068 let content = format!(
1069 "---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
1070 );
1071 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1072 }
1073 Registry::new()
1074 .auto_detect_project_layer(&yaml_path)
1075 .finalise()
1076 .unwrap()
1077 }
1078
1079 #[test]
1080 fn prompt_router_empty_by_default() {
1081 let server = McpServer::new(ServerOptions::default());
1082 assert!(server.prompt_router.map.is_empty());
1083 }
1084
1085 #[test]
1086 fn get_info_no_prompts_capability_when_empty() {
1087 let server = McpServer::new(ServerOptions::default());
1091 let info = server.get_info();
1092 assert!(
1093 info.capabilities.prompts.is_none(),
1094 "prompts capability must be absent when no skills are registered"
1095 );
1096 }
1097
1098 #[test]
1099 fn serve_prompts_registers_routes_with_metadata() {
1100 let registry = build_test_registry(&[
1101 ("alpha", "First skill.", "Alpha body.", true),
1102 ("beta", "Second skill.", "Beta body.", true),
1103 ]);
1104 let mut server = McpServer::new(ServerOptions::default());
1105 super::serve_prompts(®istry, &mut server);
1106
1107 let prompts = server.prompt_router.list_all();
1108 let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
1109 assert_eq!(names, vec!["alpha", "beta"]);
1110
1111 let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
1112 assert_eq!(alpha.description.as_deref(), Some("First skill."));
1113 assert!(alpha.arguments.is_none());
1114 }
1115
1116 #[test]
1117 fn serve_prompts_empty_registry_is_noop() {
1118 let registry = crate::server::skills::ResolvedRegistry::default();
1119 let mut server = McpServer::new(ServerOptions::default());
1120 super::serve_prompts(®istry, &mut server);
1121 assert!(server.prompt_router.map.is_empty());
1122 assert!(server.get_info().capabilities.prompts.is_none());
1123 }
1124
1125 #[test]
1126 fn get_info_advertises_prompts_when_present() {
1127 let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
1128 let mut server = McpServer::new(ServerOptions::default());
1129 super::serve_prompts(®istry, &mut server);
1130 let info = server.get_info();
1131 assert!(
1132 info.capabilities.prompts.is_some(),
1133 "prompts capability must be advertised once a skill is registered"
1134 );
1135 }
1136
1137 #[test]
1138 fn serve_prompts_auto_injects_full_body_into_matching_tool() {
1139 let registry =
1147 build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
1148 let mut server = McpServer::new(ServerOptions::default());
1149 let before = server
1150 .tool_router
1151 .get("ping")
1152 .and_then(|t| t.description.clone())
1153 .map(|c| c.into_owned())
1154 .unwrap_or_default();
1155 super::serve_prompts(®istry, &mut server);
1156 let after = server
1157 .tool_router
1158 .get("ping")
1159 .and_then(|t| t.description.clone())
1160 .map(|c| c.into_owned())
1161 .unwrap_or_default();
1162 assert!(after.starts_with(&before), "original description preserved");
1163 assert!(
1164 after.contains("## Methodology"),
1165 "inject should include a Methodology header; got: {after}"
1166 );
1167 assert!(
1168 after.contains("PING-BODY-SENTINEL"),
1169 "inject should embed the full skill body; got: {after}"
1170 );
1171 assert!(
1172 !after.contains("prompts/get"),
1173 "post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
1174 );
1175 }
1176
1177 #[test]
1178 fn serve_prompts_skips_injection_when_disabled() {
1179 let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
1180 let mut server = McpServer::new(ServerOptions::default());
1181 let before = server
1182 .tool_router
1183 .get("ping")
1184 .and_then(|t| t.description.clone())
1185 .map(|c| c.into_owned())
1186 .unwrap_or_default();
1187 super::serve_prompts(®istry, &mut server);
1188 let after = server
1189 .tool_router
1190 .get("ping")
1191 .and_then(|t| t.description.clone())
1192 .map(|c| c.into_owned())
1193 .unwrap_or_default();
1194 assert_eq!(
1195 before, after,
1196 "auto_inject_hint=false must leave tool description untouched"
1197 );
1198 }
1199
1200 #[test]
1201 fn serve_prompts_skips_injection_when_no_matching_tool() {
1202 let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
1205 let mut server = McpServer::new(ServerOptions::default());
1206 super::serve_prompts(®istry, &mut server);
1207 assert!(server.prompt_router.map.contains_key("no_such_tool"));
1208 let ping_desc = server
1211 .tool_router
1212 .get("ping")
1213 .and_then(|t| t.description.clone())
1214 .map(|c| c.into_owned())
1215 .unwrap_or_default();
1216 assert!(!ping_desc.contains("no_such_tool"));
1217 }
1218
1219 fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
1220 let dir = tempfile::tempdir().unwrap();
1221 let yaml = dir.path().join("test_mcp.yaml");
1222 std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1223 let skills_dir = dir.path().join("test_mcp.skills");
1224 std::fs::create_dir(&skills_dir).unwrap();
1225 std::fs::write(
1226 skills_dir.join("gated_skill.md"),
1227 format!(
1228 "---\n\
1229 name: gated_skill\n\
1230 description: A predicate-gated skill for testing.\n\
1231 applies_when:\n\
1232 {applies_when_yaml}\n\
1233 ---\n\n\
1234 Body.\n",
1235 ),
1236 )
1237 .unwrap();
1238 dir
1239 }
1240
1241 #[test]
1242 fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
1243 use crate::server::skills::Registry as SkillsBuilder;
1247 let dir = write_gated_project_skill(" tool_registered: nonexistent_tool");
1248 let yaml = dir.path().join("test_mcp.yaml");
1249 let registry = SkillsBuilder::new()
1250 .auto_detect_project_layer(&yaml)
1251 .finalise()
1252 .unwrap();
1253 let mut server = McpServer::new(ServerOptions::default());
1254 super::serve_prompts(®istry, &mut server);
1255 assert!(
1256 !server.prompt_router.map.contains_key("gated_skill"),
1257 "skill with unsatisfied predicate must be suppressed"
1258 );
1259 }
1260
1261 #[test]
1262 fn serve_prompts_keeps_skill_with_satisfied_predicate() {
1263 use crate::server::skills::Registry as SkillsBuilder;
1266 let dir = write_gated_project_skill(" tool_registered: ping");
1267 let yaml = dir.path().join("test_mcp.yaml");
1268 let registry = SkillsBuilder::new()
1269 .auto_detect_project_layer(&yaml)
1270 .finalise()
1271 .unwrap();
1272 let mut server = McpServer::new(ServerOptions::default());
1273 super::serve_prompts(®istry, &mut server);
1274 assert!(
1275 server.prompt_router.map.contains_key("gated_skill"),
1276 "skill with satisfied predicate must register"
1277 );
1278 }
1279
1280 #[test]
1281 fn serve_prompts_evaluates_extension_enabled_from_manifest() {
1282 use crate::server::skills::Registry as SkillsBuilder;
1286 let dir = write_gated_project_skill(" extension_enabled: csv_http_server");
1287 let yaml = dir.path().join("test_mcp.yaml");
1288 let registry = SkillsBuilder::new()
1289 .auto_detect_project_layer(&yaml)
1290 .finalise()
1291 .unwrap();
1292
1293 let mut server = McpServer::new(ServerOptions::default());
1295 super::serve_prompts(®istry, &mut server);
1296 assert!(!server.prompt_router.map.contains_key("gated_skill"));
1297
1298 let mut extensions = serde_json::Map::new();
1300 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1301 let opts = ServerOptions {
1302 extensions,
1303 ..ServerOptions::default()
1304 };
1305 let mut server = McpServer::new(opts);
1306 super::serve_prompts(®istry, &mut server);
1307 assert!(server.prompt_router.map.contains_key("gated_skill"));
1308 }
1309}