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.default_github_repo()));
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,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub repo_name: Option<String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub truncate_at: Option<usize>,
322}
323
324#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
325pub struct ListSourceArgs {
326 #[serde(default = "default_path")]
328 pub path: String,
329 #[serde(default = "default_depth")]
331 pub depth: usize,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub glob: Option<String>,
335 #[serde(default)]
337 pub dirs_only: bool,
338}
339
340fn default_path() -> String {
341 ".".to_string()
342}
343fn default_depth() -> usize {
344 1
345}
346
347#[derive(Clone)]
352pub struct McpServer {
353 options: ServerOptions,
354 tool_router: ToolRouter<McpServer>,
355 prompt_router: PromptRouter<McpServer>,
360}
361
362#[tool_router]
363impl McpServer {
364 pub fn new(options: ServerOptions) -> Self {
365 let mut server = Self {
366 options,
367 tool_router: Self::tool_router(),
368 prompt_router: PromptRouter::new(),
369 };
370 server.register_github_tools_if_authorized();
371 server.register_local_workspace_tools();
372 server.gate_workspace_tools();
373 server
374 }
375
376 fn gate_workspace_tools(&mut self) {
384 if self.options.workspace.is_none() {
385 self.tool_router.remove_route("repo_management");
386 }
387 }
388
389 fn register_local_workspace_tools(&mut self) {
393 let Some(ws) = self.options.workspace.clone() else {
394 return;
395 };
396 if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
397 return;
398 }
399 self.register_typed_tool::<SetRootDirArgs, _>(
400 "set_root_dir",
401 "Swap the active source root (local-workspace mode only). Pass `path` \
402 to a directory; the framework canonicalises it, rebinds the source \
403 tools (`read_source`, `grep`, `list_source`), and fires the post-\
404 activate hook so any downstream graph rebuilds against the new root. \
405 Inventory persists across swaps; SHA-gating skips rebuilds when the \
406 same root is re-bound with no content changes.",
407 move |args: SetRootDirArgs| {
408 let p = std::path::PathBuf::from(&args.path);
409 ws.set_root_dir(&p)
410 },
411 );
412 }
413
414 fn register_github_tools_if_authorized(&mut self) {
420 if !crate::github::has_git_token() {
421 tracing::info!(
422 "GITHUB_TOKEN not set — github_issues / github_api tools hidden from the agent. \
423 Set the env var and restart to enable them."
424 );
425 return;
426 }
427 let default_repo = self.options.default_repo.clone();
428 let repo_provider = default_repo.clone();
429 let cache: Arc<Mutex<crate::cache::ElementCache>> =
435 Arc::new(Mutex::new(crate::cache::ElementCache::new()));
436 let cache_for_issues = cache.clone();
437 self.register_typed_tool::<GithubIssuesArgs, _>(
438 "github_issues",
439 "Search, list, or fetch GitHub issues / pull requests / Discussions. \
440 Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
441 for SEARCH (across issues+PRs and Discussions); neither for LIST. \
442 `kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
443 `state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
444 result count (default 20). `labels` is a comma-separated string. \
445 `repo_name=\"org/repo\"` overrides the active repo for one call. \
446 FETCH responses collapse big code blocks / patches / comments into \
447 `cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
448 `element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
449 element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
450 `refresh=true` bypasses the cache for re-fetch.",
451 move |args: GithubIssuesArgs| {
452 let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
453 Ok(r) => r,
454 Err(msg) => return msg,
455 };
456 if let Some(number) = args.number {
462 let context = args.context.unwrap_or(3);
463 let mut guard = cache_for_issues.lock().unwrap();
464 return guard.fetch_issue(
465 &repo,
466 number,
467 args.element_id.as_deref(),
468 args.lines.as_deref(),
469 args.grep.as_deref(),
470 context,
471 args.refresh,
472 );
473 }
474 if args.element_id.is_some() {
475 return "element_id requires `number=N` (the issue/PR being drilled into)."
476 .to_string();
477 }
478 crate::github::github_issues_rust(
480 Some(&repo),
481 args.number,
482 args.query.as_deref(),
483 &args.kind,
484 &args.state,
485 args.sort.as_deref(),
486 args.limit,
487 args.labels.as_deref(),
488 )
489 },
490 );
491 let repo_provider = default_repo;
492 self.register_typed_tool::<GithubApiArgs, _>(
493 "github_api",
494 "Read-only GET against the GitHub REST API. `path` may be a \
495 repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
496 \"branches\", \"compare/main...feature\") which is auto-prefixed \
497 with /repos/<repo_name>/, or a top-level resource (\"search/issues?q=...\", \
498 \"users/octocat\", \"repos/owner/name\") which passes through. A \
499 leading slash is optional and accepted on either form. Returns \
500 JSON, truncated at 80 KB by default.",
501 move |args: GithubApiArgs| match resolve_repo_from(
502 repo_provider.as_ref(),
503 args.repo_name.clone(),
504 ) {
505 Ok(repo) => {
506 let truncate_at = args.truncate_at.unwrap_or(80_000);
507 crate::github::git_api_internal(&repo, &args.path, truncate_at)
508 }
509 Err(msg) => msg,
510 },
511 );
512 }
513
514 pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
521 &self.options.builtins
522 }
523
524 pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
530 &mut self.tool_router
531 }
532
533 pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
538 &mut self.prompt_router
539 }
540
541 pub fn register_typed_tool<T, F>(
556 &mut self,
557 name: &'static str,
558 description: &'static str,
559 handler: F,
560 ) where
561 T: for<'de> serde::Deserialize<'de>
562 + schemars::JsonSchema
563 + Default
564 + Send
565 + Sync
566 + 'static,
567 F: Fn(T) -> String + Send + Sync + 'static,
568 {
569 use std::pin::Pin;
570 type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
571
572 let schema_obj = serde_json::to_value(schemars::schema_for!(T))
573 .ok()
574 .and_then(|v| v.as_object().cloned())
575 .unwrap_or_default();
576 let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
577 let handler = std::sync::Arc::new(handler);
578
579 self.tool_router
580 .add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
581 attr,
582 move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
583 -> DynFut<'_, Result<rmcp::model::CallToolResult, rmcp::ErrorData>> {
584 let handler = handler.clone();
585 let arguments = ctx.arguments.clone();
586 Box::pin(async move {
587 let args: T = match arguments {
588 Some(map) => {
589 match serde_json::from_value(serde_json::Value::Object(map)) {
590 Ok(a) => a,
591 Err(e) => {
592 return Ok(rmcp::model::CallToolResult::success(vec![
593 rmcp::model::Content::text(format!(
594 "invalid arguments: {e}"
595 )),
596 ]));
597 }
598 }
599 }
600 None => T::default(),
601 };
602 let body = handler(args);
603 Ok(rmcp::model::CallToolResult::success(vec![
604 rmcp::model::Content::text(body),
605 ]))
606 })
607 },
608 ));
609 }
610
611 fn current_source_roots(&self) -> Vec<String> {
612 match &self.options.source_roots {
613 Some(provider) => provider(),
614 None => Vec::new(),
615 }
616 }
617
618 #[allow(dead_code)]
623 fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
624 resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
625 }
626
627 #[tool(
628 description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
629 Use to confirm the server framework is wired correctly before \
630 relying on graph- or source-aware tools."
631 )]
632 async fn ping(
633 &self,
634 Parameters(args): Parameters<PingArgs>,
635 ) -> Result<CallToolResult, McpError> {
636 let body = args.message.unwrap_or_else(|| "pong".to_string());
637 Ok(CallToolResult::success(vec![Content::text(body)]))
638 }
639
640 #[tool(description = "Read a file from the configured source root(s). Pass \
641 `start_line`/`end_line` to slice, `grep` to filter to matching \
642 lines, `max_chars` to cap output. Path traversal attempts are \
643 rejected. Available only when source roots are configured.")]
644 async fn read_source(
645 &self,
646 Parameters(args): Parameters<ReadSourceArgs>,
647 ) -> Result<CallToolResult, McpError> {
648 let roots = self.current_source_roots();
649 if roots.is_empty() {
650 return Ok(CallToolResult::success(vec![Content::text(
651 "Cannot read source: no active source root. Configure source_root in your manifest \
652 or activate one (e.g. via repo_management in workspace mode).",
653 )]));
654 }
655 let opts = ReadOpts {
656 start_line: args.start_line,
657 end_line: args.end_line,
658 grep: args.grep,
659 grep_context: args.grep_context,
660 max_matches: args.max_matches,
661 max_chars: args.max_chars,
662 };
663 let body = source::read_source(&args.file_path, &roots, &opts);
664 Ok(CallToolResult::success(vec![Content::text(body)]))
665 }
666
667 #[tool(
668 description = "Search source files using ripgrep. `pattern` is a regex (Rust \
669 syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
670 N surrounding lines per match. Set `case_insensitive=true` for \
671 case-insensitive matching. `max_results` caps total matches \
672 (default 50)."
673 )]
674 async fn grep(
675 &self,
676 Parameters(args): Parameters<GrepArgs>,
677 ) -> Result<CallToolResult, McpError> {
678 let roots = self.current_source_roots();
679 if roots.is_empty() {
680 return Ok(CallToolResult::success(vec![Content::text(
681 "Cannot grep: no active source root. Configure source_root in your manifest \
682 or activate one (e.g. via repo_management in workspace mode).",
683 )]));
684 }
685 let opts = GrepOpts {
686 glob: args.glob,
687 context: args.context,
688 max_results: Some(args.max_results.unwrap_or(50)),
689 case_insensitive: args.case_insensitive,
690 };
691 let body = source::grep(&roots, &args.pattern, &opts);
692 Ok(CallToolResult::success(vec![Content::text(body)]))
693 }
694
695 #[tool(
696 description = "List directory contents under the configured source root. `path` \
697 is resolved against the first source root (\".\" lists the root \
698 itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
699 `glob` filters entry names. `dirs_only=true` shows only \
700 directories."
701 )]
702 async fn list_source(
703 &self,
704 Parameters(args): Parameters<ListSourceArgs>,
705 ) -> Result<CallToolResult, McpError> {
706 let roots = self.current_source_roots();
707 if roots.is_empty() {
708 return Ok(CallToolResult::success(vec![Content::text(
709 "Cannot list source: no active source root. Configure source_root in your \
710 manifest or activate one (e.g. via repo_management in workspace mode).",
711 )]));
712 }
713 let primary = std::path::PathBuf::from(&roots[0]);
714 let target = match resolve_dir_under_roots(&args.path, &roots) {
715 Some(p) => p,
716 None => {
717 return Ok(CallToolResult::success(vec![Content::text(format!(
718 "Error: path '{}' resolves outside the configured source roots.",
719 args.path
720 ))]));
721 }
722 };
723 let opts = ListOpts {
724 depth: args.depth,
725 glob: args.glob,
726 dirs_only: args.dirs_only,
727 };
728 let body = source::list_source(&target, &primary, &opts);
729 Ok(CallToolResult::success(vec![Content::text(body)]))
730 }
731
732 #[tool(
733 description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
734 clone (if missing) and activate it as the source root for \
735 read_source / grep / list_source. Pass `delete=true` to remove a \
736 repo. Pass `update=true` to fetch upstream changes for the active \
737 repo (rebuild auto-skipped when HEAD hasn't moved since the last \
738 build; set `force_rebuild=true` to bypass). Call with no \
739 arguments to list all known repos with their last-access counts. \
740 Idle repos auto-sweep on each call (default 7 days, configurable \
741 via --stale-after-days)."
742 )]
743 async fn repo_management(
744 &self,
745 Parameters(args): Parameters<RepoManagementArgs>,
746 ) -> Result<CallToolResult, McpError> {
747 let body = match &self.options.workspace {
748 Some(ws) => ws.repo_management(
749 args.name.as_deref(),
750 args.delete,
751 args.update,
752 args.force_rebuild,
753 ),
754 None => "repo_management requires --workspace mode.".to_string(),
755 };
756 Ok(CallToolResult::success(vec![Content::text(body)]))
757 }
758}
759
760fn resolve_repo_from(
768 default_repo: Option<&RepoProvider>,
769 override_repo: Option<String>,
770) -> Result<String, String> {
771 if let Some(r) = override_repo {
772 if let Some(err) = crate::git_refs::validate_repo(&r) {
773 return Err(err);
774 }
775 return Ok(r);
776 }
777 if let Some(provider) = default_repo {
778 if let Some(r) = provider() {
779 if let Some(err) = crate::git_refs::validate_repo(&r) {
780 return Err(err);
781 }
782 return Ok(r);
783 }
784 }
785 if let Some(detected) = crate::github::detect_git_repo(".") {
786 if crate::git_refs::validate_repo(&detected).is_none() {
787 return Ok(detected);
788 }
789 }
790 Err(
791 "No active repository. Pass `repo_name='org/repo'`, configure a default in the \
792 server, or run from a directory whose git remote points at github.com."
793 .to_string(),
794 )
795}
796
797pub fn serve_prompts(registry: &ResolvedRegistry, server: &mut McpServer) {
811 use std::borrow::Cow;
812 use std::collections::HashSet;
813
814 let registered_tools: HashSet<String> = server
819 .tool_router
820 .list_all()
821 .iter()
822 .map(|t| t.name.to_string())
823 .collect();
824 let extensions = server.options.extensions.clone();
825
826 let mut auto_inject: Vec<(String, String)> = Vec::new();
831
832 for name in registry.skill_names() {
833 let Some(skill) = registry.get(&name) else {
834 continue;
835 };
836
837 let activation = registry.activation_for(skill, ®istered_tools, &extensions);
841 if !activation.active {
842 let failed_clauses: Vec<&str> = activation
843 .clauses
844 .iter()
845 .filter(|(_, outcome)| {
846 *outcome != crate::server::skills::PredicateOutcome::Satisfied
847 })
848 .map(|(clause, _)| clause.as_str())
849 .collect();
850 tracing::info!(
851 skill = %name,
852 suppressed_by = ?failed_clauses,
853 "skill suppressed by applies_when predicates"
854 );
855 continue;
856 }
857
858 let prompt = Prompt::new(
859 skill.name().to_string(),
860 Some(skill.description().to_string()),
861 None,
862 );
863 let body = skill.body.clone();
864 let route = PromptRoute::new_dyn(prompt, move |_ctx| {
865 let body = body.clone();
866 Box::pin(async move {
867 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
868 PromptMessageRole::Assistant,
869 body,
870 )]))
871 })
872 });
873 server.prompt_router.add_route(route);
874
875 if skill.frontmatter.auto_inject_hint {
876 auto_inject.push((skill.name().to_string(), skill.body.clone()));
877 }
878 }
879
880 for (skill_name, body) in &auto_inject {
903 let key = Cow::<'static, str>::Owned(skill_name.clone());
904 if let Some(route) = server.tool_router.map.get_mut(&key) {
905 let trimmed_body = body.trim();
906 let inject = format!("\n\n## Methodology\n\n{trimmed_body}");
907 let new_desc = match route.attr.description.take() {
908 Some(existing) => format!("{existing}{inject}"),
909 None => inject.trim_start().to_string(),
910 };
911 route.attr.description = Some(Cow::Owned(new_desc));
912 }
913 }
914}
915
916#[tool_handler(router = self.tool_router)]
917impl ServerHandler for McpServer {
918 fn get_info(&self) -> ServerInfo {
919 let name = self
920 .options
921 .name
922 .clone()
923 .unwrap_or_else(|| "MCP Server".to_string());
924 let mut caps = ServerCapabilities::builder().enable_tools().build();
931 if !self.prompt_router.map.is_empty() {
932 caps.prompts = Some(PromptsCapability::default());
933 }
934 let mut info = ServerInfo::new(caps)
935 .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
936 .with_protocol_version(ProtocolVersion::V_2024_11_05);
937 if let Some(text) = &self.options.instructions {
938 info = info.with_instructions(text.clone());
939 }
940 info
941 }
942
943 async fn list_prompts(
944 &self,
945 _request: Option<PaginatedRequestParams>,
946 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
947 ) -> Result<ListPromptsResult, McpError> {
948 Ok(ListPromptsResult {
949 meta: None,
950 next_cursor: None,
951 prompts: self.prompt_router.list_all(),
952 })
953 }
954
955 async fn get_prompt(
956 &self,
957 request: GetPromptRequestParams,
958 context: rmcp::service::RequestContext<rmcp::RoleServer>,
959 ) -> Result<GetPromptResult, McpError> {
960 let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
961 self,
962 request.name,
963 request.arguments,
964 context,
965 );
966 self.prompt_router.get_prompt(prompt_context).await
967 }
968}
969
970#[cfg(test)]
971mod tests {
972 use super::*;
973
974 #[test]
975 fn options_from_manifest_uses_name_when_set() {
976 let opts = ServerOptions::from_manifest(None, "Fallback");
977 assert_eq!(opts.name.as_deref(), Some("Fallback"));
978 }
979
980 #[test]
981 fn builtins_exposed_via_server() {
982 use crate::server::manifest::{BuiltinsConfig, TempCleanup};
983 let opts = ServerOptions {
984 builtins: BuiltinsConfig {
985 save_graph: true,
986 temp_cleanup: TempCleanup::OnOverview,
987 },
988 ..ServerOptions::default()
989 };
990 let server = McpServer::new(opts);
991 assert!(server.builtins().save_graph);
992 assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
993 }
994
995 #[test]
996 fn server_constructs() {
997 let _server = McpServer::new(ServerOptions::default());
998 }
999
1000 #[test]
1001 fn static_source_roots_provider() {
1002 let opts = ServerOptions::default()
1003 .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
1004 let server = McpServer::new(opts);
1005 assert_eq!(
1006 server.current_source_roots(),
1007 vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
1008 );
1009 }
1010
1011 #[test]
1012 fn no_provider_returns_empty_roots() {
1013 let server = McpServer::new(ServerOptions::default());
1014 assert!(server.current_source_roots().is_empty());
1015 }
1016
1017 #[test]
1018 fn repo_management_gated_to_workspace_mode() {
1019 let server = McpServer::new(ServerOptions::default());
1022 let tools = server.tool_router.list_all();
1023 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1024 assert!(
1025 !names.contains(&"repo_management"),
1026 "repo_management should be gated out without a workspace; tools were {names:?}"
1027 );
1028 }
1029
1030 #[test]
1031 fn repo_management_present_when_workspace_bound() {
1032 use crate::server::workspace::Workspace;
1035 let dir = tempfile::tempdir().unwrap();
1036 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1037 let opts = ServerOptions::default().with_workspace(ws);
1038 let server = McpServer::new(opts);
1039 let tools = server.tool_router.list_all();
1040 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1041 assert!(
1042 names.contains(&"repo_management"),
1043 "repo_management should be registered with a workspace; tools were {names:?}"
1044 );
1045 }
1046
1047 #[test]
1048 fn dynamic_provider_swaps_at_call_time() {
1049 use std::sync::Mutex;
1050 let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
1051 let s2 = state.clone();
1052 let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
1053 let opts = ServerOptions::default().with_dynamic_source_roots(provider);
1054 let server = McpServer::new(opts);
1055 assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
1056 *state.lock().unwrap() = vec!["/swapped".to_string()];
1057 assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
1058 }
1059
1060 fn build_test_registry(
1063 skills: &[(&str, &str, &str, bool)],
1064 ) -> crate::server::skills::ResolvedRegistry {
1065 use crate::server::skills::Registry;
1066 let dir = tempfile::tempdir().unwrap();
1067 let yaml_path = dir.path().join("manifest.yaml");
1068 let skills_dir = dir.path().join("manifest.skills");
1069 std::fs::create_dir_all(&skills_dir).unwrap();
1070 for (name, description, body, auto_inject) in skills {
1071 let auto = if *auto_inject { "true" } else { "false" };
1072 let content = format!(
1073 "---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
1074 );
1075 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1076 }
1077 Registry::new()
1078 .auto_detect_project_layer(&yaml_path)
1079 .finalise()
1080 .unwrap()
1081 }
1082
1083 #[test]
1084 fn prompt_router_empty_by_default() {
1085 let server = McpServer::new(ServerOptions::default());
1086 assert!(server.prompt_router.map.is_empty());
1087 }
1088
1089 #[test]
1090 fn get_info_no_prompts_capability_when_empty() {
1091 let server = McpServer::new(ServerOptions::default());
1095 let info = server.get_info();
1096 assert!(
1097 info.capabilities.prompts.is_none(),
1098 "prompts capability must be absent when no skills are registered"
1099 );
1100 }
1101
1102 #[test]
1103 fn serve_prompts_registers_routes_with_metadata() {
1104 let registry = build_test_registry(&[
1105 ("alpha", "First skill.", "Alpha body.", true),
1106 ("beta", "Second skill.", "Beta body.", true),
1107 ]);
1108 let mut server = McpServer::new(ServerOptions::default());
1109 super::serve_prompts(®istry, &mut server);
1110
1111 let prompts = server.prompt_router.list_all();
1112 let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
1113 assert_eq!(names, vec!["alpha", "beta"]);
1114
1115 let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
1116 assert_eq!(alpha.description.as_deref(), Some("First skill."));
1117 assert!(alpha.arguments.is_none());
1118 }
1119
1120 #[test]
1121 fn serve_prompts_empty_registry_is_noop() {
1122 let registry = crate::server::skills::ResolvedRegistry::default();
1123 let mut server = McpServer::new(ServerOptions::default());
1124 super::serve_prompts(®istry, &mut server);
1125 assert!(server.prompt_router.map.is_empty());
1126 assert!(server.get_info().capabilities.prompts.is_none());
1127 }
1128
1129 #[test]
1130 fn get_info_advertises_prompts_when_present() {
1131 let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
1132 let mut server = McpServer::new(ServerOptions::default());
1133 super::serve_prompts(®istry, &mut server);
1134 let info = server.get_info();
1135 assert!(
1136 info.capabilities.prompts.is_some(),
1137 "prompts capability must be advertised once a skill is registered"
1138 );
1139 }
1140
1141 #[test]
1142 fn serve_prompts_auto_injects_full_body_into_matching_tool() {
1143 let registry =
1151 build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
1152 let mut server = McpServer::new(ServerOptions::default());
1153 let before = server
1154 .tool_router
1155 .get("ping")
1156 .and_then(|t| t.description.clone())
1157 .map(|c| c.into_owned())
1158 .unwrap_or_default();
1159 super::serve_prompts(®istry, &mut server);
1160 let after = server
1161 .tool_router
1162 .get("ping")
1163 .and_then(|t| t.description.clone())
1164 .map(|c| c.into_owned())
1165 .unwrap_or_default();
1166 assert!(after.starts_with(&before), "original description preserved");
1167 assert!(
1168 after.contains("## Methodology"),
1169 "inject should include a Methodology header; got: {after}"
1170 );
1171 assert!(
1172 after.contains("PING-BODY-SENTINEL"),
1173 "inject should embed the full skill body; got: {after}"
1174 );
1175 assert!(
1176 !after.contains("prompts/get"),
1177 "post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
1178 );
1179 }
1180
1181 #[test]
1182 fn serve_prompts_skips_injection_when_disabled() {
1183 let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
1184 let mut server = McpServer::new(ServerOptions::default());
1185 let before = server
1186 .tool_router
1187 .get("ping")
1188 .and_then(|t| t.description.clone())
1189 .map(|c| c.into_owned())
1190 .unwrap_or_default();
1191 super::serve_prompts(®istry, &mut server);
1192 let after = server
1193 .tool_router
1194 .get("ping")
1195 .and_then(|t| t.description.clone())
1196 .map(|c| c.into_owned())
1197 .unwrap_or_default();
1198 assert_eq!(
1199 before, after,
1200 "auto_inject_hint=false must leave tool description untouched"
1201 );
1202 }
1203
1204 #[test]
1205 fn serve_prompts_skips_injection_when_no_matching_tool() {
1206 let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
1209 let mut server = McpServer::new(ServerOptions::default());
1210 super::serve_prompts(®istry, &mut server);
1211 assert!(server.prompt_router.map.contains_key("no_such_tool"));
1212 let ping_desc = server
1215 .tool_router
1216 .get("ping")
1217 .and_then(|t| t.description.clone())
1218 .map(|c| c.into_owned())
1219 .unwrap_or_default();
1220 assert!(!ping_desc.contains("no_such_tool"));
1221 }
1222
1223 fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
1224 let dir = tempfile::tempdir().unwrap();
1225 let yaml = dir.path().join("test_mcp.yaml");
1226 std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1227 let skills_dir = dir.path().join("test_mcp.skills");
1228 std::fs::create_dir(&skills_dir).unwrap();
1229 std::fs::write(
1230 skills_dir.join("gated_skill.md"),
1231 format!(
1232 "---\n\
1233 name: gated_skill\n\
1234 description: A predicate-gated skill for testing.\n\
1235 applies_when:\n\
1236 {applies_when_yaml}\n\
1237 ---\n\n\
1238 Body.\n",
1239 ),
1240 )
1241 .unwrap();
1242 dir
1243 }
1244
1245 #[test]
1246 fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
1247 use crate::server::skills::Registry as SkillsBuilder;
1251 let dir = write_gated_project_skill(" tool_registered: nonexistent_tool");
1252 let yaml = dir.path().join("test_mcp.yaml");
1253 let registry = SkillsBuilder::new()
1254 .auto_detect_project_layer(&yaml)
1255 .finalise()
1256 .unwrap();
1257 let mut server = McpServer::new(ServerOptions::default());
1258 super::serve_prompts(®istry, &mut server);
1259 assert!(
1260 !server.prompt_router.map.contains_key("gated_skill"),
1261 "skill with unsatisfied predicate must be suppressed"
1262 );
1263 }
1264
1265 #[test]
1266 fn serve_prompts_keeps_skill_with_satisfied_predicate() {
1267 use crate::server::skills::Registry as SkillsBuilder;
1270 let dir = write_gated_project_skill(" tool_registered: ping");
1271 let yaml = dir.path().join("test_mcp.yaml");
1272 let registry = SkillsBuilder::new()
1273 .auto_detect_project_layer(&yaml)
1274 .finalise()
1275 .unwrap();
1276 let mut server = McpServer::new(ServerOptions::default());
1277 super::serve_prompts(®istry, &mut server);
1278 assert!(
1279 server.prompt_router.map.contains_key("gated_skill"),
1280 "skill with satisfied predicate must register"
1281 );
1282 }
1283
1284 #[test]
1285 fn serve_prompts_evaluates_extension_enabled_from_manifest() {
1286 use crate::server::skills::Registry as SkillsBuilder;
1290 let dir = write_gated_project_skill(" extension_enabled: csv_http_server");
1291 let yaml = dir.path().join("test_mcp.yaml");
1292 let registry = SkillsBuilder::new()
1293 .auto_detect_project_layer(&yaml)
1294 .finalise()
1295 .unwrap();
1296
1297 let mut server = McpServer::new(ServerOptions::default());
1299 super::serve_prompts(®istry, &mut server);
1300 assert!(!server.prompt_router.map.contains_key("gated_skill"));
1301
1302 let mut extensions = serde_json::Map::new();
1304 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1305 let opts = ServerOptions {
1306 extensions,
1307 ..ServerOptions::default()
1308 };
1309 let mut server = McpServer::new(opts);
1310 super::serve_prompts(®istry, &mut server);
1311 assert!(server.prompt_router.map.contains_key("gated_skill"));
1312 }
1313}