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 struct InjectSkill {
832 name: String,
833 description: String,
834 body: String,
835 references_tools: Vec<String>,
836 }
837 let mut auto_inject: Vec<InjectSkill> = Vec::new();
838
839 for name in registry.skill_names() {
840 let Some(skill) = registry.get(&name) else {
841 continue;
842 };
843
844 let activation = registry.activation_for(skill, ®istered_tools, &extensions);
848 if !activation.active {
849 let failed_clauses: Vec<&str> = activation
850 .clauses
851 .iter()
852 .filter(|(_, outcome)| {
853 *outcome != crate::server::skills::PredicateOutcome::Satisfied
854 })
855 .map(|(clause, _)| clause.as_str())
856 .collect();
857 tracing::info!(
858 skill = %name,
859 suppressed_by = ?failed_clauses,
860 "skill suppressed by applies_when predicates"
861 );
862 continue;
863 }
864
865 let prompt = Prompt::new(
866 skill.name().to_string(),
867 Some(skill.description().to_string()),
868 None,
869 );
870 let body = skill.body.clone();
871 let route = PromptRoute::new_dyn(prompt, move |_ctx| {
872 let body = body.clone();
873 Box::pin(async move {
874 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
875 PromptMessageRole::Assistant,
876 body,
877 )]))
878 })
879 });
880 server.prompt_router.add_route(route);
881
882 if skill.frontmatter.auto_inject_hint {
883 auto_inject.push(InjectSkill {
884 name: skill.name().to_string(),
885 description: skill.description().to_string(),
886 body: skill.body.clone(),
887 references_tools: skill.frontmatter.references_tools.clone(),
888 });
889 }
890 }
891
892 for inj in &auto_inject {
928 let mut targets: Vec<&str> = Vec::new();
931 let mut seen: HashSet<&str> = HashSet::new();
932 for tool in std::iter::once(inj.name.as_str())
933 .chain(inj.references_tools.iter().map(String::as_str))
934 {
935 if seen.insert(tool) {
936 targets.push(tool);
937 }
938 }
939
940 let marker = format!("<!-- mcp-skill:{} -->", inj.name);
943 let mut block = format!("\n\n{marker}");
944 let description = inj.description.trim();
945 if !description.is_empty() {
946 block.push_str("\n\n## When to use\n\n");
947 block.push_str(description);
948 }
949 block.push_str("\n\n## Methodology\n\n");
950 block.push_str(inj.body.trim());
951
952 for tool in targets {
953 let key = Cow::<'static, str>::Owned(tool.to_string());
954 let Some(route) = server.tool_router.map.get_mut(&key) else {
955 continue;
956 };
957 if route
960 .attr
961 .description
962 .as_deref()
963 .is_some_and(|d| d.contains(&marker))
964 {
965 continue;
966 }
967 let new_desc = match route.attr.description.take() {
968 Some(existing) => format!("{existing}{block}"),
969 None => block.trim_start().to_string(),
970 };
971 route.attr.description = Some(Cow::Owned(new_desc));
972 }
973 }
974}
975
976#[tool_handler(router = self.tool_router)]
977impl ServerHandler for McpServer {
978 fn get_info(&self) -> ServerInfo {
979 let name = self
980 .options
981 .name
982 .clone()
983 .unwrap_or_else(|| "MCP Server".to_string());
984 let mut caps = ServerCapabilities::builder().enable_tools().build();
991 if !self.prompt_router.map.is_empty() {
992 caps.prompts = Some(PromptsCapability::default());
993 }
994 let mut info = ServerInfo::new(caps)
995 .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
996 .with_protocol_version(ProtocolVersion::V_2024_11_05);
997 if let Some(text) = &self.options.instructions {
998 info = info.with_instructions(text.clone());
999 }
1000 info
1001 }
1002
1003 async fn list_prompts(
1004 &self,
1005 _request: Option<PaginatedRequestParams>,
1006 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
1007 ) -> Result<ListPromptsResult, McpError> {
1008 Ok(ListPromptsResult {
1009 meta: None,
1010 next_cursor: None,
1011 prompts: self.prompt_router.list_all(),
1012 })
1013 }
1014
1015 async fn get_prompt(
1016 &self,
1017 request: GetPromptRequestParams,
1018 context: rmcp::service::RequestContext<rmcp::RoleServer>,
1019 ) -> Result<GetPromptResult, McpError> {
1020 let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
1021 self,
1022 request.name,
1023 request.arguments,
1024 context,
1025 );
1026 self.prompt_router.get_prompt(prompt_context).await
1027 }
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032 use super::*;
1033
1034 #[test]
1035 fn options_from_manifest_uses_name_when_set() {
1036 let opts = ServerOptions::from_manifest(None, "Fallback");
1037 assert_eq!(opts.name.as_deref(), Some("Fallback"));
1038 }
1039
1040 #[test]
1041 fn builtins_exposed_via_server() {
1042 use crate::server::manifest::{BuiltinsConfig, TempCleanup};
1043 let opts = ServerOptions {
1044 builtins: BuiltinsConfig {
1045 save_graph: true,
1046 temp_cleanup: TempCleanup::OnOverview,
1047 },
1048 ..ServerOptions::default()
1049 };
1050 let server = McpServer::new(opts);
1051 assert!(server.builtins().save_graph);
1052 assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
1053 }
1054
1055 #[test]
1056 fn server_constructs() {
1057 let _server = McpServer::new(ServerOptions::default());
1058 }
1059
1060 #[test]
1061 fn static_source_roots_provider() {
1062 let opts = ServerOptions::default()
1063 .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
1064 let server = McpServer::new(opts);
1065 assert_eq!(
1066 server.current_source_roots(),
1067 vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
1068 );
1069 }
1070
1071 #[test]
1072 fn no_provider_returns_empty_roots() {
1073 let server = McpServer::new(ServerOptions::default());
1074 assert!(server.current_source_roots().is_empty());
1075 }
1076
1077 #[test]
1078 fn repo_management_gated_to_workspace_mode() {
1079 let server = McpServer::new(ServerOptions::default());
1082 let tools = server.tool_router.list_all();
1083 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1084 assert!(
1085 !names.contains(&"repo_management"),
1086 "repo_management should be gated out without a workspace; tools were {names:?}"
1087 );
1088 }
1089
1090 #[test]
1091 fn repo_management_present_when_workspace_bound() {
1092 use crate::server::workspace::Workspace;
1095 let dir = tempfile::tempdir().unwrap();
1096 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1097 let opts = ServerOptions::default().with_workspace(ws);
1098 let server = McpServer::new(opts);
1099 let tools = server.tool_router.list_all();
1100 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1101 assert!(
1102 names.contains(&"repo_management"),
1103 "repo_management should be registered with a workspace; tools were {names:?}"
1104 );
1105 }
1106
1107 #[test]
1108 fn dynamic_provider_swaps_at_call_time() {
1109 use std::sync::Mutex;
1110 let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
1111 let s2 = state.clone();
1112 let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
1113 let opts = ServerOptions::default().with_dynamic_source_roots(provider);
1114 let server = McpServer::new(opts);
1115 assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
1116 *state.lock().unwrap() = vec!["/swapped".to_string()];
1117 assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
1118 }
1119
1120 fn build_test_registry(
1123 skills: &[(&str, &str, &str, bool)],
1124 ) -> crate::server::skills::ResolvedRegistry {
1125 use crate::server::skills::Registry;
1126 let dir = tempfile::tempdir().unwrap();
1127 let yaml_path = dir.path().join("manifest.yaml");
1128 let skills_dir = dir.path().join("manifest.skills");
1129 std::fs::create_dir_all(&skills_dir).unwrap();
1130 for (name, description, body, auto_inject) in skills {
1131 let auto = if *auto_inject { "true" } else { "false" };
1132 let content = format!(
1133 "---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
1134 );
1135 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1136 }
1137 Registry::new()
1138 .auto_detect_project_layer(&yaml_path)
1139 .finalise()
1140 .unwrap()
1141 }
1142
1143 fn build_registry_with_refs(
1148 skills: &[(&str, &str, &str, &str)],
1149 ) -> crate::server::skills::ResolvedRegistry {
1150 use crate::server::skills::Registry;
1151 let dir = tempfile::tempdir().unwrap();
1152 let yaml_path = dir.path().join("manifest.yaml");
1153 let skills_dir = dir.path().join("manifest.skills");
1154 std::fs::create_dir_all(&skills_dir).unwrap();
1155 for (name, description, body, references_tools) in skills {
1156 let content = format!(
1157 "---\nname: {name}\ndescription: {description}\n\
1158 auto_inject_hint: true\nreferences_tools: {references_tools}\n---\n\n{body}\n"
1159 );
1160 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1161 }
1162 Registry::new()
1163 .auto_detect_project_layer(&yaml_path)
1164 .finalise()
1165 .unwrap()
1166 }
1167
1168 fn tool_desc(server: &McpServer, tool: &str) -> String {
1169 server
1170 .tool_router
1171 .get(tool)
1172 .and_then(|t| t.description.clone())
1173 .map(|c| c.into_owned())
1174 .unwrap_or_default()
1175 }
1176
1177 #[test]
1178 fn prompt_router_empty_by_default() {
1179 let server = McpServer::new(ServerOptions::default());
1180 assert!(server.prompt_router.map.is_empty());
1181 }
1182
1183 #[test]
1184 fn get_info_no_prompts_capability_when_empty() {
1185 let server = McpServer::new(ServerOptions::default());
1189 let info = server.get_info();
1190 assert!(
1191 info.capabilities.prompts.is_none(),
1192 "prompts capability must be absent when no skills are registered"
1193 );
1194 }
1195
1196 #[test]
1197 fn serve_prompts_registers_routes_with_metadata() {
1198 let registry = build_test_registry(&[
1199 ("alpha", "First skill.", "Alpha body.", true),
1200 ("beta", "Second skill.", "Beta body.", true),
1201 ]);
1202 let mut server = McpServer::new(ServerOptions::default());
1203 super::serve_prompts(®istry, &mut server);
1204
1205 let prompts = server.prompt_router.list_all();
1206 let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
1207 assert_eq!(names, vec!["alpha", "beta"]);
1208
1209 let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
1210 assert_eq!(alpha.description.as_deref(), Some("First skill."));
1211 assert!(alpha.arguments.is_none());
1212 }
1213
1214 #[test]
1215 fn serve_prompts_empty_registry_is_noop() {
1216 let registry = crate::server::skills::ResolvedRegistry::default();
1217 let mut server = McpServer::new(ServerOptions::default());
1218 super::serve_prompts(®istry, &mut server);
1219 assert!(server.prompt_router.map.is_empty());
1220 assert!(server.get_info().capabilities.prompts.is_none());
1221 }
1222
1223 #[test]
1224 fn get_info_advertises_prompts_when_present() {
1225 let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
1226 let mut server = McpServer::new(ServerOptions::default());
1227 super::serve_prompts(®istry, &mut server);
1228 let info = server.get_info();
1229 assert!(
1230 info.capabilities.prompts.is_some(),
1231 "prompts capability must be advertised once a skill is registered"
1232 );
1233 }
1234
1235 #[test]
1236 fn serve_prompts_auto_injects_full_body_into_matching_tool() {
1237 let registry =
1245 build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
1246 let mut server = McpServer::new(ServerOptions::default());
1247 let before = server
1248 .tool_router
1249 .get("ping")
1250 .and_then(|t| t.description.clone())
1251 .map(|c| c.into_owned())
1252 .unwrap_or_default();
1253 super::serve_prompts(®istry, &mut server);
1254 let after = server
1255 .tool_router
1256 .get("ping")
1257 .and_then(|t| t.description.clone())
1258 .map(|c| c.into_owned())
1259 .unwrap_or_default();
1260 assert!(after.starts_with(&before), "original description preserved");
1261 assert!(
1262 after.contains("## Methodology"),
1263 "inject should include a Methodology header; got: {after}"
1264 );
1265 assert!(
1266 after.contains("PING-BODY-SENTINEL"),
1267 "inject should embed the full skill body; got: {after}"
1268 );
1269 assert!(
1270 !after.contains("prompts/get"),
1271 "post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
1272 );
1273 }
1274
1275 #[test]
1276 fn serve_prompts_skips_injection_when_disabled() {
1277 let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
1278 let mut server = McpServer::new(ServerOptions::default());
1279 let before = server
1280 .tool_router
1281 .get("ping")
1282 .and_then(|t| t.description.clone())
1283 .map(|c| c.into_owned())
1284 .unwrap_or_default();
1285 super::serve_prompts(®istry, &mut server);
1286 let after = server
1287 .tool_router
1288 .get("ping")
1289 .and_then(|t| t.description.clone())
1290 .map(|c| c.into_owned())
1291 .unwrap_or_default();
1292 assert_eq!(
1293 before, after,
1294 "auto_inject_hint=false must leave tool description untouched"
1295 );
1296 }
1297
1298 #[test]
1299 fn serve_prompts_skips_injection_when_no_matching_tool() {
1300 let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
1303 let mut server = McpServer::new(ServerOptions::default());
1304 super::serve_prompts(®istry, &mut server);
1305 assert!(server.prompt_router.map.contains_key("no_such_tool"));
1306 let ping_desc = server
1309 .tool_router
1310 .get("ping")
1311 .and_then(|t| t.description.clone())
1312 .map(|c| c.into_owned())
1313 .unwrap_or_default();
1314 assert!(!ping_desc.contains("no_such_tool"));
1315 }
1316
1317 #[test]
1318 fn serve_prompts_injects_description_under_when_to_use() {
1319 let registry = build_test_registry(&[("ping", "ROUTING-SENTINEL", "BODY-SENTINEL", true)]);
1323 let mut server = McpServer::new(ServerOptions::default());
1324 super::serve_prompts(®istry, &mut server);
1325 let desc = tool_desc(&server, "ping");
1326 assert!(
1327 desc.contains("## When to use\n\nROUTING-SENTINEL"),
1328 "description should be injected under `## When to use`; got: {desc}"
1329 );
1330 assert!(
1331 desc.contains("<!-- mcp-skill:ping -->"),
1332 "injection should carry the per-skill idempotency marker; got: {desc}"
1333 );
1334 let when = desc.find("## When to use").unwrap();
1336 let method = desc.find("## Methodology").unwrap();
1337 assert!(when < method, "`When to use` must precede `Methodology`");
1338 }
1339
1340 #[test]
1341 fn serve_prompts_honors_references_tools() {
1342 let registry = build_registry_with_refs(&[(
1346 "graph_strategy",
1347 "Map structure first.",
1348 "GRAPH-BODY-SENTINEL",
1349 "[ping]",
1350 )]);
1351 let mut server = McpServer::new(ServerOptions::default());
1352 super::serve_prompts(®istry, &mut server);
1353 assert!(server.prompt_router.map.contains_key("graph_strategy"));
1355 let desc = tool_desc(&server, "ping");
1357 assert!(
1358 desc.contains("<!-- mcp-skill:graph_strategy -->"),
1359 "referenced tool should carry the skill marker; got: {desc}"
1360 );
1361 assert!(
1362 desc.contains("Map structure first."),
1363 "referenced tool should carry the skill routing; got: {desc}"
1364 );
1365 assert!(
1366 desc.contains("GRAPH-BODY-SENTINEL"),
1367 "referenced tool should carry the skill body; got: {desc}"
1368 );
1369 }
1370
1371 #[test]
1372 fn serve_prompts_idempotent_when_skill_self_references() {
1373 let registry = build_registry_with_refs(&[("ping", "Routing.", "Body.", "[ping]")]);
1377 let mut server = McpServer::new(ServerOptions::default());
1378 super::serve_prompts(®istry, &mut server);
1379 let desc = tool_desc(&server, "ping");
1380 let marker_count = desc.matches("<!-- mcp-skill:ping -->").count();
1381 assert_eq!(
1382 marker_count, 1,
1383 "self-referencing skill must inject exactly once; got {marker_count}: {desc}"
1384 );
1385 }
1386
1387 #[test]
1388 fn serve_prompts_idempotent_across_repeated_passes() {
1389 let registry = build_test_registry(&[("ping", "Routing.", "Body.", true)]);
1392 let mut server = McpServer::new(ServerOptions::default());
1393 super::serve_prompts(®istry, &mut server);
1394 let once = tool_desc(&server, "ping");
1395 super::serve_prompts(®istry, &mut server);
1396 let twice = tool_desc(&server, "ping");
1397 assert_eq!(
1398 once, twice,
1399 "second pass must be a no-op for an already-injected tool"
1400 );
1401 }
1402
1403 #[test]
1404 fn serve_prompts_multiple_skills_stack_on_one_tool() {
1405 let registry = build_registry_with_refs(&[
1409 ("ping", "Ping routing.", "PING-BODY", "[]"),
1410 ("ping_strategy", "Strategy routing.", "STRAT-BODY", "[ping]"),
1411 ]);
1412 let mut server = McpServer::new(ServerOptions::default());
1413 super::serve_prompts(®istry, &mut server);
1414 let desc = tool_desc(&server, "ping");
1415 assert!(desc.contains("<!-- mcp-skill:ping -->"), "got: {desc}");
1416 assert!(
1417 desc.contains("<!-- mcp-skill:ping_strategy -->"),
1418 "got: {desc}"
1419 );
1420 assert!(
1421 desc.contains("PING-BODY") && desc.contains("STRAT-BODY"),
1422 "got: {desc}"
1423 );
1424 }
1425
1426 fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
1427 let dir = tempfile::tempdir().unwrap();
1428 let yaml = dir.path().join("test_mcp.yaml");
1429 std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1430 let skills_dir = dir.path().join("test_mcp.skills");
1431 std::fs::create_dir(&skills_dir).unwrap();
1432 std::fs::write(
1433 skills_dir.join("gated_skill.md"),
1434 format!(
1435 "---\n\
1436 name: gated_skill\n\
1437 description: A predicate-gated skill for testing.\n\
1438 applies_when:\n\
1439 {applies_when_yaml}\n\
1440 ---\n\n\
1441 Body.\n",
1442 ),
1443 )
1444 .unwrap();
1445 dir
1446 }
1447
1448 #[test]
1449 fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
1450 use crate::server::skills::Registry as SkillsBuilder;
1454 let dir = write_gated_project_skill(" tool_registered: nonexistent_tool");
1455 let yaml = dir.path().join("test_mcp.yaml");
1456 let registry = SkillsBuilder::new()
1457 .auto_detect_project_layer(&yaml)
1458 .finalise()
1459 .unwrap();
1460 let mut server = McpServer::new(ServerOptions::default());
1461 super::serve_prompts(®istry, &mut server);
1462 assert!(
1463 !server.prompt_router.map.contains_key("gated_skill"),
1464 "skill with unsatisfied predicate must be suppressed"
1465 );
1466 }
1467
1468 #[test]
1469 fn serve_prompts_keeps_skill_with_satisfied_predicate() {
1470 use crate::server::skills::Registry as SkillsBuilder;
1473 let dir = write_gated_project_skill(" tool_registered: ping");
1474 let yaml = dir.path().join("test_mcp.yaml");
1475 let registry = SkillsBuilder::new()
1476 .auto_detect_project_layer(&yaml)
1477 .finalise()
1478 .unwrap();
1479 let mut server = McpServer::new(ServerOptions::default());
1480 super::serve_prompts(®istry, &mut server);
1481 assert!(
1482 server.prompt_router.map.contains_key("gated_skill"),
1483 "skill with satisfied predicate must register"
1484 );
1485 }
1486
1487 #[test]
1488 fn serve_prompts_evaluates_extension_enabled_from_manifest() {
1489 use crate::server::skills::Registry as SkillsBuilder;
1493 let dir = write_gated_project_skill(" extension_enabled: csv_http_server");
1494 let yaml = dir.path().join("test_mcp.yaml");
1495 let registry = SkillsBuilder::new()
1496 .auto_detect_project_layer(&yaml)
1497 .finalise()
1498 .unwrap();
1499
1500 let mut server = McpServer::new(ServerOptions::default());
1502 super::serve_prompts(®istry, &mut server);
1503 assert!(!server.prompt_router.map.contains_key("gated_skill"));
1504
1505 let mut extensions = serde_json::Map::new();
1507 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1508 let opts = ServerOptions {
1509 extensions,
1510 ..ServerOptions::default()
1511 };
1512 let mut server = McpServer::new(opts);
1513 super::serve_prompts(®istry, &mut server);
1514 assert!(server.prompt_router.map.contains_key("gated_skill"));
1515 }
1516}