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
96#[derive(Clone, Default)]
98pub struct ServerOptions {
99 pub name: Option<String>,
101 pub instructions: Option<String>,
103 pub source_roots: Option<SourceRootsProvider>,
106 pub default_repo: Option<RepoProvider>,
109 pub workspace: Option<crate::server::workspace::Workspace>,
111 pub builtins: crate::server::manifest::BuiltinsConfig,
116 pub extensions: serde_json::Map<String, serde_json::Value>,
121 pub result_postprocess: Option<ResultPostprocessHook>,
125}
126
127impl std::fmt::Debug for ServerOptions {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 f.debug_struct("ServerOptions")
130 .field("name", &self.name)
131 .field("instructions", &self.instructions)
132 .field(
133 "source_roots",
134 &self.source_roots.as_ref().map(|_| "<provider>"),
135 )
136 .field(
137 "default_repo",
138 &self.default_repo.as_ref().map(|_| "<provider>"),
139 )
140 .finish()
141 }
142}
143
144impl ServerOptions {
145 pub fn from_manifest(manifest: Option<&Manifest>, fallback_name: &str) -> Self {
146 Self {
147 name: manifest
148 .and_then(|m| m.name.clone())
149 .or_else(|| Some(fallback_name.to_string())),
150 instructions: manifest.and_then(|m| m.instructions.clone()),
151 source_roots: None,
152 default_repo: None,
153 workspace: None,
154 builtins: manifest.map(|m| m.builtins.clone()).unwrap_or_default(),
155 extensions: manifest.map(|m| m.extensions.clone()).unwrap_or_default(),
156 result_postprocess: None,
157 }
158 }
159
160 pub fn with_static_source_roots(mut self, roots: Vec<String>) -> Self {
161 let captured = Arc::new(roots);
162 self.source_roots = Some(Arc::new(move || captured.as_ref().clone()));
163 self
164 }
165
166 pub fn with_dynamic_source_roots(mut self, provider: SourceRootsProvider) -> Self {
167 self.source_roots = Some(provider);
168 self
169 }
170
171 pub fn with_static_repo(mut self, repo: String) -> Self {
172 self.default_repo = Some(Arc::new(move || Some(repo.clone())));
173 self
174 }
175
176 pub fn with_dynamic_repo(mut self, provider: RepoProvider) -> Self {
177 self.default_repo = Some(provider);
178 self
179 }
180
181 pub fn with_workspace(mut self, ws: crate::server::workspace::Workspace) -> Self {
186 let ws_for_roots = ws.clone();
187 let ws_for_repo = ws.clone();
188 self.workspace = Some(ws);
189 self.source_roots = Some(Arc::new(move || {
190 ws_for_roots
191 .active_repo_path()
192 .map(|p| vec![p.to_string_lossy().into_owned()])
193 .unwrap_or_default()
194 }));
195 self.default_repo = Some(Arc::new(move || ws_for_repo.default_github_repo()));
196 self
197 }
198
199 pub fn with_result_postprocess(mut self, hook: ResultPostprocessHook) -> Self {
204 self.result_postprocess = Some(hook);
205 self
206 }
207}
208
209#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
210pub struct PingArgs {
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub message: Option<String>,
214}
215
216#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
217pub struct ReadSourceArgs {
218 pub file_path: String,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub start_line: Option<usize>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub end_line: Option<usize>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub grep: Option<String>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub grep_context: Option<usize>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub max_matches: Option<usize>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub max_chars: Option<usize>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub rev: Option<String>,
245}
246
247#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
248pub struct GrepArgs {
249 pub pattern: String,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub glob: Option<String>,
254 #[serde(default)]
256 pub context: usize,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub max_results: Option<usize>,
260 #[serde(default)]
262 pub case_insensitive: bool,
263}
264
265#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
266pub struct SetRootDirArgs {
267 pub path: String,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub revs: Option<crate::server::workspace::RevsRequest>,
278}
279
280#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
281pub struct RepoManagementArgs {
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub name: Option<String>,
285 #[serde(default)]
287 pub delete: bool,
288 #[serde(default)]
290 pub update: bool,
291 #[serde(default)]
295 pub force_rebuild: bool,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub revs: Option<crate::server::workspace::RevsRequest>,
305}
306
307#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
308pub struct GithubIssuesArgs {
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub number: Option<u64>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub repo_name: Option<String>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub query: Option<String>,
318 #[serde(default = "default_kind")]
320 pub kind: String,
321 #[serde(default = "default_state")]
323 pub state: String,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub sort: Option<String>,
327 #[serde(default = "default_limit")]
329 pub limit: usize,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub labels: Option<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub element_id: Option<String>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub lines: Option<String>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub grep: Option<String>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub context: Option<usize>,
352 #[serde(default)]
355 pub refresh: bool,
356}
357
358fn default_kind() -> String {
359 "all".to_string()
360}
361fn default_state() -> String {
362 "open".to_string()
363}
364fn default_limit() -> usize {
365 20
366}
367
368impl Default for GithubIssuesArgs {
369 fn default() -> Self {
370 Self {
371 number: None,
372 repo_name: None,
373 query: None,
374 kind: default_kind(),
375 state: default_state(),
376 sort: None,
377 limit: default_limit(),
378 labels: None,
379 element_id: None,
380 lines: None,
381 grep: None,
382 context: None,
383 refresh: false,
384 }
385 }
386}
387
388#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
389pub struct GithubApiArgs {
390 pub path: String,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub repo_name: Option<String>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub truncate_at: Option<usize>,
403}
404
405#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
406pub struct ListSourceArgs {
407 #[serde(default = "default_path")]
409 pub path: String,
410 #[serde(default = "default_depth")]
412 pub depth: usize,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub glob: Option<String>,
416 #[serde(default)]
418 pub dirs_only: bool,
419}
420
421fn default_path() -> String {
422 ".".to_string()
423}
424fn default_depth() -> usize {
425 1
426}
427
428#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
429pub struct ScreenStargazersArgs {
430 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub repo: Option<String>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub users: Option<String>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub preset: Option<String>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub rank_by: Option<String>,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
447 pub top: Option<usize>,
448 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub min_keywords: Option<usize>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub active_since: Option<String>,
454 #[serde(default)]
456 pub adopters_only: bool,
457 #[serde(default)]
459 pub stack_only: bool,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub keywords: Option<String>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub stack: Option<String>,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub max_stargazers: Option<usize>,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub element_id: Option<String>,
479 #[serde(default)]
481 pub refresh: bool,
482}
483
484#[derive(Clone)]
489pub struct McpServer {
490 options: ServerOptions,
491 tool_router: ToolRouter<McpServer>,
492 prompt_router: PromptRouter<McpServer>,
497}
498
499#[tool_router]
500impl McpServer {
501 pub fn new(options: ServerOptions) -> Self {
502 let mut server = Self {
503 options,
504 tool_router: Self::tool_router(),
505 prompt_router: PromptRouter::new(),
506 };
507 server.register_github_tools_if_authorized();
508 server.register_local_workspace_tools();
509 server.gate_workspace_tools();
510 server
511 }
512
513 fn gate_workspace_tools(&mut self) {
521 if self.options.workspace.is_none() {
522 self.tool_router.remove_route("repo_management");
523 }
524 }
525
526 fn register_local_workspace_tools(&mut self) {
530 let Some(ws) = self.options.workspace.clone() else {
531 return;
532 };
533 if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
534 return;
535 }
536 self.register_typed_tool::<SetRootDirArgs, _>(
537 "set_root_dir",
538 "Swap the active source root (local-workspace mode only). Pass `path` \
539 to a directory; the framework canonicalises it, rebinds the source \
540 tools (`read_source`, `grep`, `list_source`), and fires the post-\
541 activate hook so any downstream graph rebuilds against the new root. \
542 Pass `revs` (an integer N, or a list of git revspecs) to load multiple \
543 revisions of the root into one graph — N loads the newest N stable \
544 release tags of the dominant tag family plus HEAD (prereleases and \
545 unrelated tag families skipped); requires the root to be a git repo. \
546 Inventory persists across swaps; SHA-gating skips rebuilds when \
547 the same root is re-bound with no content changes.",
548 move |args: SetRootDirArgs| {
549 let p = std::path::PathBuf::from(&args.path);
550 ws.set_root_dir(&p, args.revs.as_ref())
551 },
552 );
553 }
554
555 fn register_github_tools_if_authorized(&mut self) {
572 if !self.options.builtins.github {
573 tracing::debug!(
577 "GitHub tools disabled (default) — set `builtins.github: true` in the manifest \
578 to register github_issues / github_api / screen_stargazers."
579 );
580 return;
581 }
582 if !crate::github::has_git_token() {
583 tracing::info!(
584 "`builtins.github: true` is set but no GitHub token is reachable — \
585 github_issues / github_api tools hidden from the agent. Set GITHUB_TOKEN \
586 (env or the manifest's env_file) and restart to enable them."
587 );
588 return;
589 }
590 let default_repo = self.options.default_repo.clone();
591 let repo_provider = default_repo.clone();
592 let cache: Arc<Mutex<crate::cache::ElementCache>> =
598 Arc::new(Mutex::new(crate::cache::ElementCache::new()));
599 let cache_for_issues = cache.clone();
600 self.register_typed_tool::<GithubIssuesArgs, _>(
601 "github_issues",
602 "Search, list, or fetch GitHub issues / pull requests / Discussions. \
603 Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
604 for SEARCH (across issues+PRs and Discussions); neither for LIST. \
605 `kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
606 `state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
607 result count (default 20). `labels` is a comma-separated string. \
608 `repo_name=\"org/repo\"` overrides the active repo for one call. \
609 FETCH responses collapse big code blocks / patches / comments into \
610 `cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
611 `element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
612 element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
613 `refresh=true` bypasses the cache for re-fetch.",
614 move |args: GithubIssuesArgs| {
615 let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
616 Ok(r) => r,
617 Err(msg) => return msg,
618 };
619 if let Some(number) = args.number {
625 let context = args.context.unwrap_or(3);
626 let mut guard = cache_for_issues.lock().unwrap();
627 return guard.fetch_issue(
628 &repo,
629 number,
630 args.element_id.as_deref(),
631 args.lines.as_deref(),
632 args.grep.as_deref(),
633 context,
634 args.refresh,
635 );
636 }
637 if args.element_id.is_some() {
638 return "element_id requires `number=N` (the issue/PR being drilled into)."
639 .to_string();
640 }
641 crate::github::github_issues_rust(
643 Some(&repo),
644 args.number,
645 args.query.as_deref(),
646 &args.kind,
647 &args.state,
648 args.sort.as_deref(),
649 args.limit,
650 args.labels.as_deref(),
651 )
652 },
653 );
654 let repo_provider = default_repo.clone();
655 let repo_for_screen = default_repo;
656 self.register_typed_tool::<GithubApiArgs, _>(
657 "github_api",
658 "Read-only GET against the GitHub REST API. `path` may be a \
659 repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
660 \"branches\", \"compare/main...feature\") which is auto-prefixed \
661 with /repos/<repo_name>/, or a top-level resource (\"search/issues?q=...\", \
662 \"users/octocat\", \"repos/owner/name\") which passes through. A \
663 leading slash is optional and accepted on either form. Returns \
664 JSON, truncated at 80 KB by default.",
665 move |args: GithubApiArgs| match resolve_repo_from(
666 repo_provider.as_ref(),
667 args.repo_name.clone(),
668 ) {
669 Ok(repo) => {
670 let truncate_at = args.truncate_at.unwrap_or(80_000);
671 crate::github::git_api_internal(&repo, &args.path, truncate_at)
672 }
673 Err(msg) => msg,
674 },
675 );
676
677 if self.options.builtins.screen_stargazers {
687 let screen_store: Arc<Mutex<crate::screen::ScreenStore>> =
688 Arc::new(Mutex::new(crate::screen::ScreenStore::new()));
689 self.register_typed_tool::<ScreenStargazersArgs, _>(
690 "screen_stargazers",
691 "Screen the people around a GitHub project to find relevant developers, \
692 notable/legendary devs, architectural peers, and actual users — cheaply. \
693 Seed on a repo (`repo=\"owner/repo\"` → screens its stargazers) OR an \
694 explicit user list (`users=\"alice,bob\"` → screens them directly). With \
695 just a repo it auto-derives relevance keywords + tech stack from the repo \
696 itself, bulk-fetches each person's public repo portfolio over plain REST \
697 (~1 request per person, no GraphQL, no READMEs), classifies them, and \
698 enriches a bounded shortlist with follower counts, dependency-adoption, \
699 stack co-location, and contributions. Every person gets a normalized \
700 0–100 score vector on four axes — relatedness, popularity, effort, \
701 recency. RANK/FILTER: pass a `preset` (\"outreach\"=relevant+active by \
702 reach, \"peers\"=your stack by effort, \"legends\"=biggest reach any \
703 domain, \"intel\"=on-domain by popularity, \"adopters\"=actual users), or \
704 `rank_by`=relatedness|popularity|effort|recency with filters \
705 (`min_keywords`, `active_since`, `adopters_only`, `stack_only`) and \
706 `top`=N (rank-then-take-N, default 10) for a focused filter→rank→take \
707 view; with none, the full multi-lens browse: \
708 `✅ ADOPTERS` (stargazers whose repos actually declare your package as a \
709 dependency — real users, not just watchers), `★ MOST RELEVANT` \
710 (relatedness — repos matching your topic keywords, with follower counts \
711 and external contributions), `🏆 NOTABLE` (popularity/reach lens — your \
712 highest-traction stargazers, flagged `LEGEND` for big audiences/projects), \
713 `✦ QUALITY` (best-kept maintained projects), `⚙ STACK MATCH` (architectural \
714 peers who build in your stack — co-location-confirmed where possible), and \
715 a cohort inventory. Override the auto-config with `keywords=\"graph,rag,agent\"` \
716 (single words — \"knowledge,graph\" not \"knowledge-graph\") and \
717 `stack=\"Rust,Python\"`; re-calling with new values re-ranks the cached \
718 fetch for free. Treat description-based leads as candidates to verify by \
719 drilling. DRILL via `element_id`: `\"cohort:<key>\"` (established / single / \
720 prolific / casual / dormant / consumers — the overview lists each key), \
721 `\"user:<login>\"` (portfolio), `\"user:<login>/repo:<name>\"` (repo profile), \
722 or `\"user:<login>/repo:<name>/readme\"` (README gist — the only drill that \
723 costs a request). `max_stargazers` samples the most-recent N (the overview \
724 reports if results are partial); `refresh=true` re-fetches.",
725 move |args: ScreenStargazersArgs| {
726 use crate::screen::{self, Filters, RankBy, Seed, Selection};
727 let split_csv = |s: Option<String>| -> Vec<String> {
728 s.map(|v| {
729 v.split(',')
730 .map(|t| t.trim().to_string())
731 .filter(|t| !t.is_empty())
732 .collect()
733 })
734 .unwrap_or_default()
735 };
736 let seed = if let Some(u) = &args.users {
738 Seed::Users(split_csv(Some(u.clone())))
739 } else {
740 let repo =
741 match resolve_repo_from(repo_for_screen.as_ref(), args.repo.clone()) {
742 Ok(r) => r,
743 Err(msg) => return msg,
744 };
745 if let Some(err) = crate::git_refs::validate_repo(&repo) {
746 return err;
747 }
748 Seed::Repo(repo)
749 };
750 let cfg = screen::ScreenConfig {
751 max_stargazers: args.max_stargazers,
752 max_repos_per_user: 100,
753 relevance_keywords: split_csv(args.keywords)
754 .into_iter()
755 .map(|k| k.to_lowercase())
756 .collect(),
757 stack_languages: split_csv(args.stack),
758 };
759 let top = args.top.unwrap_or(10);
761 let filters = Filters {
762 min_keywords: args.min_keywords,
763 active_since: args.active_since.clone(),
764 adopters_only: args.adopters_only,
765 stack_only: args.stack_only,
766 ..Default::default()
767 };
768 let filters_active = filters.min_keywords.is_some()
769 || filters.active_since.is_some()
770 || filters.adopters_only
771 || filters.stack_only;
772 let selection: Option<Selection> = if let Some(name) = &args.preset {
773 screen::preset(name, top)
774 } else if args.rank_by.is_some() || filters_active {
775 Some(Selection {
776 filters,
777 rank: args
778 .rank_by
779 .as_deref()
780 .and_then(RankBy::parse)
781 .unwrap_or(RankBy::Relatedness),
782 label: "SELECTION".into(),
783 take: top,
784 })
785 } else {
786 None
787 };
788 screen::screen_dispatch(
789 &screen_store,
790 &seed,
791 &cfg,
792 selection.as_ref(),
793 args.element_id.as_deref(),
794 args.refresh,
795 )
796 },
797 );
798 }
799 }
800
801 pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
808 &self.options.builtins
809 }
810
811 pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
817 &mut self.tool_router
818 }
819
820 pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
825 &mut self.prompt_router
826 }
827
828 pub fn register_typed_tool<T, F>(
843 &mut self,
844 name: &'static str,
845 description: &'static str,
846 handler: F,
847 ) where
848 T: for<'de> serde::Deserialize<'de>
849 + schemars::JsonSchema
850 + Default
851 + Send
852 + Sync
853 + 'static,
854 F: Fn(T) -> String + Send + Sync + 'static,
855 {
856 use std::pin::Pin;
857 type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
858
859 let schema_obj = serde_json::to_value(schemars::schema_for!(T))
860 .ok()
861 .and_then(|v| v.as_object().cloned())
862 .unwrap_or_default();
863 let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
864 let handler = std::sync::Arc::new(handler);
865 let tool_name = name;
870 let postprocess = self.options.result_postprocess.clone();
871 let source_roots = self.options.source_roots.clone();
872 let workspace = self.options.workspace.clone();
873
874 self.tool_router
875 .add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
876 attr,
877 move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
878 -> DynFut<'_, Result<rmcp::model::CallToolResponse, rmcp::ErrorData>> {
879 let handler = handler.clone();
880 let arguments = ctx.arguments.clone();
881 let postprocess = postprocess.clone();
882 let source_roots = source_roots.clone();
883 let workspace = workspace.clone();
884 Box::pin(async move {
885 let args_json = match &arguments {
888 Some(map) => serde_json::Value::Object(map.clone()),
889 None => serde_json::Value::Null,
890 };
891 let args: T = match arguments {
892 Some(map) => {
893 match serde_json::from_value(serde_json::Value::Object(map)) {
894 Ok(a) => a,
895 Err(e) => {
896 return Ok(rmcp::model::CallToolResult::success(vec![
897 rmcp::model::ContentBlock::text(format!(
898 "invalid arguments: {e}"
899 )),
900 ])
901 .into());
902 }
903 }
904 }
905 None => T::default(),
906 };
907 let body = handler(args);
908 let body = match &postprocess {
909 Some(hook) => {
910 let ctx = ResultCtx {
911 source_roots: source_roots
912 .as_ref()
913 .map(|p| p())
914 .unwrap_or_default(),
915 active_repo: workspace
916 .as_ref()
917 .and_then(|w| w.active_repo_name()),
918 };
919 let footer = hook(tool_name, &args_json, &body, &ctx);
920 append_footer(body, footer)
921 }
922 None => body,
923 };
924 Ok(rmcp::model::CallToolResult::success(vec![
925 rmcp::model::ContentBlock::text(body),
926 ])
927 .into())
928 })
929 },
930 ));
931 }
932
933 fn current_source_roots(&self) -> Vec<String> {
934 match &self.options.source_roots {
935 Some(provider) => provider(),
936 None => Vec::new(),
937 }
938 }
939
940 fn finish(&self, tool: &str, args: &serde_json::Value, body: String) -> String {
947 let Some(hook) = &self.options.result_postprocess else {
948 return body;
949 };
950 let ctx = ResultCtx {
951 source_roots: self.current_source_roots(),
952 active_repo: self
953 .options
954 .workspace
955 .as_ref()
956 .and_then(|w| w.active_repo_name()),
957 };
958 let footer = hook(tool, args, &body, &ctx);
959 append_footer(body, footer)
960 }
961
962 #[allow(dead_code)]
967 fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
968 resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
969 }
970
971 #[tool(
972 description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
973 Use to confirm the server framework is wired correctly before \
974 relying on graph- or source-aware tools."
975 )]
976 async fn ping(
977 &self,
978 Parameters(args): Parameters<PingArgs>,
979 ) -> Result<CallToolResult, McpError> {
980 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
981 let body = args.message.unwrap_or_else(|| "pong".to_string());
982 let body = self.finish("ping", &args_json, body);
983 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
984 }
985
986 #[tool(description = "Read a file from the configured source root(s). Pass \
987 `start_line`/`end_line` to slice, `grep` to filter to matching \
988 lines, `max_chars` to cap output. Pass `rev` (a tag, branch, or \
989 commit SHA) to read the file's content at that git revision via \
990 `git show` instead of the working tree — useful for comparing a \
991 file across releases (requires a git repo source root). Path \
992 traversal attempts are rejected. Available only when source roots \
993 are configured.")]
994 async fn read_source(
995 &self,
996 Parameters(args): Parameters<ReadSourceArgs>,
997 ) -> Result<CallToolResult, McpError> {
998 let roots = self.current_source_roots();
999 if roots.is_empty() {
1000 return Ok(CallToolResult::success(vec![ContentBlock::text(
1001 "Cannot read source: no active source root. Configure source_root in your manifest \
1002 or activate one (e.g. via repo_management in workspace mode).",
1003 )]));
1004 }
1005 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1006 let opts = ReadOpts {
1007 start_line: args.start_line,
1008 end_line: args.end_line,
1009 grep: args.grep,
1010 grep_context: args.grep_context,
1011 max_matches: args.max_matches,
1012 max_chars: args.max_chars,
1013 rev: args.rev,
1014 };
1015 let body = source::read_source(&args.file_path, &roots, &opts);
1016 let body = self.finish("read_source", &args_json, body);
1017 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1018 }
1019
1020 #[tool(
1021 description = "Search source files using ripgrep. `pattern` is a regex (Rust \
1022 syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
1023 N surrounding lines per match. Set `case_insensitive=true` for \
1024 case-insensitive matching. `max_results` caps total matches \
1025 (default 50)."
1026 )]
1027 async fn grep(
1028 &self,
1029 Parameters(args): Parameters<GrepArgs>,
1030 ) -> Result<CallToolResult, McpError> {
1031 let roots = self.current_source_roots();
1032 if roots.is_empty() {
1033 return Ok(CallToolResult::success(vec![ContentBlock::text(
1034 "Cannot grep: no active source root. Configure source_root in your manifest \
1035 or activate one (e.g. via repo_management in workspace mode).",
1036 )]));
1037 }
1038 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1039 let opts = GrepOpts {
1040 glob: args.glob,
1041 context: args.context,
1042 max_results: Some(args.max_results.unwrap_or(50)),
1043 case_insensitive: args.case_insensitive,
1044 };
1045 let body = source::grep(&roots, &args.pattern, &opts);
1046 let body = self.finish("grep", &args_json, body);
1047 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1048 }
1049
1050 #[tool(
1051 description = "List directory contents under the configured source root. `path` \
1052 is resolved against the first source root (\".\" lists the root \
1053 itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
1054 `glob` filters entry names. `dirs_only=true` shows only \
1055 directories."
1056 )]
1057 async fn list_source(
1058 &self,
1059 Parameters(args): Parameters<ListSourceArgs>,
1060 ) -> Result<CallToolResult, McpError> {
1061 let roots = self.current_source_roots();
1062 if roots.is_empty() {
1063 return Ok(CallToolResult::success(vec![ContentBlock::text(
1064 "Cannot list source: no active source root. Configure source_root in your \
1065 manifest or activate one (e.g. via repo_management in workspace mode).",
1066 )]));
1067 }
1068 let primary = std::path::PathBuf::from(&roots[0]);
1069 let target = match resolve_dir_under_roots(&args.path, &roots) {
1070 Some(p) => p,
1071 None => {
1072 return Ok(CallToolResult::success(vec![ContentBlock::text(format!(
1073 "Error: path '{}' resolves outside the configured source roots.",
1074 args.path
1075 ))]));
1076 }
1077 };
1078 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1079 let opts = ListOpts {
1080 depth: args.depth,
1081 glob: args.glob,
1082 dirs_only: args.dirs_only,
1083 };
1084 let body = source::list_source(&target, &primary, &opts);
1085 let body = self.finish("list_source", &args_json, body);
1086 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1087 }
1088
1089 #[tool(
1090 description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
1091 clone (if missing) and activate it as the source root for \
1092 read_source / grep / list_source. Pass `delete=true` to remove a \
1093 repo. Pass `update=true` to fetch upstream changes for the active \
1094 repo (rebuild auto-skipped when HEAD hasn't moved since the last \
1095 build; set `force_rebuild=true` to bypass). Pass `revs` (an \
1096 integer N, or a list of git revspecs) to load multiple revisions \
1097 of the repo into one graph — N loads the newest N stable release \
1098 tags of the dominant tag family plus HEAD (prereleases and \
1099 unrelated tag families skipped); a revs request always rebuilds. \
1100 Call with no \
1101 arguments to list all known repos with their last-access counts. \
1102 Idle repos auto-sweep on each call (default 7 days, configurable \
1103 via --stale-after-days)."
1104 )]
1105 async fn repo_management(
1106 &self,
1107 Parameters(args): Parameters<RepoManagementArgs>,
1108 ) -> Result<CallToolResult, McpError> {
1109 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1110 let body = match &self.options.workspace {
1111 Some(ws) => ws.repo_management(
1112 args.name.as_deref(),
1113 args.delete,
1114 args.update,
1115 args.force_rebuild,
1116 args.revs.as_ref(),
1117 ),
1118 None => "repo_management requires --workspace mode.".to_string(),
1119 };
1120 let body = self.finish("repo_management", &args_json, body);
1121 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1122 }
1123}
1124
1125fn resolve_repo_from(
1133 default_repo: Option<&RepoProvider>,
1134 override_repo: Option<String>,
1135) -> Result<String, String> {
1136 if let Some(r) = override_repo {
1137 if let Some(err) = crate::git_refs::validate_repo(&r) {
1138 return Err(err);
1139 }
1140 return Ok(r);
1141 }
1142 if let Some(provider) = default_repo {
1143 if let Some(r) = provider() {
1144 if let Some(err) = crate::git_refs::validate_repo(&r) {
1145 return Err(err);
1146 }
1147 return Ok(r);
1148 }
1149 }
1150 if let Some(detected) = crate::github::detect_git_repo(".") {
1151 if crate::git_refs::validate_repo(&detected).is_none() {
1152 return Ok(detected);
1153 }
1154 }
1155 Err(
1156 "No active repository. Pass `repo_name='org/repo'`, configure a default in the \
1157 server, or run from a directory whose git remote points at github.com."
1158 .to_string(),
1159 )
1160}
1161
1162pub fn serve_prompts(registry: &ResolvedRegistry, server: &mut McpServer) {
1176 use std::borrow::Cow;
1177 use std::collections::HashSet;
1178
1179 let registered_tools: HashSet<String> = server
1184 .tool_router
1185 .list_all()
1186 .iter()
1187 .map(|t| t.name.to_string())
1188 .collect();
1189 let extensions = server.options.extensions.clone();
1190
1191 struct InjectSkill {
1197 name: String,
1198 description: String,
1199 body: String,
1200 references_tools: Vec<String>,
1201 }
1202 let mut auto_inject: Vec<InjectSkill> = Vec::new();
1203
1204 for name in registry.skill_names() {
1205 let Some(skill) = registry.get(&name) else {
1206 continue;
1207 };
1208
1209 let activation = registry.activation_for(skill, ®istered_tools, &extensions);
1213 if !activation.active {
1214 let failed_clauses: Vec<&str> = activation
1215 .clauses
1216 .iter()
1217 .filter(|(_, outcome)| {
1218 *outcome != crate::server::skills::PredicateOutcome::Satisfied
1219 })
1220 .map(|(clause, _)| clause.as_str())
1221 .collect();
1222 tracing::info!(
1223 skill = %name,
1224 suppressed_by = ?failed_clauses,
1225 "skill suppressed by applies_when predicates"
1226 );
1227 continue;
1228 }
1229
1230 let prompt = Prompt::new(
1231 skill.name().to_string(),
1232 Some(skill.description().to_string()),
1233 None,
1234 );
1235 let body = skill.body.clone();
1236 let route = PromptRoute::new_dyn(prompt, move |_ctx| {
1237 let body = body.clone();
1238 Box::pin(async move {
1239 Ok(
1240 GetPromptResult::new(vec![PromptMessage::new_text(Role::Assistant, body)])
1241 .into(),
1242 )
1243 })
1244 });
1245 server.prompt_router.add_route(route);
1246
1247 if skill.frontmatter.auto_inject_hint {
1248 auto_inject.push(InjectSkill {
1249 name: skill.name().to_string(),
1250 description: skill.description().to_string(),
1251 body: skill.body.clone(),
1252 references_tools: skill.frontmatter.references_tools.clone(),
1253 });
1254 }
1255 }
1256
1257 for inj in &auto_inject {
1293 let mut targets: Vec<&str> = Vec::new();
1296 let mut seen: HashSet<&str> = HashSet::new();
1297 for tool in std::iter::once(inj.name.as_str())
1298 .chain(inj.references_tools.iter().map(String::as_str))
1299 {
1300 if seen.insert(tool) {
1301 targets.push(tool);
1302 }
1303 }
1304
1305 let marker = format!("<!-- mcp-skill:{} -->", inj.name);
1308 let mut block = format!("\n\n{marker}");
1309 let description = inj.description.trim();
1310 if !description.is_empty() {
1311 block.push_str("\n\n## When to use\n\n");
1312 block.push_str(description);
1313 }
1314 block.push_str("\n\n## Methodology\n\n");
1315 block.push_str(inj.body.trim());
1316
1317 for tool in targets {
1318 let key = Cow::<'static, str>::Owned(tool.to_string());
1319 let Some(route) = server.tool_router.map.get_mut(&key) else {
1320 continue;
1321 };
1322 if route
1325 .attr
1326 .description
1327 .as_deref()
1328 .is_some_and(|d| d.contains(&marker))
1329 {
1330 continue;
1331 }
1332 let new_desc = match route.attr.description.take() {
1333 Some(existing) => format!("{existing}{block}"),
1334 None => block.trim_start().to_string(),
1335 };
1336 route.attr.description = Some(Cow::Owned(new_desc));
1337 }
1338 }
1339}
1340
1341#[tool_handler(router = self.tool_router)]
1342impl ServerHandler for McpServer {
1343 fn get_info(&self) -> ServerInfo {
1344 let name = self
1345 .options
1346 .name
1347 .clone()
1348 .unwrap_or_else(|| "MCP Server".to_string());
1349 let mut caps = ServerCapabilities::builder().enable_tools().build();
1356 if !self.prompt_router.map.is_empty() {
1357 caps.prompts = Some(PromptsCapability::default());
1358 }
1359 let mut info = ServerInfo::new(caps)
1360 .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
1361 .with_protocol_version(ProtocolVersion::V_2024_11_05);
1362 if let Some(text) = &self.options.instructions {
1363 info = info.with_instructions(text.clone());
1364 }
1365 info
1366 }
1367
1368 async fn on_initialized(&self, context: rmcp::service::NotificationContext<rmcp::RoleServer>) {
1383 tracing::info!("client initialized");
1386 crate::server::roots::on_client_initialized(&self.options, &context.peer).await;
1387 }
1388
1389 async fn on_roots_list_changed(
1392 &self,
1393 context: rmcp::service::NotificationContext<rmcp::RoleServer>,
1394 ) {
1395 crate::server::roots::on_client_roots_changed(&self.options, &context.peer).await;
1396 }
1397
1398 async fn list_prompts(
1399 &self,
1400 _request: Option<PaginatedRequestParams>,
1401 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
1402 ) -> Result<ListPromptsResult, McpError> {
1403 Ok(ListPromptsResult {
1404 prompts: self.prompt_router.list_all(),
1405 ..Default::default()
1406 })
1407 }
1408
1409 async fn get_prompt(
1410 &self,
1411 request: GetPromptRequestParams,
1412 context: rmcp::service::RequestContext<rmcp::RoleServer>,
1413 ) -> Result<GetPromptResponse, McpError> {
1414 let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
1415 self,
1416 request.name,
1417 request.arguments,
1418 context,
1419 );
1420 self.prompt_router.get_prompt(prompt_context).await
1421 }
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426 use super::*;
1427
1428 #[test]
1429 fn options_from_manifest_uses_name_when_set() {
1430 let opts = ServerOptions::from_manifest(None, "Fallback");
1431 assert_eq!(opts.name.as_deref(), Some("Fallback"));
1432 }
1433
1434 #[test]
1435 fn builtins_exposed_via_server() {
1436 use crate::server::manifest::{BuiltinsConfig, TempCleanup};
1437 let opts = ServerOptions {
1438 builtins: BuiltinsConfig {
1439 save_graph: true,
1440 temp_cleanup: TempCleanup::OnOverview,
1441 ..Default::default()
1442 },
1443 ..ServerOptions::default()
1444 };
1445 let server = McpServer::new(opts);
1446 assert!(server.builtins().save_graph);
1447 assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
1448 }
1449
1450 #[test]
1451 fn server_constructs() {
1452 let _server = McpServer::new(ServerOptions::default());
1453 }
1454
1455 #[test]
1456 fn static_source_roots_provider() {
1457 let opts = ServerOptions::default()
1458 .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
1459 let server = McpServer::new(opts);
1460 assert_eq!(
1461 server.current_source_roots(),
1462 vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
1463 );
1464 }
1465
1466 #[test]
1467 fn no_provider_returns_empty_roots() {
1468 let server = McpServer::new(ServerOptions::default());
1469 assert!(server.current_source_roots().is_empty());
1470 }
1471
1472 #[test]
1473 fn repo_management_gated_to_workspace_mode() {
1474 let server = McpServer::new(ServerOptions::default());
1477 let tools = server.tool_router.list_all();
1478 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1479 assert!(
1480 !names.contains(&"repo_management"),
1481 "repo_management should be gated out without a workspace; tools were {names:?}"
1482 );
1483 }
1484
1485 fn github_tool_surface(github_opt_in: bool, token_present: bool) -> Vec<String> {
1491 use crate::server::manifest::BuiltinsConfig;
1492 let _g = crate::github::env_lock();
1493 let prev_token = std::env::var("GITHUB_TOKEN").ok();
1494 let prev_alt = std::env::var("GH_TOKEN").ok();
1495 unsafe {
1496 std::env::remove_var("GH_TOKEN");
1497 if token_present {
1498 std::env::set_var("GITHUB_TOKEN", "ghp_surface_test_not_real");
1499 } else {
1500 std::env::remove_var("GITHUB_TOKEN");
1501 }
1502 }
1503 let opts = ServerOptions {
1504 builtins: BuiltinsConfig {
1505 github: github_opt_in,
1506 ..Default::default()
1507 },
1508 ..ServerOptions::default()
1509 };
1510 let server = McpServer::new(opts);
1511 let names: Vec<String> = server
1512 .tool_router
1513 .list_all()
1514 .iter()
1515 .map(|t| t.name.to_string())
1516 .collect();
1517 unsafe {
1518 match prev_token {
1519 Some(v) => std::env::set_var("GITHUB_TOKEN", v),
1520 None => std::env::remove_var("GITHUB_TOKEN"),
1521 }
1522 match prev_alt {
1523 Some(v) => std::env::set_var("GH_TOKEN", v),
1524 None => std::env::remove_var("GH_TOKEN"),
1525 }
1526 }
1527 names
1528 }
1529
1530 const GITHUB_TOOLS: [&str; 3] = ["github_issues", "github_api", "screen_stargazers"];
1531
1532 #[test]
1533 fn github_tools_absent_by_default_even_with_a_token() {
1534 let names = github_tool_surface(false, true);
1538 for tool in GITHUB_TOOLS {
1539 assert!(
1540 !names.iter().any(|n| n == tool),
1541 "{tool} registered without `builtins.github: true`; tools were {names:?}"
1542 );
1543 }
1544 }
1545
1546 #[test]
1547 fn github_tools_register_on_opt_in_with_a_token() {
1548 let names = github_tool_surface(true, true);
1549 for tool in GITHUB_TOOLS {
1550 assert!(
1551 names.iter().any(|n| n == tool),
1552 "{tool} missing with `builtins.github: true` and a token; tools were {names:?}"
1553 );
1554 }
1555 }
1556
1557 #[test]
1558 fn github_tools_absent_on_opt_in_without_a_token() {
1559 let names = github_tool_surface(true, false);
1562 for tool in GITHUB_TOOLS {
1563 assert!(
1564 !names.iter().any(|n| n == tool),
1565 "{tool} registered with no reachable token; tools were {names:?}"
1566 );
1567 }
1568 }
1569
1570 #[test]
1571 fn repo_management_present_when_workspace_bound() {
1572 use crate::server::workspace::Workspace;
1575 let dir = tempfile::tempdir().unwrap();
1576 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1577 let opts = ServerOptions::default().with_workspace(ws);
1578 let server = McpServer::new(opts);
1579 let tools = server.tool_router.list_all();
1580 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1581 assert!(
1582 names.contains(&"repo_management"),
1583 "repo_management should be registered with a workspace; tools were {names:?}"
1584 );
1585 }
1586
1587 #[test]
1588 fn result_postprocess_appends_footer_and_sees_ctx() {
1589 use std::sync::Mutex;
1590 type Seen = Option<(String, serde_json::Value, String, Vec<String>)>;
1592 let seen: Arc<Mutex<Seen>> = Arc::new(Mutex::new(None));
1593 let seen_c = seen.clone();
1594 let hook: ResultPostprocessHook = Arc::new(move |tool, args, body, ctx| {
1595 *seen_c.lock().unwrap() = Some((
1596 tool.to_string(),
1597 args.clone(),
1598 body.to_string(),
1599 ctx.source_roots.clone(),
1600 ));
1601 if tool == "grep" {
1603 Some("↳ prefer cypher_query".to_string())
1604 } else {
1605 None
1606 }
1607 });
1608 let opts = ServerOptions::default()
1609 .with_static_source_roots(vec!["/src".to_string()])
1610 .with_result_postprocess(hook);
1611 let server = McpServer::new(opts);
1612
1613 let args = serde_json::json!({ "pattern": "^fn " });
1614 let out = server.finish("grep", &args, "match line".to_string());
1615 assert_eq!(out, "match line\n\n↳ prefer cypher_query");
1616
1617 let rec = seen.lock().unwrap().clone().unwrap();
1618 assert_eq!(rec.0, "grep");
1619 assert_eq!(rec.1, args);
1620 assert_eq!(rec.2, "match line");
1621 assert_eq!(rec.3, vec!["/src".to_string()]);
1622
1623 let out2 = server.finish("read_source", &args, "file body".to_string());
1625 assert_eq!(out2, "file body");
1626 }
1627
1628 #[test]
1629 fn no_result_postprocess_leaves_body_unchanged() {
1630 let server = McpServer::new(ServerOptions::default());
1631 let out = server.finish("grep", &serde_json::Value::Null, "x".to_string());
1632 assert_eq!(out, "x");
1633 }
1634
1635 #[test]
1636 fn append_footer_ignores_empty_footers() {
1637 assert_eq!(append_footer("a".to_string(), None), "a");
1638 assert_eq!(append_footer("a".to_string(), Some(String::new())), "a");
1639 assert_eq!(
1640 append_footer("a".to_string(), Some("b".to_string())),
1641 "a\n\nb"
1642 );
1643 }
1644
1645 #[test]
1646 fn dynamic_provider_swaps_at_call_time() {
1647 use std::sync::Mutex;
1648 let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
1649 let s2 = state.clone();
1650 let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
1651 let opts = ServerOptions::default().with_dynamic_source_roots(provider);
1652 let server = McpServer::new(opts);
1653 assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
1654 *state.lock().unwrap() = vec!["/swapped".to_string()];
1655 assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
1656 }
1657
1658 fn build_test_registry(
1661 skills: &[(&str, &str, &str, bool)],
1662 ) -> crate::server::skills::ResolvedRegistry {
1663 use crate::server::skills::Registry;
1664 let dir = tempfile::tempdir().unwrap();
1665 let yaml_path = dir.path().join("manifest.yaml");
1666 let skills_dir = dir.path().join("manifest.skills");
1667 std::fs::create_dir_all(&skills_dir).unwrap();
1668 for (name, description, body, auto_inject) in skills {
1669 let auto = if *auto_inject { "true" } else { "false" };
1670 let content = format!(
1671 "---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
1672 );
1673 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1674 }
1675 Registry::new()
1676 .auto_detect_project_layer(&yaml_path)
1677 .finalise()
1678 .unwrap()
1679 }
1680
1681 fn build_registry_with_refs(
1686 skills: &[(&str, &str, &str, &str)],
1687 ) -> crate::server::skills::ResolvedRegistry {
1688 use crate::server::skills::Registry;
1689 let dir = tempfile::tempdir().unwrap();
1690 let yaml_path = dir.path().join("manifest.yaml");
1691 let skills_dir = dir.path().join("manifest.skills");
1692 std::fs::create_dir_all(&skills_dir).unwrap();
1693 for (name, description, body, references_tools) in skills {
1694 let content = format!(
1695 "---\nname: {name}\ndescription: {description}\n\
1696 auto_inject_hint: true\nreferences_tools: {references_tools}\n---\n\n{body}\n"
1697 );
1698 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1699 }
1700 Registry::new()
1701 .auto_detect_project_layer(&yaml_path)
1702 .finalise()
1703 .unwrap()
1704 }
1705
1706 fn tool_desc(server: &McpServer, tool: &str) -> String {
1707 server
1708 .tool_router
1709 .get(tool)
1710 .and_then(|t| t.description.clone())
1711 .map(|c| c.into_owned())
1712 .unwrap_or_default()
1713 }
1714
1715 #[test]
1716 fn prompt_router_empty_by_default() {
1717 let server = McpServer::new(ServerOptions::default());
1718 assert!(server.prompt_router.map.is_empty());
1719 }
1720
1721 #[test]
1722 fn get_info_no_prompts_capability_when_empty() {
1723 let server = McpServer::new(ServerOptions::default());
1727 let info = server.get_info();
1728 assert!(
1729 info.capabilities.prompts.is_none(),
1730 "prompts capability must be absent when no skills are registered"
1731 );
1732 }
1733
1734 #[test]
1735 fn serve_prompts_registers_routes_with_metadata() {
1736 let registry = build_test_registry(&[
1737 ("alpha", "First skill.", "Alpha body.", true),
1738 ("beta", "Second skill.", "Beta body.", true),
1739 ]);
1740 let mut server = McpServer::new(ServerOptions::default());
1741 super::serve_prompts(®istry, &mut server);
1742
1743 let prompts = server.prompt_router.list_all();
1744 let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
1745 assert_eq!(names, vec!["alpha", "beta"]);
1746
1747 let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
1748 assert_eq!(alpha.description.as_deref(), Some("First skill."));
1749 assert!(alpha.arguments.is_none());
1750 }
1751
1752 #[test]
1753 fn serve_prompts_empty_registry_is_noop() {
1754 let registry = crate::server::skills::ResolvedRegistry::default();
1755 let mut server = McpServer::new(ServerOptions::default());
1756 super::serve_prompts(®istry, &mut server);
1757 assert!(server.prompt_router.map.is_empty());
1758 assert!(server.get_info().capabilities.prompts.is_none());
1759 }
1760
1761 #[test]
1762 fn get_info_advertises_prompts_when_present() {
1763 let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
1764 let mut server = McpServer::new(ServerOptions::default());
1765 super::serve_prompts(®istry, &mut server);
1766 let info = server.get_info();
1767 assert!(
1768 info.capabilities.prompts.is_some(),
1769 "prompts capability must be advertised once a skill is registered"
1770 );
1771 }
1772
1773 #[test]
1774 fn serve_prompts_auto_injects_full_body_into_matching_tool() {
1775 let registry =
1783 build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
1784 let mut server = McpServer::new(ServerOptions::default());
1785 let before = server
1786 .tool_router
1787 .get("ping")
1788 .and_then(|t| t.description.clone())
1789 .map(|c| c.into_owned())
1790 .unwrap_or_default();
1791 super::serve_prompts(®istry, &mut server);
1792 let after = server
1793 .tool_router
1794 .get("ping")
1795 .and_then(|t| t.description.clone())
1796 .map(|c| c.into_owned())
1797 .unwrap_or_default();
1798 assert!(after.starts_with(&before), "original description preserved");
1799 assert!(
1800 after.contains("## Methodology"),
1801 "inject should include a Methodology header; got: {after}"
1802 );
1803 assert!(
1804 after.contains("PING-BODY-SENTINEL"),
1805 "inject should embed the full skill body; got: {after}"
1806 );
1807 assert!(
1808 !after.contains("prompts/get"),
1809 "post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
1810 );
1811 }
1812
1813 #[test]
1814 fn serve_prompts_skips_injection_when_disabled() {
1815 let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
1816 let mut server = McpServer::new(ServerOptions::default());
1817 let before = server
1818 .tool_router
1819 .get("ping")
1820 .and_then(|t| t.description.clone())
1821 .map(|c| c.into_owned())
1822 .unwrap_or_default();
1823 super::serve_prompts(®istry, &mut server);
1824 let after = server
1825 .tool_router
1826 .get("ping")
1827 .and_then(|t| t.description.clone())
1828 .map(|c| c.into_owned())
1829 .unwrap_or_default();
1830 assert_eq!(
1831 before, after,
1832 "auto_inject_hint=false must leave tool description untouched"
1833 );
1834 }
1835
1836 #[test]
1837 fn serve_prompts_skips_injection_when_no_matching_tool() {
1838 let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
1841 let mut server = McpServer::new(ServerOptions::default());
1842 super::serve_prompts(®istry, &mut server);
1843 assert!(server.prompt_router.map.contains_key("no_such_tool"));
1844 let ping_desc = server
1847 .tool_router
1848 .get("ping")
1849 .and_then(|t| t.description.clone())
1850 .map(|c| c.into_owned())
1851 .unwrap_or_default();
1852 assert!(!ping_desc.contains("no_such_tool"));
1853 }
1854
1855 #[test]
1856 fn serve_prompts_injects_description_under_when_to_use() {
1857 let registry = build_test_registry(&[("ping", "ROUTING-SENTINEL", "BODY-SENTINEL", true)]);
1861 let mut server = McpServer::new(ServerOptions::default());
1862 super::serve_prompts(®istry, &mut server);
1863 let desc = tool_desc(&server, "ping");
1864 assert!(
1865 desc.contains("## When to use\n\nROUTING-SENTINEL"),
1866 "description should be injected under `## When to use`; got: {desc}"
1867 );
1868 assert!(
1869 desc.contains("<!-- mcp-skill:ping -->"),
1870 "injection should carry the per-skill idempotency marker; got: {desc}"
1871 );
1872 let when = desc.find("## When to use").unwrap();
1874 let method = desc.find("## Methodology").unwrap();
1875 assert!(when < method, "`When to use` must precede `Methodology`");
1876 }
1877
1878 #[test]
1879 fn serve_prompts_honors_references_tools() {
1880 let registry = build_registry_with_refs(&[(
1884 "graph_strategy",
1885 "Map structure first.",
1886 "GRAPH-BODY-SENTINEL",
1887 "[ping]",
1888 )]);
1889 let mut server = McpServer::new(ServerOptions::default());
1890 super::serve_prompts(®istry, &mut server);
1891 assert!(server.prompt_router.map.contains_key("graph_strategy"));
1893 let desc = tool_desc(&server, "ping");
1895 assert!(
1896 desc.contains("<!-- mcp-skill:graph_strategy -->"),
1897 "referenced tool should carry the skill marker; got: {desc}"
1898 );
1899 assert!(
1900 desc.contains("Map structure first."),
1901 "referenced tool should carry the skill routing; got: {desc}"
1902 );
1903 assert!(
1904 desc.contains("GRAPH-BODY-SENTINEL"),
1905 "referenced tool should carry the skill body; got: {desc}"
1906 );
1907 }
1908
1909 #[test]
1910 fn serve_prompts_idempotent_when_skill_self_references() {
1911 let registry = build_registry_with_refs(&[("ping", "Routing.", "Body.", "[ping]")]);
1915 let mut server = McpServer::new(ServerOptions::default());
1916 super::serve_prompts(®istry, &mut server);
1917 let desc = tool_desc(&server, "ping");
1918 let marker_count = desc.matches("<!-- mcp-skill:ping -->").count();
1919 assert_eq!(
1920 marker_count, 1,
1921 "self-referencing skill must inject exactly once; got {marker_count}: {desc}"
1922 );
1923 }
1924
1925 #[test]
1926 fn serve_prompts_idempotent_across_repeated_passes() {
1927 let registry = build_test_registry(&[("ping", "Routing.", "Body.", true)]);
1930 let mut server = McpServer::new(ServerOptions::default());
1931 super::serve_prompts(®istry, &mut server);
1932 let once = tool_desc(&server, "ping");
1933 super::serve_prompts(®istry, &mut server);
1934 let twice = tool_desc(&server, "ping");
1935 assert_eq!(
1936 once, twice,
1937 "second pass must be a no-op for an already-injected tool"
1938 );
1939 }
1940
1941 #[test]
1942 fn serve_prompts_multiple_skills_stack_on_one_tool() {
1943 let registry = build_registry_with_refs(&[
1947 ("ping", "Ping routing.", "PING-BODY", "[]"),
1948 ("ping_strategy", "Strategy routing.", "STRAT-BODY", "[ping]"),
1949 ]);
1950 let mut server = McpServer::new(ServerOptions::default());
1951 super::serve_prompts(®istry, &mut server);
1952 let desc = tool_desc(&server, "ping");
1953 assert!(desc.contains("<!-- mcp-skill:ping -->"), "got: {desc}");
1954 assert!(
1955 desc.contains("<!-- mcp-skill:ping_strategy -->"),
1956 "got: {desc}"
1957 );
1958 assert!(
1959 desc.contains("PING-BODY") && desc.contains("STRAT-BODY"),
1960 "got: {desc}"
1961 );
1962 }
1963
1964 fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
1965 let dir = tempfile::tempdir().unwrap();
1966 let yaml = dir.path().join("test_mcp.yaml");
1967 std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1968 let skills_dir = dir.path().join("test_mcp.skills");
1969 std::fs::create_dir(&skills_dir).unwrap();
1970 std::fs::write(
1971 skills_dir.join("gated_skill.md"),
1972 format!(
1973 "---\n\
1974 name: gated_skill\n\
1975 description: A predicate-gated skill for testing.\n\
1976 applies_when:\n\
1977 {applies_when_yaml}\n\
1978 ---\n\n\
1979 Body.\n",
1980 ),
1981 )
1982 .unwrap();
1983 dir
1984 }
1985
1986 #[test]
1987 fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
1988 use crate::server::skills::Registry as SkillsBuilder;
1992 let dir = write_gated_project_skill(" tool_registered: nonexistent_tool");
1993 let yaml = dir.path().join("test_mcp.yaml");
1994 let registry = SkillsBuilder::new()
1995 .auto_detect_project_layer(&yaml)
1996 .finalise()
1997 .unwrap();
1998 let mut server = McpServer::new(ServerOptions::default());
1999 super::serve_prompts(®istry, &mut server);
2000 assert!(
2001 !server.prompt_router.map.contains_key("gated_skill"),
2002 "skill with unsatisfied predicate must be suppressed"
2003 );
2004 }
2005
2006 #[test]
2007 fn serve_prompts_keeps_skill_with_satisfied_predicate() {
2008 use crate::server::skills::Registry as SkillsBuilder;
2011 let dir = write_gated_project_skill(" tool_registered: ping");
2012 let yaml = dir.path().join("test_mcp.yaml");
2013 let registry = SkillsBuilder::new()
2014 .auto_detect_project_layer(&yaml)
2015 .finalise()
2016 .unwrap();
2017 let mut server = McpServer::new(ServerOptions::default());
2018 super::serve_prompts(®istry, &mut server);
2019 assert!(
2020 server.prompt_router.map.contains_key("gated_skill"),
2021 "skill with satisfied predicate must register"
2022 );
2023 }
2024
2025 #[test]
2026 fn serve_prompts_evaluates_extension_enabled_from_manifest() {
2027 use crate::server::skills::Registry as SkillsBuilder;
2031 let dir = write_gated_project_skill(" extension_enabled: csv_http_server");
2032 let yaml = dir.path().join("test_mcp.yaml");
2033 let registry = SkillsBuilder::new()
2034 .auto_detect_project_layer(&yaml)
2035 .finalise()
2036 .unwrap();
2037
2038 let mut server = McpServer::new(ServerOptions::default());
2040 super::serve_prompts(®istry, &mut server);
2041 assert!(!server.prompt_router.map.contains_key("gated_skill"));
2042
2043 let mut extensions = serde_json::Map::new();
2045 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
2046 let opts = ServerOptions {
2047 extensions,
2048 ..ServerOptions::default()
2049 };
2050 let mut server = McpServer::new(opts);
2051 super::serve_prompts(®istry, &mut server);
2052 assert!(server.prompt_router.map.contains_key("gated_skill"));
2053 }
2054}