1#![allow(dead_code)]
37
38use std::sync::{Arc, Mutex};
39
40use rmcp::handler::server::router::prompt::{PromptRoute, PromptRouter};
41use rmcp::handler::server::router::tool::ToolRouter;
42use rmcp::handler::server::wrapper::Parameters;
43use rmcp::model::*;
44use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
45use serde::{Deserialize, Serialize};
46
47use crate::server::manifest::Manifest;
48use crate::server::skills::ResolvedRegistry;
49use crate::server::source::{
50 self, resolve_dir_under_roots, GrepOpts, ListOpts, ReadOpts, SourceRootsProvider,
51};
52
53pub type RepoProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
57
58pub struct ResultCtx {
63 pub source_roots: Vec<String>,
65 pub active_repo: Option<String>,
68}
69
70pub type ResultPostprocessHook =
84 Arc<dyn Fn(&str, &serde_json::Value, &str, &ResultCtx) -> Option<String> + Send + Sync>;
85
86fn append_footer(body: String, footer: Option<String>) -> String {
90 match footer {
91 Some(f) if !f.is_empty() => format!("{body}\n\n{f}"),
92 _ => body,
93 }
94}
95
96fn dispatch_typed_call<T, F>(
112 tool_name: &str,
113 arguments: Option<rmcp::model::JsonObject>,
114 handler: &F,
115 postprocess: Option<&ResultPostprocessHook>,
116 source_roots: Option<&SourceRootsProvider>,
117 workspace: Option<&crate::server::workspace::Workspace>,
118) -> rmcp::model::CallToolResult
119where
120 T: for<'de> serde::Deserialize<'de> + Default,
121 F: Fn(T) -> Result<String, String>,
122{
123 let args_json = match &arguments {
126 Some(map) => serde_json::Value::Object(map.clone()),
127 None => serde_json::Value::Null,
128 };
129 let outcome = match arguments {
130 Some(map) => match serde_json::from_value::<T>(serde_json::Value::Object(map)) {
131 Ok(args) => handler(args),
132 Err(e) => Err(format!("invalid arguments: {e}")),
133 },
134 None => handler(T::default()),
135 };
136 let is_error = outcome.is_err();
137 let body = match outcome {
138 Ok(body) | Err(body) => body,
139 };
140 let body = match postprocess {
141 Some(hook) => {
142 let ctx = ResultCtx {
143 source_roots: source_roots.map(|p| p()).unwrap_or_default(),
144 active_repo: workspace.and_then(|w| w.active_repo_name()),
145 };
146 let footer = hook(tool_name, &args_json, &body, &ctx);
147 append_footer(body, footer)
148 }
149 None => body,
150 };
151 let content = vec![rmcp::model::ContentBlock::text(body)];
152 if is_error {
153 rmcp::model::CallToolResult::error(content)
154 } else {
155 rmcp::model::CallToolResult::success(content)
156 }
157}
158
159#[derive(Clone, Default)]
161pub struct ServerOptions {
162 pub name: Option<String>,
164 pub instructions: Option<String>,
166 pub source_roots: Option<SourceRootsProvider>,
169 pub default_repo: Option<RepoProvider>,
172 pub workspace: Option<crate::server::workspace::Workspace>,
174 pub builtins: crate::server::manifest::BuiltinsConfig,
179 pub extensions: serde_json::Map<String, serde_json::Value>,
184 pub result_postprocess: Option<ResultPostprocessHook>,
188}
189
190impl std::fmt::Debug for ServerOptions {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 f.debug_struct("ServerOptions")
193 .field("name", &self.name)
194 .field("instructions", &self.instructions)
195 .field(
196 "source_roots",
197 &self.source_roots.as_ref().map(|_| "<provider>"),
198 )
199 .field(
200 "default_repo",
201 &self.default_repo.as_ref().map(|_| "<provider>"),
202 )
203 .finish()
204 }
205}
206
207impl ServerOptions {
208 pub fn from_manifest(manifest: Option<&Manifest>, fallback_name: &str) -> Self {
209 Self {
210 name: manifest
211 .and_then(|m| m.name.clone())
212 .or_else(|| Some(fallback_name.to_string())),
213 instructions: manifest.and_then(|m| m.instructions.clone()),
214 source_roots: None,
215 default_repo: None,
216 workspace: None,
217 builtins: manifest.map(|m| m.builtins.clone()).unwrap_or_default(),
218 extensions: manifest.map(|m| m.extensions.clone()).unwrap_or_default(),
219 result_postprocess: None,
220 }
221 }
222
223 pub fn with_static_source_roots(mut self, roots: Vec<String>) -> Self {
224 let captured = Arc::new(roots);
225 self.source_roots = Some(Arc::new(move || captured.as_ref().clone()));
226 self
227 }
228
229 pub fn with_dynamic_source_roots(mut self, provider: SourceRootsProvider) -> Self {
230 self.source_roots = Some(provider);
231 self
232 }
233
234 pub fn with_static_repo(mut self, repo: String) -> Self {
235 self.default_repo = Some(Arc::new(move || Some(repo.clone())));
236 self
237 }
238
239 pub fn with_dynamic_repo(mut self, provider: RepoProvider) -> Self {
240 self.default_repo = Some(provider);
241 self
242 }
243
244 pub fn with_workspace(mut self, ws: crate::server::workspace::Workspace) -> Self {
249 let ws_for_roots = ws.clone();
250 let ws_for_repo = ws.clone();
251 self.workspace = Some(ws);
252 self.source_roots = Some(Arc::new(move || {
253 ws_for_roots
254 .active_repo_path()
255 .map(|p| vec![p.to_string_lossy().into_owned()])
256 .unwrap_or_default()
257 }));
258 self.default_repo = Some(Arc::new(move || ws_for_repo.default_github_repo()));
259 self
260 }
261
262 pub fn with_result_postprocess(mut self, hook: ResultPostprocessHook) -> Self {
267 self.result_postprocess = Some(hook);
268 self
269 }
270}
271
272#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
273pub struct PingArgs {
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub message: Option<String>,
277}
278
279#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
280pub struct ReadSourceArgs {
281 pub file_path: String,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub start_line: Option<usize>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub end_line: Option<usize>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub grep: Option<String>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub grep_context: Option<usize>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub max_matches: Option<usize>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub max_chars: Option<usize>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub rev: Option<String>,
308}
309
310#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
311pub struct GrepArgs {
312 pub pattern: String,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub glob: Option<String>,
317 #[serde(default)]
319 pub context: usize,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub max_results: Option<usize>,
323 #[serde(default)]
325 pub case_insensitive: bool,
326}
327
328#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
329pub struct SetRootDirArgs {
330 pub path: String,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub revs: Option<crate::server::workspace::RevsRequest>,
341}
342
343#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
344pub struct RepoManagementArgs {
345 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub name: Option<String>,
348 #[serde(default)]
350 pub delete: bool,
351 #[serde(default)]
353 pub update: bool,
354 #[serde(default)]
358 pub force_rebuild: bool,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub revs: Option<crate::server::workspace::RevsRequest>,
368}
369
370#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
371pub struct GithubIssuesArgs {
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub number: Option<u64>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub repo_name: Option<String>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub query: Option<String>,
381 #[serde(default = "default_kind")]
383 pub kind: String,
384 #[serde(default = "default_state")]
386 pub state: String,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub sort: Option<String>,
390 #[serde(default = "default_limit")]
392 pub limit: usize,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub labels: Option<String>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub element_id: Option<String>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub lines: Option<String>,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub grep: Option<String>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub context: Option<usize>,
415 #[serde(default)]
418 pub refresh: bool,
419}
420
421fn default_kind() -> String {
422 "all".to_string()
423}
424fn default_state() -> String {
425 "open".to_string()
426}
427fn default_limit() -> usize {
428 20
429}
430
431impl Default for GithubIssuesArgs {
432 fn default() -> Self {
433 Self {
434 number: None,
435 repo_name: None,
436 query: None,
437 kind: default_kind(),
438 state: default_state(),
439 sort: None,
440 limit: default_limit(),
441 labels: None,
442 element_id: None,
443 lines: None,
444 grep: None,
445 context: None,
446 refresh: false,
447 }
448 }
449}
450
451#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
452pub struct GithubApiArgs {
453 pub path: String,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub repo_name: Option<String>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub truncate_at: Option<usize>,
466}
467
468#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
469pub struct ListSourceArgs {
470 #[serde(default = "default_path")]
472 pub path: String,
473 #[serde(default = "default_depth")]
475 pub depth: usize,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub glob: Option<String>,
479 #[serde(default)]
481 pub dirs_only: bool,
482}
483
484fn default_path() -> String {
485 ".".to_string()
486}
487fn default_depth() -> usize {
488 1
489}
490
491#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
492pub struct ScreenStargazersArgs {
493 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub repo: Option<String>,
496 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub users: Option<String>,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub preset: Option<String>,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub rank_by: Option<String>,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub top: Option<usize>,
511 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub min_keywords: Option<usize>,
514 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub active_since: Option<String>,
517 #[serde(default)]
519 pub adopters_only: bool,
520 #[serde(default)]
522 pub stack_only: bool,
523 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub keywords: Option<String>,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub stack: Option<String>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub max_stargazers: Option<usize>,
537 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub element_id: Option<String>,
542 #[serde(default)]
544 pub refresh: bool,
545}
546
547#[derive(Clone)]
552pub struct McpServer {
553 options: ServerOptions,
554 tool_router: ToolRouter<McpServer>,
555 prompt_router: PromptRouter<McpServer>,
560}
561
562#[tool_router]
563impl McpServer {
564 pub fn new(options: ServerOptions) -> Self {
565 let mut server = Self {
566 options,
567 tool_router: Self::tool_router(),
568 prompt_router: PromptRouter::new(),
569 };
570 server.register_github_tools_if_authorized();
571 server.register_local_workspace_tools();
572 server.gate_workspace_tools();
573 server
574 }
575
576 fn gate_workspace_tools(&mut self) {
584 if self.options.workspace.is_none() {
585 self.tool_router.remove_route("repo_management");
586 }
587 }
588
589 fn register_local_workspace_tools(&mut self) {
593 let Some(ws) = self.options.workspace.clone() else {
594 return;
595 };
596 if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
597 return;
598 }
599 self.register_typed_tool::<SetRootDirArgs, _>(
600 "set_root_dir",
601 "Swap the active source root (local-workspace mode only). Pass `path` \
602 to a directory; the framework canonicalises it, rebinds the source \
603 tools (`read_source`, `grep`, `list_source`), and fires the post-\
604 activate hook so any downstream graph rebuilds against the new root. \
605 Pass `revs` (an integer N, or a list of git revspecs) to load multiple \
606 revisions of the root into one graph — N loads the newest N stable \
607 release tags of the dominant tag family plus HEAD (prereleases and \
608 unrelated tag families skipped); requires the root to be a git repo. \
609 Inventory persists across swaps; SHA-gating skips rebuilds when \
610 the same root is re-bound with no content changes.",
611 move |args: SetRootDirArgs| {
612 let p = std::path::PathBuf::from(&args.path);
613 ws.set_root_dir(&p, args.revs.as_ref())
614 },
615 );
616 }
617
618 fn register_github_tools_if_authorized(&mut self) {
635 if !self.options.builtins.github {
636 tracing::debug!(
640 "GitHub tools disabled (default) — set `builtins.github: true` in the manifest \
641 to register github_issues / github_api / screen_stargazers."
642 );
643 return;
644 }
645 if !crate::github::has_git_token() {
646 tracing::info!(
647 "`builtins.github: true` is set but no GitHub token is reachable — \
648 github_issues / github_api tools hidden from the agent. Set GITHUB_TOKEN \
649 (env or the manifest's env_file) and restart to enable them."
650 );
651 return;
652 }
653 let default_repo = self.options.default_repo.clone();
654 let repo_provider = default_repo.clone();
655 let cache: Arc<Mutex<crate::cache::ElementCache>> =
661 Arc::new(Mutex::new(crate::cache::ElementCache::new()));
662 let cache_for_issues = cache.clone();
663 self.register_typed_tool::<GithubIssuesArgs, _>(
664 "github_issues",
665 "Search, list, or fetch GitHub issues / pull requests / Discussions. \
666 Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
667 for SEARCH (across issues+PRs and Discussions); neither for LIST. \
668 `kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
669 `state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
670 result count (default 20). `labels` is a comma-separated string. \
671 `repo_name=\"org/repo\"` overrides the active repo for one call. \
672 FETCH responses collapse big code blocks / patches / comments into \
673 `cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
674 `element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
675 element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
676 `refresh=true` bypasses the cache for re-fetch.",
677 move |args: GithubIssuesArgs| {
678 let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
679 Ok(r) => r,
680 Err(msg) => return msg,
681 };
682 if let Some(number) = args.number {
688 let context = args.context.unwrap_or(3);
689 let mut guard = cache_for_issues.lock().unwrap();
690 return guard.fetch_issue(
691 &repo,
692 number,
693 args.element_id.as_deref(),
694 args.lines.as_deref(),
695 args.grep.as_deref(),
696 context,
697 args.refresh,
698 );
699 }
700 if args.element_id.is_some() {
701 return "element_id requires `number=N` (the issue/PR being drilled into)."
702 .to_string();
703 }
704 crate::github::github_issues_rust(
706 Some(&repo),
707 args.number,
708 args.query.as_deref(),
709 &args.kind,
710 &args.state,
711 args.sort.as_deref(),
712 args.limit,
713 args.labels.as_deref(),
714 )
715 },
716 );
717 let repo_provider = default_repo.clone();
718 let repo_for_screen = default_repo;
719 self.register_typed_tool::<GithubApiArgs, _>(
720 "github_api",
721 "Read-only GET against the GitHub REST API. `path` may be a \
722 repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
723 \"branches\", \"compare/main...feature\") which is auto-prefixed \
724 with /repos/<repo_name>/, or a top-level resource (\"search/issues?q=...\", \
725 \"users/octocat\", \"repos/owner/name\") which passes through. A \
726 leading slash is optional and accepted on either form. Returns \
727 JSON, truncated at 80 KB by default.",
728 move |args: GithubApiArgs| match resolve_repo_from(
729 repo_provider.as_ref(),
730 args.repo_name.clone(),
731 ) {
732 Ok(repo) => {
733 let truncate_at = args.truncate_at.unwrap_or(80_000);
734 crate::github::git_api_internal(&repo, &args.path, truncate_at)
735 }
736 Err(msg) => msg,
737 },
738 );
739
740 if self.options.builtins.screen_stargazers {
750 let screen_store: Arc<Mutex<crate::screen::ScreenStore>> =
751 Arc::new(Mutex::new(crate::screen::ScreenStore::new()));
752 self.register_typed_tool::<ScreenStargazersArgs, _>(
753 "screen_stargazers",
754 "Screen the people around a GitHub project to find relevant developers, \
755 notable/legendary devs, architectural peers, and actual users — cheaply. \
756 Seed on a repo (`repo=\"owner/repo\"` → screens its stargazers) OR an \
757 explicit user list (`users=\"alice,bob\"` → screens them directly). With \
758 just a repo it auto-derives relevance keywords + tech stack from the repo \
759 itself, bulk-fetches each person's public repo portfolio over plain REST \
760 (~1 request per person, no GraphQL, no READMEs), classifies them, and \
761 enriches a bounded shortlist with follower counts, dependency-adoption, \
762 stack co-location, and contributions. Every person gets a normalized \
763 0–100 score vector on four axes — relatedness, popularity, effort, \
764 recency. RANK/FILTER: pass a `preset` (\"outreach\"=relevant+active by \
765 reach, \"peers\"=your stack by effort, \"legends\"=biggest reach any \
766 domain, \"intel\"=on-domain by popularity, \"adopters\"=actual users), or \
767 `rank_by`=relatedness|popularity|effort|recency with filters \
768 (`min_keywords`, `active_since`, `adopters_only`, `stack_only`) and \
769 `top`=N (rank-then-take-N, default 10) for a focused filter→rank→take \
770 view; with none, the full multi-lens browse: \
771 `✅ ADOPTERS` (stargazers whose repos actually declare your package as a \
772 dependency — real users, not just watchers), `★ MOST RELEVANT` \
773 (relatedness — repos matching your topic keywords, with follower counts \
774 and external contributions), `🏆 NOTABLE` (popularity/reach lens — your \
775 highest-traction stargazers, flagged `LEGEND` for big audiences/projects), \
776 `✦ QUALITY` (best-kept maintained projects), `⚙ STACK MATCH` (architectural \
777 peers who build in your stack — co-location-confirmed where possible), and \
778 a cohort inventory. Override the auto-config with `keywords=\"graph,rag,agent\"` \
779 (single words — \"knowledge,graph\" not \"knowledge-graph\") and \
780 `stack=\"Rust,Python\"`; re-calling with new values re-ranks the cached \
781 fetch for free. Treat description-based leads as candidates to verify by \
782 drilling. DRILL via `element_id`: `\"cohort:<key>\"` (established / single / \
783 prolific / casual / dormant / consumers — the overview lists each key), \
784 `\"user:<login>\"` (portfolio), `\"user:<login>/repo:<name>\"` (repo profile), \
785 or `\"user:<login>/repo:<name>/readme\"` (README gist — the only drill that \
786 costs a request). `max_stargazers` samples the most-recent N (the overview \
787 reports if results are partial); `refresh=true` re-fetches.",
788 move |args: ScreenStargazersArgs| {
789 use crate::screen::{self, Filters, RankBy, Seed, Selection};
790 let split_csv = |s: Option<String>| -> Vec<String> {
791 s.map(|v| {
792 v.split(',')
793 .map(|t| t.trim().to_string())
794 .filter(|t| !t.is_empty())
795 .collect()
796 })
797 .unwrap_or_default()
798 };
799 let seed = if let Some(u) = &args.users {
801 Seed::Users(split_csv(Some(u.clone())))
802 } else {
803 let repo =
804 match resolve_repo_from(repo_for_screen.as_ref(), args.repo.clone()) {
805 Ok(r) => r,
806 Err(msg) => return msg,
807 };
808 if let Some(err) = crate::git_refs::validate_repo(&repo) {
809 return err;
810 }
811 Seed::Repo(repo)
812 };
813 let cfg = screen::ScreenConfig {
814 max_stargazers: args.max_stargazers,
815 max_repos_per_user: 100,
816 relevance_keywords: split_csv(args.keywords)
817 .into_iter()
818 .map(|k| k.to_lowercase())
819 .collect(),
820 stack_languages: split_csv(args.stack),
821 };
822 let top = args.top.unwrap_or(10);
824 let filters = Filters {
825 min_keywords: args.min_keywords,
826 active_since: args.active_since.clone(),
827 adopters_only: args.adopters_only,
828 stack_only: args.stack_only,
829 ..Default::default()
830 };
831 let filters_active = filters.min_keywords.is_some()
832 || filters.active_since.is_some()
833 || filters.adopters_only
834 || filters.stack_only;
835 let selection: Option<Selection> = if let Some(name) = &args.preset {
836 screen::preset(name, top)
837 } else if args.rank_by.is_some() || filters_active {
838 Some(Selection {
839 filters,
840 rank: args
841 .rank_by
842 .as_deref()
843 .and_then(RankBy::parse)
844 .unwrap_or(RankBy::Relatedness),
845 label: "SELECTION".into(),
846 take: top,
847 })
848 } else {
849 None
850 };
851 screen::screen_dispatch(
852 &screen_store,
853 &seed,
854 &cfg,
855 selection.as_ref(),
856 args.element_id.as_deref(),
857 args.refresh,
858 )
859 },
860 );
861 }
862 }
863
864 pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
871 &self.options.builtins
872 }
873
874 pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
880 &mut self.tool_router
881 }
882
883 pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
888 &mut self.prompt_router
889 }
890
891 pub fn register_typed_tool<T, F>(
921 &mut self,
922 name: &'static str,
923 description: &'static str,
924 handler: F,
925 ) where
926 T: for<'de> serde::Deserialize<'de>
927 + schemars::JsonSchema
928 + Default
929 + Send
930 + Sync
931 + 'static,
932 F: Fn(T) -> String + Send + Sync + 'static,
933 {
934 self.register_typed_route(name, description, move |args: T| Ok(handler(args)));
937 }
938
939 pub fn register_typed_tool_fallible<T, F>(
959 &mut self,
960 name: &'static str,
961 description: &'static str,
962 handler: F,
963 ) where
964 T: for<'de> serde::Deserialize<'de>
965 + schemars::JsonSchema
966 + Default
967 + Send
968 + Sync
969 + 'static,
970 F: Fn(T) -> Result<String, String> + Send + Sync + 'static,
971 {
972 self.register_typed_route(name, description, handler);
973 }
974
975 fn register_typed_route<T, F>(
980 &mut self,
981 name: &'static str,
982 description: &'static str,
983 handler: F,
984 ) where
985 T: for<'de> serde::Deserialize<'de>
986 + schemars::JsonSchema
987 + Default
988 + Send
989 + Sync
990 + 'static,
991 F: Fn(T) -> Result<String, String> + Send + Sync + 'static,
992 {
993 use std::pin::Pin;
994 type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
995
996 let schema_obj = serde_json::to_value(schemars::schema_for!(T))
997 .ok()
998 .and_then(|v| v.as_object().cloned())
999 .unwrap_or_default();
1000 let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
1001 let handler = std::sync::Arc::new(handler);
1002 let tool_name = name;
1007 let postprocess = self.options.result_postprocess.clone();
1008 let source_roots = self.options.source_roots.clone();
1009 let workspace = self.options.workspace.clone();
1010
1011 self.tool_router
1012 .add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
1013 attr,
1014 move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
1015 -> DynFut<'_, Result<rmcp::model::CallToolResponse, rmcp::ErrorData>> {
1016 let handler = handler.clone();
1017 let arguments = ctx.arguments.clone();
1018 let postprocess = postprocess.clone();
1019 let source_roots = source_roots.clone();
1020 let workspace = workspace.clone();
1021 Box::pin(async move {
1022 Ok(dispatch_typed_call(
1023 tool_name,
1024 arguments,
1025 handler.as_ref(),
1026 postprocess.as_ref(),
1027 source_roots.as_ref(),
1028 workspace.as_ref(),
1029 )
1030 .into())
1031 })
1032 },
1033 ));
1034 }
1035
1036 fn current_source_roots(&self) -> Vec<String> {
1037 match &self.options.source_roots {
1038 Some(provider) => provider(),
1039 None => Vec::new(),
1040 }
1041 }
1042
1043 fn finish(&self, tool: &str, args: &serde_json::Value, body: String) -> String {
1050 let Some(hook) = &self.options.result_postprocess else {
1051 return body;
1052 };
1053 let ctx = ResultCtx {
1054 source_roots: self.current_source_roots(),
1055 active_repo: self
1056 .options
1057 .workspace
1058 .as_ref()
1059 .and_then(|w| w.active_repo_name()),
1060 };
1061 let footer = hook(tool, args, &body, &ctx);
1062 append_footer(body, footer)
1063 }
1064
1065 #[allow(dead_code)]
1070 fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
1071 resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
1072 }
1073
1074 #[tool(
1075 description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
1076 Use to confirm the server framework is wired correctly before \
1077 relying on graph- or source-aware tools."
1078 )]
1079 async fn ping(
1080 &self,
1081 Parameters(args): Parameters<PingArgs>,
1082 ) -> Result<CallToolResult, McpError> {
1083 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1084 let body = args.message.unwrap_or_else(|| "pong".to_string());
1085 let body = self.finish("ping", &args_json, body);
1086 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1087 }
1088
1089 #[tool(description = "Read a file from the configured source root(s). Pass \
1090 `start_line`/`end_line` to slice, `grep` to filter to matching \
1091 lines, `max_chars` to cap output. Pass `rev` (a tag, branch, or \
1092 commit SHA) to read the file's content at that git revision via \
1093 `git show` instead of the working tree — useful for comparing a \
1094 file across releases (requires a git repo source root). Path \
1095 traversal attempts are rejected. Available only when source roots \
1096 are configured.")]
1097 async fn read_source(
1098 &self,
1099 Parameters(args): Parameters<ReadSourceArgs>,
1100 ) -> Result<CallToolResult, McpError> {
1101 let roots = self.current_source_roots();
1102 if roots.is_empty() {
1103 return Ok(CallToolResult::success(vec![ContentBlock::text(
1104 "Cannot read source: no active source root. Configure source_root in your manifest \
1105 or activate one (e.g. via repo_management in workspace mode).",
1106 )]));
1107 }
1108 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1109 let opts = ReadOpts {
1110 start_line: args.start_line,
1111 end_line: args.end_line,
1112 grep: args.grep,
1113 grep_context: args.grep_context,
1114 max_matches: args.max_matches,
1115 max_chars: args.max_chars,
1116 rev: args.rev,
1117 };
1118 let body = source::read_source(&args.file_path, &roots, &opts);
1119 let body = self.finish("read_source", &args_json, body);
1120 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1121 }
1122
1123 #[tool(
1124 description = "Search source files using ripgrep. `pattern` is a regex (Rust \
1125 syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
1126 N surrounding lines per match. Set `case_insensitive=true` for \
1127 case-insensitive matching. `max_results` caps total matches \
1128 (default 50)."
1129 )]
1130 async fn grep(
1131 &self,
1132 Parameters(args): Parameters<GrepArgs>,
1133 ) -> Result<CallToolResult, McpError> {
1134 let roots = self.current_source_roots();
1135 if roots.is_empty() {
1136 return Ok(CallToolResult::success(vec![ContentBlock::text(
1137 "Cannot grep: no active source root. Configure source_root in your manifest \
1138 or activate one (e.g. via repo_management in workspace mode).",
1139 )]));
1140 }
1141 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1142 let opts = GrepOpts {
1143 glob: args.glob,
1144 context: args.context,
1145 max_results: Some(args.max_results.unwrap_or(50)),
1146 case_insensitive: args.case_insensitive,
1147 };
1148 let body = source::grep(&roots, &args.pattern, &opts);
1149 let body = self.finish("grep", &args_json, body);
1150 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1151 }
1152
1153 #[tool(
1154 description = "List directory contents under the configured source root. `path` \
1155 is resolved against the first source root (\".\" lists the root \
1156 itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
1157 `glob` filters entry names. `dirs_only=true` shows only \
1158 directories."
1159 )]
1160 async fn list_source(
1161 &self,
1162 Parameters(args): Parameters<ListSourceArgs>,
1163 ) -> Result<CallToolResult, McpError> {
1164 let roots = self.current_source_roots();
1165 if roots.is_empty() {
1166 return Ok(CallToolResult::success(vec![ContentBlock::text(
1167 "Cannot list source: no active source root. Configure source_root in your \
1168 manifest or activate one (e.g. via repo_management in workspace mode).",
1169 )]));
1170 }
1171 let primary = std::path::PathBuf::from(&roots[0]);
1172 let target = match resolve_dir_under_roots(&args.path, &roots) {
1173 Some(p) => p,
1174 None => {
1175 return Ok(CallToolResult::success(vec![ContentBlock::text(format!(
1176 "Error: path '{}' resolves outside the configured source roots.",
1177 args.path
1178 ))]));
1179 }
1180 };
1181 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1182 let opts = ListOpts {
1183 depth: args.depth,
1184 glob: args.glob,
1185 dirs_only: args.dirs_only,
1186 };
1187 let body = source::list_source(&target, &primary, &opts);
1188 let body = self.finish("list_source", &args_json, body);
1189 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1190 }
1191
1192 #[tool(
1193 description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
1194 clone (if missing) and activate it as the source root for \
1195 read_source / grep / list_source. Pass `delete=true` to remove a \
1196 repo. Pass `update=true` to fetch upstream changes for the active \
1197 repo (rebuild auto-skipped when HEAD hasn't moved since the last \
1198 build; set `force_rebuild=true` to bypass). Pass `revs` (an \
1199 integer N, or a list of git revspecs) to load multiple revisions \
1200 of the repo into one graph — N loads the newest N stable release \
1201 tags of the dominant tag family plus HEAD (prereleases and \
1202 unrelated tag families skipped); a revs request always rebuilds. \
1203 Call with no \
1204 arguments to list all known repos with their last-access counts. \
1205 Idle repos auto-sweep on each call (default 7 days, configurable \
1206 via --stale-after-days)."
1207 )]
1208 async fn repo_management(
1209 &self,
1210 Parameters(args): Parameters<RepoManagementArgs>,
1211 ) -> Result<CallToolResult, McpError> {
1212 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1213 let body = match &self.options.workspace {
1214 Some(ws) => ws.repo_management(
1215 args.name.as_deref(),
1216 args.delete,
1217 args.update,
1218 args.force_rebuild,
1219 args.revs.as_ref(),
1220 ),
1221 None => "repo_management requires --workspace mode.".to_string(),
1222 };
1223 let body = self.finish("repo_management", &args_json, body);
1224 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1225 }
1226}
1227
1228fn resolve_repo_from(
1236 default_repo: Option<&RepoProvider>,
1237 override_repo: Option<String>,
1238) -> Result<String, String> {
1239 if let Some(r) = override_repo {
1240 if let Some(err) = crate::git_refs::validate_repo(&r) {
1241 return Err(err);
1242 }
1243 return Ok(r);
1244 }
1245 if let Some(provider) = default_repo {
1246 if let Some(r) = provider() {
1247 if let Some(err) = crate::git_refs::validate_repo(&r) {
1248 return Err(err);
1249 }
1250 return Ok(r);
1251 }
1252 }
1253 if let Some(detected) = crate::github::detect_git_repo(".") {
1254 if crate::git_refs::validate_repo(&detected).is_none() {
1255 return Ok(detected);
1256 }
1257 }
1258 Err(
1259 "No active repository. Pass `repo_name='org/repo'`, configure a default in the \
1260 server, or run from a directory whose git remote points at github.com."
1261 .to_string(),
1262 )
1263}
1264
1265pub fn serve_prompts(registry: &ResolvedRegistry, server: &mut McpServer) {
1279 use std::borrow::Cow;
1280 use std::collections::HashSet;
1281
1282 let registered_tools: HashSet<String> = server
1287 .tool_router
1288 .list_all()
1289 .iter()
1290 .map(|t| t.name.to_string())
1291 .collect();
1292 let extensions = server.options.extensions.clone();
1293
1294 struct InjectSkill {
1300 name: String,
1301 description: String,
1302 body: String,
1303 references_tools: Vec<String>,
1304 }
1305 let mut auto_inject: Vec<InjectSkill> = Vec::new();
1306
1307 for name in registry.skill_names() {
1308 let Some(skill) = registry.get(&name) else {
1309 continue;
1310 };
1311
1312 let activation = registry.activation_for(skill, ®istered_tools, &extensions);
1316 if !activation.active {
1317 let failed_clauses: Vec<&str> = activation
1318 .clauses
1319 .iter()
1320 .filter(|(_, outcome)| {
1321 *outcome != crate::server::skills::PredicateOutcome::Satisfied
1322 })
1323 .map(|(clause, _)| clause.as_str())
1324 .collect();
1325 tracing::info!(
1326 skill = %name,
1327 suppressed_by = ?failed_clauses,
1328 "skill suppressed by applies_when predicates"
1329 );
1330 continue;
1331 }
1332
1333 let prompt = Prompt::new(
1334 skill.name().to_string(),
1335 Some(skill.description().to_string()),
1336 None,
1337 );
1338 let body = skill.body.clone();
1339 let route = PromptRoute::new_dyn(prompt, move |_ctx| {
1340 let body = body.clone();
1341 Box::pin(async move {
1342 Ok(
1343 GetPromptResult::new(vec![PromptMessage::new_text(Role::Assistant, body)])
1344 .into(),
1345 )
1346 })
1347 });
1348 server.prompt_router.add_route(route);
1349
1350 if skill.frontmatter.auto_inject_hint {
1351 auto_inject.push(InjectSkill {
1352 name: skill.name().to_string(),
1353 description: skill.description().to_string(),
1354 body: skill.body.clone(),
1355 references_tools: skill.frontmatter.references_tools.clone(),
1356 });
1357 }
1358 }
1359
1360 for inj in &auto_inject {
1396 let mut targets: Vec<&str> = Vec::new();
1399 let mut seen: HashSet<&str> = HashSet::new();
1400 for tool in std::iter::once(inj.name.as_str())
1401 .chain(inj.references_tools.iter().map(String::as_str))
1402 {
1403 if seen.insert(tool) {
1404 targets.push(tool);
1405 }
1406 }
1407
1408 let marker = format!("<!-- mcp-skill:{} -->", inj.name);
1411 let mut block = format!("\n\n{marker}");
1412 let description = inj.description.trim();
1413 if !description.is_empty() {
1414 block.push_str("\n\n## When to use\n\n");
1415 block.push_str(description);
1416 }
1417 block.push_str("\n\n## Methodology\n\n");
1418 block.push_str(inj.body.trim());
1419
1420 for tool in targets {
1421 let key = Cow::<'static, str>::Owned(tool.to_string());
1422 let Some(route) = server.tool_router.map.get_mut(&key) else {
1423 continue;
1424 };
1425 if route
1428 .attr
1429 .description
1430 .as_deref()
1431 .is_some_and(|d| d.contains(&marker))
1432 {
1433 continue;
1434 }
1435 let new_desc = match route.attr.description.take() {
1436 Some(existing) => format!("{existing}{block}"),
1437 None => block.trim_start().to_string(),
1438 };
1439 route.attr.description = Some(Cow::Owned(new_desc));
1440 }
1441 }
1442}
1443
1444#[tool_handler(router = self.tool_router)]
1445impl ServerHandler for McpServer {
1446 fn get_info(&self) -> ServerInfo {
1447 let name = self
1448 .options
1449 .name
1450 .clone()
1451 .unwrap_or_else(|| "MCP Server".to_string());
1452 let mut caps = ServerCapabilities::builder().enable_tools().build();
1459 if !self.prompt_router.map.is_empty() {
1460 caps.prompts = Some(PromptsCapability::default());
1461 }
1462 let mut info = ServerInfo::new(caps)
1463 .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
1464 .with_protocol_version(ProtocolVersion::V_2024_11_05);
1465 if let Some(text) = &self.options.instructions {
1466 info = info.with_instructions(text.clone());
1467 }
1468 info
1469 }
1470
1471 async fn on_initialized(&self, context: rmcp::service::NotificationContext<rmcp::RoleServer>) {
1486 tracing::info!("client initialized");
1489 crate::server::roots::on_client_initialized(&self.options, &context.peer).await;
1490 }
1491
1492 async fn on_roots_list_changed(
1495 &self,
1496 context: rmcp::service::NotificationContext<rmcp::RoleServer>,
1497 ) {
1498 crate::server::roots::on_client_roots_changed(&self.options, &context.peer).await;
1499 }
1500
1501 async fn list_prompts(
1502 &self,
1503 _request: Option<PaginatedRequestParams>,
1504 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
1505 ) -> Result<ListPromptsResult, McpError> {
1506 Ok(ListPromptsResult {
1507 prompts: self.prompt_router.list_all(),
1508 ..Default::default()
1509 })
1510 }
1511
1512 async fn get_prompt(
1513 &self,
1514 request: GetPromptRequestParams,
1515 context: rmcp::service::RequestContext<rmcp::RoleServer>,
1516 ) -> Result<GetPromptResponse, McpError> {
1517 let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
1518 self,
1519 request.name,
1520 request.arguments,
1521 context,
1522 );
1523 self.prompt_router.get_prompt(prompt_context).await
1524 }
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529 use super::*;
1530
1531 #[test]
1532 fn options_from_manifest_uses_name_when_set() {
1533 let opts = ServerOptions::from_manifest(None, "Fallback");
1534 assert_eq!(opts.name.as_deref(), Some("Fallback"));
1535 }
1536
1537 #[test]
1538 fn builtins_exposed_via_server() {
1539 use crate::server::manifest::{BuiltinsConfig, TempCleanup};
1540 let opts = ServerOptions {
1541 builtins: BuiltinsConfig {
1542 save_graph: true,
1543 temp_cleanup: TempCleanup::OnOverview,
1544 ..Default::default()
1545 },
1546 ..ServerOptions::default()
1547 };
1548 let server = McpServer::new(opts);
1549 assert!(server.builtins().save_graph);
1550 assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
1551 }
1552
1553 #[test]
1554 fn server_constructs() {
1555 let _server = McpServer::new(ServerOptions::default());
1556 }
1557
1558 #[test]
1559 fn static_source_roots_provider() {
1560 let opts = ServerOptions::default()
1561 .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
1562 let server = McpServer::new(opts);
1563 assert_eq!(
1564 server.current_source_roots(),
1565 vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
1566 );
1567 }
1568
1569 #[test]
1570 fn no_provider_returns_empty_roots() {
1571 let server = McpServer::new(ServerOptions::default());
1572 assert!(server.current_source_roots().is_empty());
1573 }
1574
1575 #[test]
1576 fn repo_management_gated_to_workspace_mode() {
1577 let server = McpServer::new(ServerOptions::default());
1580 let tools = server.tool_router.list_all();
1581 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1582 assert!(
1583 !names.contains(&"repo_management"),
1584 "repo_management should be gated out without a workspace; tools were {names:?}"
1585 );
1586 }
1587
1588 fn github_tool_surface(github_opt_in: bool, token_present: bool) -> Vec<String> {
1594 use crate::server::manifest::BuiltinsConfig;
1595 let _g = crate::github::env_lock();
1596 let prev_token = std::env::var("GITHUB_TOKEN").ok();
1597 let prev_alt = std::env::var("GH_TOKEN").ok();
1598 unsafe {
1599 std::env::remove_var("GH_TOKEN");
1600 if token_present {
1601 std::env::set_var("GITHUB_TOKEN", "ghp_surface_test_not_real");
1602 } else {
1603 std::env::remove_var("GITHUB_TOKEN");
1604 }
1605 }
1606 let opts = ServerOptions {
1607 builtins: BuiltinsConfig {
1608 github: github_opt_in,
1609 ..Default::default()
1610 },
1611 ..ServerOptions::default()
1612 };
1613 let server = McpServer::new(opts);
1614 let names: Vec<String> = server
1615 .tool_router
1616 .list_all()
1617 .iter()
1618 .map(|t| t.name.to_string())
1619 .collect();
1620 unsafe {
1621 match prev_token {
1622 Some(v) => std::env::set_var("GITHUB_TOKEN", v),
1623 None => std::env::remove_var("GITHUB_TOKEN"),
1624 }
1625 match prev_alt {
1626 Some(v) => std::env::set_var("GH_TOKEN", v),
1627 None => std::env::remove_var("GH_TOKEN"),
1628 }
1629 }
1630 names
1631 }
1632
1633 const GITHUB_TOOLS: [&str; 3] = ["github_issues", "github_api", "screen_stargazers"];
1634
1635 #[test]
1636 fn github_tools_absent_by_default_even_with_a_token() {
1637 let names = github_tool_surface(false, true);
1641 for tool in GITHUB_TOOLS {
1642 assert!(
1643 !names.iter().any(|n| n == tool),
1644 "{tool} registered without `builtins.github: true`; tools were {names:?}"
1645 );
1646 }
1647 }
1648
1649 #[test]
1650 fn github_tools_register_on_opt_in_with_a_token() {
1651 let names = github_tool_surface(true, true);
1652 for tool in GITHUB_TOOLS {
1653 assert!(
1654 names.iter().any(|n| n == tool),
1655 "{tool} missing with `builtins.github: true` and a token; tools were {names:?}"
1656 );
1657 }
1658 }
1659
1660 #[test]
1661 fn github_tools_absent_on_opt_in_without_a_token() {
1662 let names = github_tool_surface(true, false);
1665 for tool in GITHUB_TOOLS {
1666 assert!(
1667 !names.iter().any(|n| n == tool),
1668 "{tool} registered with no reachable token; tools were {names:?}"
1669 );
1670 }
1671 }
1672
1673 #[test]
1674 fn repo_management_present_when_workspace_bound() {
1675 use crate::server::workspace::Workspace;
1678 let dir = tempfile::tempdir().unwrap();
1679 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1680 let opts = ServerOptions::default().with_workspace(ws);
1681 let server = McpServer::new(opts);
1682 let tools = server.tool_router.list_all();
1683 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1684 assert!(
1685 names.contains(&"repo_management"),
1686 "repo_management should be registered with a workspace; tools were {names:?}"
1687 );
1688 }
1689
1690 #[test]
1691 fn result_postprocess_appends_footer_and_sees_ctx() {
1692 use std::sync::Mutex;
1693 type Seen = Option<(String, serde_json::Value, String, Vec<String>)>;
1695 let seen: Arc<Mutex<Seen>> = Arc::new(Mutex::new(None));
1696 let seen_c = seen.clone();
1697 let hook: ResultPostprocessHook = Arc::new(move |tool, args, body, ctx| {
1698 *seen_c.lock().unwrap() = Some((
1699 tool.to_string(),
1700 args.clone(),
1701 body.to_string(),
1702 ctx.source_roots.clone(),
1703 ));
1704 if tool == "grep" {
1706 Some("↳ prefer cypher_query".to_string())
1707 } else {
1708 None
1709 }
1710 });
1711 let opts = ServerOptions::default()
1712 .with_static_source_roots(vec!["/src".to_string()])
1713 .with_result_postprocess(hook);
1714 let server = McpServer::new(opts);
1715
1716 let args = serde_json::json!({ "pattern": "^fn " });
1717 let out = server.finish("grep", &args, "match line".to_string());
1718 assert_eq!(out, "match line\n\n↳ prefer cypher_query");
1719
1720 let rec = seen.lock().unwrap().clone().unwrap();
1721 assert_eq!(rec.0, "grep");
1722 assert_eq!(rec.1, args);
1723 assert_eq!(rec.2, "match line");
1724 assert_eq!(rec.3, vec!["/src".to_string()]);
1725
1726 let out2 = server.finish("read_source", &args, "file body".to_string());
1728 assert_eq!(out2, "file body");
1729 }
1730
1731 #[test]
1732 fn no_result_postprocess_leaves_body_unchanged() {
1733 let server = McpServer::new(ServerOptions::default());
1734 let out = server.finish("grep", &serde_json::Value::Null, "x".to_string());
1735 assert_eq!(out, "x");
1736 }
1737
1738 #[test]
1739 fn append_footer_ignores_empty_footers() {
1740 assert_eq!(append_footer("a".to_string(), None), "a");
1741 assert_eq!(append_footer("a".to_string(), Some(String::new())), "a");
1742 assert_eq!(
1743 append_footer("a".to_string(), Some("b".to_string())),
1744 "a\n\nb"
1745 );
1746 }
1747
1748 #[test]
1749 fn dynamic_provider_swaps_at_call_time() {
1750 use std::sync::Mutex;
1751 let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
1752 let s2 = state.clone();
1753 let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
1754 let opts = ServerOptions::default().with_dynamic_source_roots(provider);
1755 let server = McpServer::new(opts);
1756 assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
1757 *state.lock().unwrap() = vec!["/swapped".to_string()];
1758 assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
1759 }
1760
1761 #[derive(Default, serde::Deserialize, schemars::JsonSchema)]
1766 struct EchoArgs {
1767 #[serde(default)]
1768 text: String,
1769 #[serde(default)]
1770 count: u32,
1771 }
1772
1773 fn result_text(result: &CallToolResult) -> String {
1777 result
1778 .content
1779 .iter()
1780 .filter_map(|c| c.as_text().map(|t| t.text.clone()))
1781 .collect::<Vec<_>>()
1782 .join("")
1783 }
1784
1785 fn footer_hook() -> ResultPostprocessHook {
1788 Arc::new(|_tool, _args, _body, _ctx| Some("↳ footer".to_string()))
1789 }
1790
1791 fn args_map(json: serde_json::Value) -> Option<rmcp::model::JsonObject> {
1792 json.as_object().cloned()
1793 }
1794
1795 #[test]
1796 fn fallible_ok_reports_success_with_footer() {
1797 let hook = footer_hook();
1798 let out = dispatch_typed_call(
1799 "echo",
1800 args_map(serde_json::json!({ "text": "hi", "count": 2 })),
1801 &|args: EchoArgs| Ok(format!("{} x{}", args.text, args.count)),
1802 Some(&hook),
1803 None,
1804 None,
1805 );
1806 assert_eq!(out.is_error, Some(false));
1807 assert_eq!(result_text(&out), "hi x2\n\n↳ footer");
1808 }
1809
1810 #[test]
1811 fn fallible_err_sets_is_error_and_keeps_footer() {
1812 let hook = footer_hook();
1815 let out = dispatch_typed_call(
1816 "echo",
1817 args_map(serde_json::json!({ "text": "hi" })),
1818 &|_args: EchoArgs| Err::<String, String>("no rows matched".to_string()),
1819 Some(&hook),
1820 None,
1821 None,
1822 );
1823 assert_eq!(out.is_error, Some(true));
1824 assert_eq!(result_text(&out), "no rows matched\n\n↳ footer");
1825 }
1826
1827 #[test]
1828 fn fallible_err_without_hook_is_error_text_verbatim() {
1829 let out = dispatch_typed_call(
1830 "echo",
1831 args_map(serde_json::json!({})),
1832 &|_args: EchoArgs| Err::<String, String>("boom".to_string()),
1833 None,
1834 None,
1835 None,
1836 );
1837 assert_eq!(out.is_error, Some(true));
1838 assert_eq!(result_text(&out), "boom");
1839 }
1840
1841 #[test]
1842 fn postprocess_ctx_reaches_both_arms() {
1843 use std::sync::Mutex;
1844 type Seen = Arc<Mutex<Vec<(String, Vec<String>)>>>;
1846 let seen: Seen = Arc::new(Mutex::new(Vec::new()));
1847 let seen_c = seen.clone();
1848 let hook: ResultPostprocessHook = Arc::new(move |_tool, _args, body, ctx| {
1849 seen_c
1850 .lock()
1851 .unwrap()
1852 .push((body.to_string(), ctx.source_roots.clone()));
1853 None
1854 });
1855 let roots: SourceRootsProvider = Arc::new(|| vec!["/src".to_string()]);
1856 for handler_result in ["ok", "err"] {
1857 let _ = dispatch_typed_call(
1858 "echo",
1859 args_map(serde_json::json!({})),
1860 &|_args: EchoArgs| {
1861 if handler_result == "ok" {
1862 Ok("body".to_string())
1863 } else {
1864 Err("failed".to_string())
1865 }
1866 },
1867 Some(&hook),
1868 Some(&roots),
1869 None,
1870 );
1871 }
1872 let rec = seen.lock().unwrap().clone();
1873 assert_eq!(rec.len(), 2, "hook must run on both arms");
1874 assert_eq!(rec[0].0, "body");
1875 assert_eq!(rec[1].0, "failed");
1876 for (_, roots) in &rec {
1877 assert_eq!(roots, &vec!["/src".to_string()]);
1878 }
1879 }
1880
1881 #[test]
1882 fn invalid_arguments_set_is_error_on_both_registrations() {
1883 let bad = || args_map(serde_json::json!({ "count": "not a number" }));
1888
1889 let fallible = dispatch_typed_call(
1890 "echo",
1891 bad(),
1892 &|_args: EchoArgs| Ok("unreachable".to_string()),
1893 None,
1894 None,
1895 None,
1896 );
1897 assert_eq!(fallible.is_error, Some(true));
1898 assert!(
1899 result_text(&fallible).starts_with("invalid arguments: "),
1900 "got {:?}",
1901 result_text(&fallible)
1902 );
1903
1904 let plain_handler = |_args: EchoArgs| "unreachable".to_string();
1906 let plain = dispatch_typed_call(
1907 "echo",
1908 bad(),
1909 &move |args: EchoArgs| Ok(plain_handler(args)),
1910 None,
1911 None,
1912 None,
1913 );
1914 assert_eq!(plain.is_error, Some(true));
1915 assert!(result_text(&plain).starts_with("invalid arguments: "));
1916 }
1917
1918 #[test]
1919 fn invalid_arguments_still_get_the_footer() {
1920 let hook = footer_hook();
1921 let out = dispatch_typed_call(
1922 "echo",
1923 args_map(serde_json::json!({ "count": "not a number" })),
1924 &|_args: EchoArgs| Ok("unreachable".to_string()),
1925 Some(&hook),
1926 None,
1927 None,
1928 );
1929 assert_eq!(out.is_error, Some(true));
1930 assert!(result_text(&out).ends_with("\n\n↳ footer"));
1931 }
1932
1933 #[test]
1934 fn plain_handler_success_unchanged() {
1935 let hook = footer_hook();
1938 let plain_handler = |args: EchoArgs| format!("said {}", args.text);
1939 let out = dispatch_typed_call(
1940 "echo",
1941 args_map(serde_json::json!({ "text": "hello" })),
1942 &move |args: EchoArgs| Ok(plain_handler(args)),
1943 Some(&hook),
1944 None,
1945 None,
1946 );
1947 assert_eq!(out.is_error, Some(false));
1948 assert_eq!(result_text(&out), "said hello\n\n↳ footer");
1949 }
1950
1951 #[test]
1952 fn missing_arguments_fall_back_to_default_args() {
1953 let out = dispatch_typed_call(
1955 "echo",
1956 None,
1957 &|args: EchoArgs| Ok(format!("[{}]", args.text)),
1958 None,
1959 None,
1960 None,
1961 );
1962 assert_eq!(out.is_error, Some(false));
1963 assert_eq!(result_text(&out), "[]");
1964 }
1965
1966 #[test]
1967 fn both_registrations_reach_the_router() {
1968 let mut server = McpServer::new(ServerOptions::default());
1969 server.register_typed_tool("echo_plain", "plain", |args: EchoArgs| args.text);
1970 server.register_typed_tool_fallible("echo_fallible", "fallible", |args: EchoArgs| {
1971 if args.text.is_empty() {
1972 Err("text is required".to_string())
1973 } else {
1974 Ok(args.text)
1975 }
1976 });
1977 let names: Vec<String> = server
1978 .tool_router
1979 .list_all()
1980 .iter()
1981 .map(|t| t.name.to_string())
1982 .collect();
1983 assert!(names.iter().any(|n| n == "echo_plain"), "{names:?}");
1984 assert!(names.iter().any(|n| n == "echo_fallible"), "{names:?}");
1985 }
1986
1987 fn build_test_registry(
1990 skills: &[(&str, &str, &str, bool)],
1991 ) -> crate::server::skills::ResolvedRegistry {
1992 use crate::server::skills::Registry;
1993 let dir = tempfile::tempdir().unwrap();
1994 let yaml_path = dir.path().join("manifest.yaml");
1995 let skills_dir = dir.path().join("manifest.skills");
1996 std::fs::create_dir_all(&skills_dir).unwrap();
1997 for (name, description, body, auto_inject) in skills {
1998 let auto = if *auto_inject { "true" } else { "false" };
1999 let content = format!(
2000 "---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
2001 );
2002 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
2003 }
2004 Registry::new()
2005 .auto_detect_project_layer(&yaml_path)
2006 .finalise()
2007 .unwrap()
2008 }
2009
2010 fn build_registry_with_refs(
2015 skills: &[(&str, &str, &str, &str)],
2016 ) -> crate::server::skills::ResolvedRegistry {
2017 use crate::server::skills::Registry;
2018 let dir = tempfile::tempdir().unwrap();
2019 let yaml_path = dir.path().join("manifest.yaml");
2020 let skills_dir = dir.path().join("manifest.skills");
2021 std::fs::create_dir_all(&skills_dir).unwrap();
2022 for (name, description, body, references_tools) in skills {
2023 let content = format!(
2024 "---\nname: {name}\ndescription: {description}\n\
2025 auto_inject_hint: true\nreferences_tools: {references_tools}\n---\n\n{body}\n"
2026 );
2027 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
2028 }
2029 Registry::new()
2030 .auto_detect_project_layer(&yaml_path)
2031 .finalise()
2032 .unwrap()
2033 }
2034
2035 fn tool_desc(server: &McpServer, tool: &str) -> String {
2036 server
2037 .tool_router
2038 .get(tool)
2039 .and_then(|t| t.description.clone())
2040 .map(|c| c.into_owned())
2041 .unwrap_or_default()
2042 }
2043
2044 #[test]
2045 fn prompt_router_empty_by_default() {
2046 let server = McpServer::new(ServerOptions::default());
2047 assert!(server.prompt_router.map.is_empty());
2048 }
2049
2050 #[test]
2051 fn get_info_no_prompts_capability_when_empty() {
2052 let server = McpServer::new(ServerOptions::default());
2056 let info = server.get_info();
2057 assert!(
2058 info.capabilities.prompts.is_none(),
2059 "prompts capability must be absent when no skills are registered"
2060 );
2061 }
2062
2063 #[test]
2064 fn serve_prompts_registers_routes_with_metadata() {
2065 let registry = build_test_registry(&[
2066 ("alpha", "First skill.", "Alpha body.", true),
2067 ("beta", "Second skill.", "Beta body.", true),
2068 ]);
2069 let mut server = McpServer::new(ServerOptions::default());
2070 super::serve_prompts(®istry, &mut server);
2071
2072 let prompts = server.prompt_router.list_all();
2073 let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
2074 assert_eq!(names, vec!["alpha", "beta"]);
2075
2076 let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
2077 assert_eq!(alpha.description.as_deref(), Some("First skill."));
2078 assert!(alpha.arguments.is_none());
2079 }
2080
2081 #[test]
2082 fn serve_prompts_empty_registry_is_noop() {
2083 let registry = crate::server::skills::ResolvedRegistry::default();
2084 let mut server = McpServer::new(ServerOptions::default());
2085 super::serve_prompts(®istry, &mut server);
2086 assert!(server.prompt_router.map.is_empty());
2087 assert!(server.get_info().capabilities.prompts.is_none());
2088 }
2089
2090 #[test]
2091 fn get_info_advertises_prompts_when_present() {
2092 let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
2093 let mut server = McpServer::new(ServerOptions::default());
2094 super::serve_prompts(®istry, &mut server);
2095 let info = server.get_info();
2096 assert!(
2097 info.capabilities.prompts.is_some(),
2098 "prompts capability must be advertised once a skill is registered"
2099 );
2100 }
2101
2102 #[test]
2103 fn serve_prompts_auto_injects_full_body_into_matching_tool() {
2104 let registry =
2112 build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
2113 let mut server = McpServer::new(ServerOptions::default());
2114 let before = server
2115 .tool_router
2116 .get("ping")
2117 .and_then(|t| t.description.clone())
2118 .map(|c| c.into_owned())
2119 .unwrap_or_default();
2120 super::serve_prompts(®istry, &mut server);
2121 let after = server
2122 .tool_router
2123 .get("ping")
2124 .and_then(|t| t.description.clone())
2125 .map(|c| c.into_owned())
2126 .unwrap_or_default();
2127 assert!(after.starts_with(&before), "original description preserved");
2128 assert!(
2129 after.contains("## Methodology"),
2130 "inject should include a Methodology header; got: {after}"
2131 );
2132 assert!(
2133 after.contains("PING-BODY-SENTINEL"),
2134 "inject should embed the full skill body; got: {after}"
2135 );
2136 assert!(
2137 !after.contains("prompts/get"),
2138 "post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
2139 );
2140 }
2141
2142 #[test]
2143 fn serve_prompts_skips_injection_when_disabled() {
2144 let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
2145 let mut server = McpServer::new(ServerOptions::default());
2146 let before = server
2147 .tool_router
2148 .get("ping")
2149 .and_then(|t| t.description.clone())
2150 .map(|c| c.into_owned())
2151 .unwrap_or_default();
2152 super::serve_prompts(®istry, &mut server);
2153 let after = server
2154 .tool_router
2155 .get("ping")
2156 .and_then(|t| t.description.clone())
2157 .map(|c| c.into_owned())
2158 .unwrap_or_default();
2159 assert_eq!(
2160 before, after,
2161 "auto_inject_hint=false must leave tool description untouched"
2162 );
2163 }
2164
2165 #[test]
2166 fn serve_prompts_skips_injection_when_no_matching_tool() {
2167 let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
2170 let mut server = McpServer::new(ServerOptions::default());
2171 super::serve_prompts(®istry, &mut server);
2172 assert!(server.prompt_router.map.contains_key("no_such_tool"));
2173 let ping_desc = server
2176 .tool_router
2177 .get("ping")
2178 .and_then(|t| t.description.clone())
2179 .map(|c| c.into_owned())
2180 .unwrap_or_default();
2181 assert!(!ping_desc.contains("no_such_tool"));
2182 }
2183
2184 #[test]
2185 fn serve_prompts_injects_description_under_when_to_use() {
2186 let registry = build_test_registry(&[("ping", "ROUTING-SENTINEL", "BODY-SENTINEL", true)]);
2190 let mut server = McpServer::new(ServerOptions::default());
2191 super::serve_prompts(®istry, &mut server);
2192 let desc = tool_desc(&server, "ping");
2193 assert!(
2194 desc.contains("## When to use\n\nROUTING-SENTINEL"),
2195 "description should be injected under `## When to use`; got: {desc}"
2196 );
2197 assert!(
2198 desc.contains("<!-- mcp-skill:ping -->"),
2199 "injection should carry the per-skill idempotency marker; got: {desc}"
2200 );
2201 let when = desc.find("## When to use").unwrap();
2203 let method = desc.find("## Methodology").unwrap();
2204 assert!(when < method, "`When to use` must precede `Methodology`");
2205 }
2206
2207 #[test]
2208 fn serve_prompts_honors_references_tools() {
2209 let registry = build_registry_with_refs(&[(
2213 "graph_strategy",
2214 "Map structure first.",
2215 "GRAPH-BODY-SENTINEL",
2216 "[ping]",
2217 )]);
2218 let mut server = McpServer::new(ServerOptions::default());
2219 super::serve_prompts(®istry, &mut server);
2220 assert!(server.prompt_router.map.contains_key("graph_strategy"));
2222 let desc = tool_desc(&server, "ping");
2224 assert!(
2225 desc.contains("<!-- mcp-skill:graph_strategy -->"),
2226 "referenced tool should carry the skill marker; got: {desc}"
2227 );
2228 assert!(
2229 desc.contains("Map structure first."),
2230 "referenced tool should carry the skill routing; got: {desc}"
2231 );
2232 assert!(
2233 desc.contains("GRAPH-BODY-SENTINEL"),
2234 "referenced tool should carry the skill body; got: {desc}"
2235 );
2236 }
2237
2238 #[test]
2239 fn serve_prompts_idempotent_when_skill_self_references() {
2240 let registry = build_registry_with_refs(&[("ping", "Routing.", "Body.", "[ping]")]);
2244 let mut server = McpServer::new(ServerOptions::default());
2245 super::serve_prompts(®istry, &mut server);
2246 let desc = tool_desc(&server, "ping");
2247 let marker_count = desc.matches("<!-- mcp-skill:ping -->").count();
2248 assert_eq!(
2249 marker_count, 1,
2250 "self-referencing skill must inject exactly once; got {marker_count}: {desc}"
2251 );
2252 }
2253
2254 #[test]
2255 fn serve_prompts_idempotent_across_repeated_passes() {
2256 let registry = build_test_registry(&[("ping", "Routing.", "Body.", true)]);
2259 let mut server = McpServer::new(ServerOptions::default());
2260 super::serve_prompts(®istry, &mut server);
2261 let once = tool_desc(&server, "ping");
2262 super::serve_prompts(®istry, &mut server);
2263 let twice = tool_desc(&server, "ping");
2264 assert_eq!(
2265 once, twice,
2266 "second pass must be a no-op for an already-injected tool"
2267 );
2268 }
2269
2270 #[test]
2271 fn serve_prompts_multiple_skills_stack_on_one_tool() {
2272 let registry = build_registry_with_refs(&[
2276 ("ping", "Ping routing.", "PING-BODY", "[]"),
2277 ("ping_strategy", "Strategy routing.", "STRAT-BODY", "[ping]"),
2278 ]);
2279 let mut server = McpServer::new(ServerOptions::default());
2280 super::serve_prompts(®istry, &mut server);
2281 let desc = tool_desc(&server, "ping");
2282 assert!(desc.contains("<!-- mcp-skill:ping -->"), "got: {desc}");
2283 assert!(
2284 desc.contains("<!-- mcp-skill:ping_strategy -->"),
2285 "got: {desc}"
2286 );
2287 assert!(
2288 desc.contains("PING-BODY") && desc.contains("STRAT-BODY"),
2289 "got: {desc}"
2290 );
2291 }
2292
2293 fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
2294 let dir = tempfile::tempdir().unwrap();
2295 let yaml = dir.path().join("test_mcp.yaml");
2296 std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
2297 let skills_dir = dir.path().join("test_mcp.skills");
2298 std::fs::create_dir(&skills_dir).unwrap();
2299 std::fs::write(
2300 skills_dir.join("gated_skill.md"),
2301 format!(
2302 "---\n\
2303 name: gated_skill\n\
2304 description: A predicate-gated skill for testing.\n\
2305 applies_when:\n\
2306 {applies_when_yaml}\n\
2307 ---\n\n\
2308 Body.\n",
2309 ),
2310 )
2311 .unwrap();
2312 dir
2313 }
2314
2315 #[test]
2316 fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
2317 use crate::server::skills::Registry as SkillsBuilder;
2321 let dir = write_gated_project_skill(" tool_registered: nonexistent_tool");
2322 let yaml = dir.path().join("test_mcp.yaml");
2323 let registry = SkillsBuilder::new()
2324 .auto_detect_project_layer(&yaml)
2325 .finalise()
2326 .unwrap();
2327 let mut server = McpServer::new(ServerOptions::default());
2328 super::serve_prompts(®istry, &mut server);
2329 assert!(
2330 !server.prompt_router.map.contains_key("gated_skill"),
2331 "skill with unsatisfied predicate must be suppressed"
2332 );
2333 }
2334
2335 #[test]
2336 fn serve_prompts_keeps_skill_with_satisfied_predicate() {
2337 use crate::server::skills::Registry as SkillsBuilder;
2340 let dir = write_gated_project_skill(" tool_registered: ping");
2341 let yaml = dir.path().join("test_mcp.yaml");
2342 let registry = SkillsBuilder::new()
2343 .auto_detect_project_layer(&yaml)
2344 .finalise()
2345 .unwrap();
2346 let mut server = McpServer::new(ServerOptions::default());
2347 super::serve_prompts(®istry, &mut server);
2348 assert!(
2349 server.prompt_router.map.contains_key("gated_skill"),
2350 "skill with satisfied predicate must register"
2351 );
2352 }
2353
2354 #[test]
2355 fn serve_prompts_evaluates_extension_enabled_from_manifest() {
2356 use crate::server::skills::Registry as SkillsBuilder;
2360 let dir = write_gated_project_skill(" extension_enabled: csv_http_server");
2361 let yaml = dir.path().join("test_mcp.yaml");
2362 let registry = SkillsBuilder::new()
2363 .auto_detect_project_layer(&yaml)
2364 .finalise()
2365 .unwrap();
2366
2367 let mut server = McpServer::new(ServerOptions::default());
2369 super::serve_prompts(®istry, &mut server);
2370 assert!(!server.prompt_router.map.contains_key("gated_skill"));
2371
2372 let mut extensions = serde_json::Map::new();
2374 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
2375 let opts = ServerOptions {
2376 extensions,
2377 ..ServerOptions::default()
2378 };
2379 let mut server = McpServer::new(opts);
2380 super::serve_prompts(®istry, &mut server);
2381 assert!(server.prompt_router.map.contains_key("gated_skill"));
2382 }
2383}