1#![allow(dead_code)]
33
34use std::sync::{Arc, Mutex};
35
36use rmcp::handler::server::router::prompt::{PromptRoute, PromptRouter};
37use rmcp::handler::server::router::tool::ToolRouter;
38use rmcp::handler::server::wrapper::Parameters;
39use rmcp::model::*;
40use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
41use serde::{Deserialize, Serialize};
42
43use crate::server::manifest::Manifest;
44use crate::server::skills::ResolvedRegistry;
45use crate::server::source::{
46 self, resolve_dir_under_roots, GrepOpts, ListOpts, ReadOpts, SourceRootsProvider,
47};
48
49pub type RepoProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
53
54pub struct ResultCtx {
59 pub source_roots: Vec<String>,
61 pub active_repo: Option<String>,
64}
65
66pub type ResultPostprocessHook =
80 Arc<dyn Fn(&str, &serde_json::Value, &str, &ResultCtx) -> Option<String> + Send + Sync>;
81
82fn append_footer(body: String, footer: Option<String>) -> String {
86 match footer {
87 Some(f) if !f.is_empty() => format!("{body}\n\n{f}"),
88 _ => body,
89 }
90}
91
92#[derive(Clone, Default)]
94pub struct ServerOptions {
95 pub name: Option<String>,
97 pub instructions: Option<String>,
99 pub source_roots: Option<SourceRootsProvider>,
102 pub default_repo: Option<RepoProvider>,
105 pub workspace: Option<crate::server::workspace::Workspace>,
107 pub builtins: crate::server::manifest::BuiltinsConfig,
112 pub extensions: serde_json::Map<String, serde_json::Value>,
117 pub result_postprocess: Option<ResultPostprocessHook>,
121}
122
123impl std::fmt::Debug for ServerOptions {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 f.debug_struct("ServerOptions")
126 .field("name", &self.name)
127 .field("instructions", &self.instructions)
128 .field(
129 "source_roots",
130 &self.source_roots.as_ref().map(|_| "<provider>"),
131 )
132 .field(
133 "default_repo",
134 &self.default_repo.as_ref().map(|_| "<provider>"),
135 )
136 .finish()
137 }
138}
139
140impl ServerOptions {
141 pub fn from_manifest(manifest: Option<&Manifest>, fallback_name: &str) -> Self {
142 Self {
143 name: manifest
144 .and_then(|m| m.name.clone())
145 .or_else(|| Some(fallback_name.to_string())),
146 instructions: manifest.and_then(|m| m.instructions.clone()),
147 source_roots: None,
148 default_repo: None,
149 workspace: None,
150 builtins: manifest.map(|m| m.builtins.clone()).unwrap_or_default(),
151 extensions: manifest.map(|m| m.extensions.clone()).unwrap_or_default(),
152 result_postprocess: None,
153 }
154 }
155
156 pub fn with_static_source_roots(mut self, roots: Vec<String>) -> Self {
157 let captured = Arc::new(roots);
158 self.source_roots = Some(Arc::new(move || captured.as_ref().clone()));
159 self
160 }
161
162 pub fn with_dynamic_source_roots(mut self, provider: SourceRootsProvider) -> Self {
163 self.source_roots = Some(provider);
164 self
165 }
166
167 pub fn with_static_repo(mut self, repo: String) -> Self {
168 self.default_repo = Some(Arc::new(move || Some(repo.clone())));
169 self
170 }
171
172 pub fn with_dynamic_repo(mut self, provider: RepoProvider) -> Self {
173 self.default_repo = Some(provider);
174 self
175 }
176
177 pub fn with_workspace(mut self, ws: crate::server::workspace::Workspace) -> Self {
182 let ws_for_roots = ws.clone();
183 let ws_for_repo = ws.clone();
184 self.workspace = Some(ws);
185 self.source_roots = Some(Arc::new(move || {
186 ws_for_roots
187 .active_repo_path()
188 .map(|p| vec![p.to_string_lossy().into_owned()])
189 .unwrap_or_default()
190 }));
191 self.default_repo = Some(Arc::new(move || ws_for_repo.default_github_repo()));
192 self
193 }
194
195 pub fn with_result_postprocess(mut self, hook: ResultPostprocessHook) -> Self {
200 self.result_postprocess = Some(hook);
201 self
202 }
203}
204
205#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
206pub struct PingArgs {
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub message: Option<String>,
210}
211
212#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
213pub struct ReadSourceArgs {
214 pub file_path: String,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub start_line: Option<usize>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub end_line: Option<usize>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub grep: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub grep_context: Option<usize>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub max_matches: Option<usize>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub max_chars: Option<usize>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub rev: Option<String>,
241}
242
243#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
244pub struct GrepArgs {
245 pub pattern: String,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub glob: Option<String>,
250 #[serde(default)]
252 pub context: usize,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub max_results: Option<usize>,
256 #[serde(default)]
258 pub case_insensitive: bool,
259}
260
261#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
262pub struct SetRootDirArgs {
263 pub path: String,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub revs: Option<crate::server::workspace::RevsRequest>,
274}
275
276#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
277pub struct RepoManagementArgs {
278 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub name: Option<String>,
281 #[serde(default)]
283 pub delete: bool,
284 #[serde(default)]
286 pub update: bool,
287 #[serde(default)]
291 pub force_rebuild: bool,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub revs: Option<crate::server::workspace::RevsRequest>,
301}
302
303#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
304pub struct GithubIssuesArgs {
305 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub number: Option<u64>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub repo_name: Option<String>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub query: Option<String>,
314 #[serde(default = "default_kind")]
316 pub kind: String,
317 #[serde(default = "default_state")]
319 pub state: String,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub sort: Option<String>,
323 #[serde(default = "default_limit")]
325 pub limit: usize,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub labels: Option<String>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub element_id: Option<String>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub lines: Option<String>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub grep: Option<String>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub context: Option<usize>,
348 #[serde(default)]
351 pub refresh: bool,
352}
353
354fn default_kind() -> String {
355 "all".to_string()
356}
357fn default_state() -> String {
358 "open".to_string()
359}
360fn default_limit() -> usize {
361 20
362}
363
364impl Default for GithubIssuesArgs {
365 fn default() -> Self {
366 Self {
367 number: None,
368 repo_name: None,
369 query: None,
370 kind: default_kind(),
371 state: default_state(),
372 sort: None,
373 limit: default_limit(),
374 labels: None,
375 element_id: None,
376 lines: None,
377 grep: None,
378 context: None,
379 refresh: false,
380 }
381 }
382}
383
384#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
385pub struct GithubApiArgs {
386 pub path: String,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub repo_name: Option<String>,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub truncate_at: Option<usize>,
399}
400
401#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
402pub struct ListSourceArgs {
403 #[serde(default = "default_path")]
405 pub path: String,
406 #[serde(default = "default_depth")]
408 pub depth: usize,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub glob: Option<String>,
412 #[serde(default)]
414 pub dirs_only: bool,
415}
416
417fn default_path() -> String {
418 ".".to_string()
419}
420fn default_depth() -> usize {
421 1
422}
423
424#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
425pub struct ScreenStargazersArgs {
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub repo: Option<String>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub users: Option<String>,
433 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub preset: Option<String>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub rank_by: Option<String>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub top: Option<usize>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub min_keywords: Option<usize>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub active_since: Option<String>,
450 #[serde(default)]
452 pub adopters_only: bool,
453 #[serde(default)]
455 pub stack_only: bool,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub keywords: Option<String>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub stack: Option<String>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub max_stargazers: Option<usize>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
474 pub element_id: Option<String>,
475 #[serde(default)]
477 pub refresh: bool,
478}
479
480#[derive(Clone)]
485pub struct McpServer {
486 options: ServerOptions,
487 tool_router: ToolRouter<McpServer>,
488 prompt_router: PromptRouter<McpServer>,
493}
494
495#[tool_router]
496impl McpServer {
497 pub fn new(options: ServerOptions) -> Self {
498 let mut server = Self {
499 options,
500 tool_router: Self::tool_router(),
501 prompt_router: PromptRouter::new(),
502 };
503 server.register_github_tools_if_authorized();
504 server.register_local_workspace_tools();
505 server.gate_workspace_tools();
506 server
507 }
508
509 fn gate_workspace_tools(&mut self) {
517 if self.options.workspace.is_none() {
518 self.tool_router.remove_route("repo_management");
519 }
520 }
521
522 fn register_local_workspace_tools(&mut self) {
526 let Some(ws) = self.options.workspace.clone() else {
527 return;
528 };
529 if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
530 return;
531 }
532 self.register_typed_tool::<SetRootDirArgs, _>(
533 "set_root_dir",
534 "Swap the active source root (local-workspace mode only). Pass `path` \
535 to a directory; the framework canonicalises it, rebinds the source \
536 tools (`read_source`, `grep`, `list_source`), and fires the post-\
537 activate hook so any downstream graph rebuilds against the new root. \
538 Pass `revs` (an integer N, or a list of git revspecs) to load multiple \
539 revisions of the root into one graph — N loads the newest N stable \
540 release tags of the dominant tag family plus HEAD (prereleases and \
541 unrelated tag families skipped); requires the root to be a git repo. \
542 Inventory persists across swaps; SHA-gating skips rebuilds when \
543 the same root is re-bound with no content changes.",
544 move |args: SetRootDirArgs| {
545 let p = std::path::PathBuf::from(&args.path);
546 ws.set_root_dir(&p, args.revs.as_ref())
547 },
548 );
549 }
550
551 fn register_github_tools_if_authorized(&mut self) {
557 if !crate::github::has_git_token() {
558 tracing::info!(
559 "GITHUB_TOKEN not set — github_issues / github_api tools hidden from the agent. \
560 Set the env var and restart to enable them."
561 );
562 return;
563 }
564 let default_repo = self.options.default_repo.clone();
565 let repo_provider = default_repo.clone();
566 let cache: Arc<Mutex<crate::cache::ElementCache>> =
572 Arc::new(Mutex::new(crate::cache::ElementCache::new()));
573 let cache_for_issues = cache.clone();
574 self.register_typed_tool::<GithubIssuesArgs, _>(
575 "github_issues",
576 "Search, list, or fetch GitHub issues / pull requests / Discussions. \
577 Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
578 for SEARCH (across issues+PRs and Discussions); neither for LIST. \
579 `kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
580 `state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
581 result count (default 20). `labels` is a comma-separated string. \
582 `repo_name=\"org/repo\"` overrides the active repo for one call. \
583 FETCH responses collapse big code blocks / patches / comments into \
584 `cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
585 `element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
586 element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
587 `refresh=true` bypasses the cache for re-fetch.",
588 move |args: GithubIssuesArgs| {
589 let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
590 Ok(r) => r,
591 Err(msg) => return msg,
592 };
593 if let Some(number) = args.number {
599 let context = args.context.unwrap_or(3);
600 let mut guard = cache_for_issues.lock().unwrap();
601 return guard.fetch_issue(
602 &repo,
603 number,
604 args.element_id.as_deref(),
605 args.lines.as_deref(),
606 args.grep.as_deref(),
607 context,
608 args.refresh,
609 );
610 }
611 if args.element_id.is_some() {
612 return "element_id requires `number=N` (the issue/PR being drilled into)."
613 .to_string();
614 }
615 crate::github::github_issues_rust(
617 Some(&repo),
618 args.number,
619 args.query.as_deref(),
620 &args.kind,
621 &args.state,
622 args.sort.as_deref(),
623 args.limit,
624 args.labels.as_deref(),
625 )
626 },
627 );
628 let repo_provider = default_repo.clone();
629 let repo_for_screen = default_repo;
630 self.register_typed_tool::<GithubApiArgs, _>(
631 "github_api",
632 "Read-only GET against the GitHub REST API. `path` may be a \
633 repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
634 \"branches\", \"compare/main...feature\") which is auto-prefixed \
635 with /repos/<repo_name>/, or a top-level resource (\"search/issues?q=...\", \
636 \"users/octocat\", \"repos/owner/name\") which passes through. A \
637 leading slash is optional and accepted on either form. Returns \
638 JSON, truncated at 80 KB by default.",
639 move |args: GithubApiArgs| match resolve_repo_from(
640 repo_provider.as_ref(),
641 args.repo_name.clone(),
642 ) {
643 Ok(repo) => {
644 let truncate_at = args.truncate_at.unwrap_or(80_000);
645 crate::github::git_api_internal(&repo, &args.path, truncate_at)
646 }
647 Err(msg) => msg,
648 },
649 );
650
651 if self.options.builtins.screen_stargazers {
659 let screen_store: Arc<Mutex<crate::screen::ScreenStore>> =
660 Arc::new(Mutex::new(crate::screen::ScreenStore::new()));
661 self.register_typed_tool::<ScreenStargazersArgs, _>(
662 "screen_stargazers",
663 "Screen the people around a GitHub project to find relevant developers, \
664 notable/legendary devs, architectural peers, and actual users — cheaply. \
665 Seed on a repo (`repo=\"owner/repo\"` → screens its stargazers) OR an \
666 explicit user list (`users=\"alice,bob\"` → screens them directly). With \
667 just a repo it auto-derives relevance keywords + tech stack from the repo \
668 itself, bulk-fetches each person's public repo portfolio over plain REST \
669 (~1 request per person, no GraphQL, no READMEs), classifies them, and \
670 enriches a bounded shortlist with follower counts, dependency-adoption, \
671 stack co-location, and contributions. Every person gets a normalized \
672 0–100 score vector on four axes — relatedness, popularity, effort, \
673 recency. RANK/FILTER: pass a `preset` (\"outreach\"=relevant+active by \
674 reach, \"peers\"=your stack by effort, \"legends\"=biggest reach any \
675 domain, \"intel\"=on-domain by popularity, \"adopters\"=actual users), or \
676 `rank_by`=relatedness|popularity|effort|recency with filters \
677 (`min_keywords`, `active_since`, `adopters_only`, `stack_only`) and \
678 `top`=N (rank-then-take-N, default 10) for a focused filter→rank→take \
679 view; with none, the full multi-lens browse: \
680 `✅ ADOPTERS` (stargazers whose repos actually declare your package as a \
681 dependency — real users, not just watchers), `★ MOST RELEVANT` \
682 (relatedness — repos matching your topic keywords, with follower counts \
683 and external contributions), `🏆 NOTABLE` (popularity/reach lens — your \
684 highest-traction stargazers, flagged `LEGEND` for big audiences/projects), \
685 `✦ QUALITY` (best-kept maintained projects), `⚙ STACK MATCH` (architectural \
686 peers who build in your stack — co-location-confirmed where possible), and \
687 a cohort inventory. Override the auto-config with `keywords=\"graph,rag,agent\"` \
688 (single words — \"knowledge,graph\" not \"knowledge-graph\") and \
689 `stack=\"Rust,Python\"`; re-calling with new values re-ranks the cached \
690 fetch for free. Treat description-based leads as candidates to verify by \
691 drilling. DRILL via `element_id`: `\"cohort:<key>\"` (established / single / \
692 prolific / casual / dormant / consumers — the overview lists each key), \
693 `\"user:<login>\"` (portfolio), `\"user:<login>/repo:<name>\"` (repo profile), \
694 or `\"user:<login>/repo:<name>/readme\"` (README gist — the only drill that \
695 costs a request). `max_stargazers` samples the most-recent N (the overview \
696 reports if results are partial); `refresh=true` re-fetches.",
697 move |args: ScreenStargazersArgs| {
698 use crate::screen::{self, Filters, RankBy, Seed, Selection};
699 let split_csv = |s: Option<String>| -> Vec<String> {
700 s.map(|v| {
701 v.split(',')
702 .map(|t| t.trim().to_string())
703 .filter(|t| !t.is_empty())
704 .collect()
705 })
706 .unwrap_or_default()
707 };
708 let seed = if let Some(u) = &args.users {
710 Seed::Users(split_csv(Some(u.clone())))
711 } else {
712 let repo =
713 match resolve_repo_from(repo_for_screen.as_ref(), args.repo.clone()) {
714 Ok(r) => r,
715 Err(msg) => return msg,
716 };
717 if let Some(err) = crate::git_refs::validate_repo(&repo) {
718 return err;
719 }
720 Seed::Repo(repo)
721 };
722 let cfg = screen::ScreenConfig {
723 max_stargazers: args.max_stargazers,
724 max_repos_per_user: 100,
725 relevance_keywords: split_csv(args.keywords)
726 .into_iter()
727 .map(|k| k.to_lowercase())
728 .collect(),
729 stack_languages: split_csv(args.stack),
730 };
731 let top = args.top.unwrap_or(10);
733 let filters = Filters {
734 min_keywords: args.min_keywords,
735 active_since: args.active_since.clone(),
736 adopters_only: args.adopters_only,
737 stack_only: args.stack_only,
738 ..Default::default()
739 };
740 let filters_active = filters.min_keywords.is_some()
741 || filters.active_since.is_some()
742 || filters.adopters_only
743 || filters.stack_only;
744 let selection: Option<Selection> = if let Some(name) = &args.preset {
745 screen::preset(name, top)
746 } else if args.rank_by.is_some() || filters_active {
747 Some(Selection {
748 filters,
749 rank: args
750 .rank_by
751 .as_deref()
752 .and_then(RankBy::parse)
753 .unwrap_or(RankBy::Relatedness),
754 label: "SELECTION".into(),
755 take: top,
756 })
757 } else {
758 None
759 };
760 screen::screen_dispatch(
761 &screen_store,
762 &seed,
763 &cfg,
764 selection.as_ref(),
765 args.element_id.as_deref(),
766 args.refresh,
767 )
768 },
769 );
770 }
771 }
772
773 pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
780 &self.options.builtins
781 }
782
783 pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
789 &mut self.tool_router
790 }
791
792 pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
797 &mut self.prompt_router
798 }
799
800 pub fn register_typed_tool<T, F>(
815 &mut self,
816 name: &'static str,
817 description: &'static str,
818 handler: F,
819 ) where
820 T: for<'de> serde::Deserialize<'de>
821 + schemars::JsonSchema
822 + Default
823 + Send
824 + Sync
825 + 'static,
826 F: Fn(T) -> String + Send + Sync + 'static,
827 {
828 use std::pin::Pin;
829 type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
830
831 let schema_obj = serde_json::to_value(schemars::schema_for!(T))
832 .ok()
833 .and_then(|v| v.as_object().cloned())
834 .unwrap_or_default();
835 let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
836 let handler = std::sync::Arc::new(handler);
837 let tool_name = name;
842 let postprocess = self.options.result_postprocess.clone();
843 let source_roots = self.options.source_roots.clone();
844 let workspace = self.options.workspace.clone();
845
846 self.tool_router
847 .add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
848 attr,
849 move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
850 -> DynFut<'_, Result<rmcp::model::CallToolResponse, rmcp::ErrorData>> {
851 let handler = handler.clone();
852 let arguments = ctx.arguments.clone();
853 let postprocess = postprocess.clone();
854 let source_roots = source_roots.clone();
855 let workspace = workspace.clone();
856 Box::pin(async move {
857 let args_json = match &arguments {
860 Some(map) => serde_json::Value::Object(map.clone()),
861 None => serde_json::Value::Null,
862 };
863 let args: T = match arguments {
864 Some(map) => {
865 match serde_json::from_value(serde_json::Value::Object(map)) {
866 Ok(a) => a,
867 Err(e) => {
868 return Ok(rmcp::model::CallToolResult::success(vec![
869 rmcp::model::ContentBlock::text(format!(
870 "invalid arguments: {e}"
871 )),
872 ])
873 .into());
874 }
875 }
876 }
877 None => T::default(),
878 };
879 let body = handler(args);
880 let body = match &postprocess {
881 Some(hook) => {
882 let ctx = ResultCtx {
883 source_roots: source_roots
884 .as_ref()
885 .map(|p| p())
886 .unwrap_or_default(),
887 active_repo: workspace
888 .as_ref()
889 .and_then(|w| w.active_repo_name()),
890 };
891 let footer = hook(tool_name, &args_json, &body, &ctx);
892 append_footer(body, footer)
893 }
894 None => body,
895 };
896 Ok(rmcp::model::CallToolResult::success(vec![
897 rmcp::model::ContentBlock::text(body),
898 ])
899 .into())
900 })
901 },
902 ));
903 }
904
905 fn current_source_roots(&self) -> Vec<String> {
906 match &self.options.source_roots {
907 Some(provider) => provider(),
908 None => Vec::new(),
909 }
910 }
911
912 fn finish(&self, tool: &str, args: &serde_json::Value, body: String) -> String {
919 let Some(hook) = &self.options.result_postprocess else {
920 return body;
921 };
922 let ctx = ResultCtx {
923 source_roots: self.current_source_roots(),
924 active_repo: self
925 .options
926 .workspace
927 .as_ref()
928 .and_then(|w| w.active_repo_name()),
929 };
930 let footer = hook(tool, args, &body, &ctx);
931 append_footer(body, footer)
932 }
933
934 #[allow(dead_code)]
939 fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
940 resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
941 }
942
943 #[tool(
944 description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
945 Use to confirm the server framework is wired correctly before \
946 relying on graph- or source-aware tools."
947 )]
948 async fn ping(
949 &self,
950 Parameters(args): Parameters<PingArgs>,
951 ) -> Result<CallToolResult, McpError> {
952 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
953 let body = args.message.unwrap_or_else(|| "pong".to_string());
954 let body = self.finish("ping", &args_json, body);
955 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
956 }
957
958 #[tool(description = "Read a file from the configured source root(s). Pass \
959 `start_line`/`end_line` to slice, `grep` to filter to matching \
960 lines, `max_chars` to cap output. Pass `rev` (a tag, branch, or \
961 commit SHA) to read the file's content at that git revision via \
962 `git show` instead of the working tree — useful for comparing a \
963 file across releases (requires a git repo source root). Path \
964 traversal attempts are rejected. Available only when source roots \
965 are configured.")]
966 async fn read_source(
967 &self,
968 Parameters(args): Parameters<ReadSourceArgs>,
969 ) -> Result<CallToolResult, McpError> {
970 let roots = self.current_source_roots();
971 if roots.is_empty() {
972 return Ok(CallToolResult::success(vec![ContentBlock::text(
973 "Cannot read source: no active source root. Configure source_root in your manifest \
974 or activate one (e.g. via repo_management in workspace mode).",
975 )]));
976 }
977 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
978 let opts = ReadOpts {
979 start_line: args.start_line,
980 end_line: args.end_line,
981 grep: args.grep,
982 grep_context: args.grep_context,
983 max_matches: args.max_matches,
984 max_chars: args.max_chars,
985 rev: args.rev,
986 };
987 let body = source::read_source(&args.file_path, &roots, &opts);
988 let body = self.finish("read_source", &args_json, body);
989 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
990 }
991
992 #[tool(
993 description = "Search source files using ripgrep. `pattern` is a regex (Rust \
994 syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
995 N surrounding lines per match. Set `case_insensitive=true` for \
996 case-insensitive matching. `max_results` caps total matches \
997 (default 50)."
998 )]
999 async fn grep(
1000 &self,
1001 Parameters(args): Parameters<GrepArgs>,
1002 ) -> Result<CallToolResult, McpError> {
1003 let roots = self.current_source_roots();
1004 if roots.is_empty() {
1005 return Ok(CallToolResult::success(vec![ContentBlock::text(
1006 "Cannot grep: no active source root. Configure source_root in your manifest \
1007 or activate one (e.g. via repo_management in workspace mode).",
1008 )]));
1009 }
1010 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1011 let opts = GrepOpts {
1012 glob: args.glob,
1013 context: args.context,
1014 max_results: Some(args.max_results.unwrap_or(50)),
1015 case_insensitive: args.case_insensitive,
1016 };
1017 let body = source::grep(&roots, &args.pattern, &opts);
1018 let body = self.finish("grep", &args_json, body);
1019 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1020 }
1021
1022 #[tool(
1023 description = "List directory contents under the configured source root. `path` \
1024 is resolved against the first source root (\".\" lists the root \
1025 itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
1026 `glob` filters entry names. `dirs_only=true` shows only \
1027 directories."
1028 )]
1029 async fn list_source(
1030 &self,
1031 Parameters(args): Parameters<ListSourceArgs>,
1032 ) -> Result<CallToolResult, McpError> {
1033 let roots = self.current_source_roots();
1034 if roots.is_empty() {
1035 return Ok(CallToolResult::success(vec![ContentBlock::text(
1036 "Cannot list source: no active source root. Configure source_root in your \
1037 manifest or activate one (e.g. via repo_management in workspace mode).",
1038 )]));
1039 }
1040 let primary = std::path::PathBuf::from(&roots[0]);
1041 let target = match resolve_dir_under_roots(&args.path, &roots) {
1042 Some(p) => p,
1043 None => {
1044 return Ok(CallToolResult::success(vec![ContentBlock::text(format!(
1045 "Error: path '{}' resolves outside the configured source roots.",
1046 args.path
1047 ))]));
1048 }
1049 };
1050 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1051 let opts = ListOpts {
1052 depth: args.depth,
1053 glob: args.glob,
1054 dirs_only: args.dirs_only,
1055 };
1056 let body = source::list_source(&target, &primary, &opts);
1057 let body = self.finish("list_source", &args_json, body);
1058 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1059 }
1060
1061 #[tool(
1062 description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
1063 clone (if missing) and activate it as the source root for \
1064 read_source / grep / list_source. Pass `delete=true` to remove a \
1065 repo. Pass `update=true` to fetch upstream changes for the active \
1066 repo (rebuild auto-skipped when HEAD hasn't moved since the last \
1067 build; set `force_rebuild=true` to bypass). Pass `revs` (an \
1068 integer N, or a list of git revspecs) to load multiple revisions \
1069 of the repo into one graph — N loads the newest N stable release \
1070 tags of the dominant tag family plus HEAD (prereleases and \
1071 unrelated tag families skipped); a revs request always rebuilds. \
1072 Call with no \
1073 arguments to list all known repos with their last-access counts. \
1074 Idle repos auto-sweep on each call (default 7 days, configurable \
1075 via --stale-after-days)."
1076 )]
1077 async fn repo_management(
1078 &self,
1079 Parameters(args): Parameters<RepoManagementArgs>,
1080 ) -> Result<CallToolResult, McpError> {
1081 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1082 let body = match &self.options.workspace {
1083 Some(ws) => ws.repo_management(
1084 args.name.as_deref(),
1085 args.delete,
1086 args.update,
1087 args.force_rebuild,
1088 args.revs.as_ref(),
1089 ),
1090 None => "repo_management requires --workspace mode.".to_string(),
1091 };
1092 let body = self.finish("repo_management", &args_json, body);
1093 Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
1094 }
1095}
1096
1097fn resolve_repo_from(
1105 default_repo: Option<&RepoProvider>,
1106 override_repo: Option<String>,
1107) -> Result<String, String> {
1108 if let Some(r) = override_repo {
1109 if let Some(err) = crate::git_refs::validate_repo(&r) {
1110 return Err(err);
1111 }
1112 return Ok(r);
1113 }
1114 if let Some(provider) = default_repo {
1115 if let Some(r) = provider() {
1116 if let Some(err) = crate::git_refs::validate_repo(&r) {
1117 return Err(err);
1118 }
1119 return Ok(r);
1120 }
1121 }
1122 if let Some(detected) = crate::github::detect_git_repo(".") {
1123 if crate::git_refs::validate_repo(&detected).is_none() {
1124 return Ok(detected);
1125 }
1126 }
1127 Err(
1128 "No active repository. Pass `repo_name='org/repo'`, configure a default in the \
1129 server, or run from a directory whose git remote points at github.com."
1130 .to_string(),
1131 )
1132}
1133
1134pub fn serve_prompts(registry: &ResolvedRegistry, server: &mut McpServer) {
1148 use std::borrow::Cow;
1149 use std::collections::HashSet;
1150
1151 let registered_tools: HashSet<String> = server
1156 .tool_router
1157 .list_all()
1158 .iter()
1159 .map(|t| t.name.to_string())
1160 .collect();
1161 let extensions = server.options.extensions.clone();
1162
1163 struct InjectSkill {
1169 name: String,
1170 description: String,
1171 body: String,
1172 references_tools: Vec<String>,
1173 }
1174 let mut auto_inject: Vec<InjectSkill> = Vec::new();
1175
1176 for name in registry.skill_names() {
1177 let Some(skill) = registry.get(&name) else {
1178 continue;
1179 };
1180
1181 let activation = registry.activation_for(skill, ®istered_tools, &extensions);
1185 if !activation.active {
1186 let failed_clauses: Vec<&str> = activation
1187 .clauses
1188 .iter()
1189 .filter(|(_, outcome)| {
1190 *outcome != crate::server::skills::PredicateOutcome::Satisfied
1191 })
1192 .map(|(clause, _)| clause.as_str())
1193 .collect();
1194 tracing::info!(
1195 skill = %name,
1196 suppressed_by = ?failed_clauses,
1197 "skill suppressed by applies_when predicates"
1198 );
1199 continue;
1200 }
1201
1202 let prompt = Prompt::new(
1203 skill.name().to_string(),
1204 Some(skill.description().to_string()),
1205 None,
1206 );
1207 let body = skill.body.clone();
1208 let route = PromptRoute::new_dyn(prompt, move |_ctx| {
1209 let body = body.clone();
1210 Box::pin(async move {
1211 Ok(
1212 GetPromptResult::new(vec![PromptMessage::new_text(Role::Assistant, body)])
1213 .into(),
1214 )
1215 })
1216 });
1217 server.prompt_router.add_route(route);
1218
1219 if skill.frontmatter.auto_inject_hint {
1220 auto_inject.push(InjectSkill {
1221 name: skill.name().to_string(),
1222 description: skill.description().to_string(),
1223 body: skill.body.clone(),
1224 references_tools: skill.frontmatter.references_tools.clone(),
1225 });
1226 }
1227 }
1228
1229 for inj in &auto_inject {
1265 let mut targets: Vec<&str> = Vec::new();
1268 let mut seen: HashSet<&str> = HashSet::new();
1269 for tool in std::iter::once(inj.name.as_str())
1270 .chain(inj.references_tools.iter().map(String::as_str))
1271 {
1272 if seen.insert(tool) {
1273 targets.push(tool);
1274 }
1275 }
1276
1277 let marker = format!("<!-- mcp-skill:{} -->", inj.name);
1280 let mut block = format!("\n\n{marker}");
1281 let description = inj.description.trim();
1282 if !description.is_empty() {
1283 block.push_str("\n\n## When to use\n\n");
1284 block.push_str(description);
1285 }
1286 block.push_str("\n\n## Methodology\n\n");
1287 block.push_str(inj.body.trim());
1288
1289 for tool in targets {
1290 let key = Cow::<'static, str>::Owned(tool.to_string());
1291 let Some(route) = server.tool_router.map.get_mut(&key) else {
1292 continue;
1293 };
1294 if route
1297 .attr
1298 .description
1299 .as_deref()
1300 .is_some_and(|d| d.contains(&marker))
1301 {
1302 continue;
1303 }
1304 let new_desc = match route.attr.description.take() {
1305 Some(existing) => format!("{existing}{block}"),
1306 None => block.trim_start().to_string(),
1307 };
1308 route.attr.description = Some(Cow::Owned(new_desc));
1309 }
1310 }
1311}
1312
1313#[tool_handler(router = self.tool_router)]
1314impl ServerHandler for McpServer {
1315 fn get_info(&self) -> ServerInfo {
1316 let name = self
1317 .options
1318 .name
1319 .clone()
1320 .unwrap_or_else(|| "MCP Server".to_string());
1321 let mut caps = ServerCapabilities::builder().enable_tools().build();
1328 if !self.prompt_router.map.is_empty() {
1329 caps.prompts = Some(PromptsCapability::default());
1330 }
1331 let mut info = ServerInfo::new(caps)
1332 .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
1333 .with_protocol_version(ProtocolVersion::V_2024_11_05);
1334 if let Some(text) = &self.options.instructions {
1335 info = info.with_instructions(text.clone());
1336 }
1337 info
1338 }
1339
1340 async fn on_initialized(&self, context: rmcp::service::NotificationContext<rmcp::RoleServer>) {
1355 tracing::info!("client initialized");
1358 crate::server::roots::on_client_initialized(&self.options, &context.peer).await;
1359 }
1360
1361 async fn on_roots_list_changed(
1364 &self,
1365 context: rmcp::service::NotificationContext<rmcp::RoleServer>,
1366 ) {
1367 crate::server::roots::on_client_roots_changed(&self.options, &context.peer).await;
1368 }
1369
1370 async fn list_prompts(
1371 &self,
1372 _request: Option<PaginatedRequestParams>,
1373 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
1374 ) -> Result<ListPromptsResult, McpError> {
1375 Ok(ListPromptsResult {
1376 prompts: self.prompt_router.list_all(),
1377 ..Default::default()
1378 })
1379 }
1380
1381 async fn get_prompt(
1382 &self,
1383 request: GetPromptRequestParams,
1384 context: rmcp::service::RequestContext<rmcp::RoleServer>,
1385 ) -> Result<GetPromptResponse, McpError> {
1386 let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
1387 self,
1388 request.name,
1389 request.arguments,
1390 context,
1391 );
1392 self.prompt_router.get_prompt(prompt_context).await
1393 }
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398 use super::*;
1399
1400 #[test]
1401 fn options_from_manifest_uses_name_when_set() {
1402 let opts = ServerOptions::from_manifest(None, "Fallback");
1403 assert_eq!(opts.name.as_deref(), Some("Fallback"));
1404 }
1405
1406 #[test]
1407 fn builtins_exposed_via_server() {
1408 use crate::server::manifest::{BuiltinsConfig, TempCleanup};
1409 let opts = ServerOptions {
1410 builtins: BuiltinsConfig {
1411 save_graph: true,
1412 temp_cleanup: TempCleanup::OnOverview,
1413 ..Default::default()
1414 },
1415 ..ServerOptions::default()
1416 };
1417 let server = McpServer::new(opts);
1418 assert!(server.builtins().save_graph);
1419 assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
1420 }
1421
1422 #[test]
1423 fn server_constructs() {
1424 let _server = McpServer::new(ServerOptions::default());
1425 }
1426
1427 #[test]
1428 fn static_source_roots_provider() {
1429 let opts = ServerOptions::default()
1430 .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
1431 let server = McpServer::new(opts);
1432 assert_eq!(
1433 server.current_source_roots(),
1434 vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
1435 );
1436 }
1437
1438 #[test]
1439 fn no_provider_returns_empty_roots() {
1440 let server = McpServer::new(ServerOptions::default());
1441 assert!(server.current_source_roots().is_empty());
1442 }
1443
1444 #[test]
1445 fn repo_management_gated_to_workspace_mode() {
1446 let server = McpServer::new(ServerOptions::default());
1449 let tools = server.tool_router.list_all();
1450 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1451 assert!(
1452 !names.contains(&"repo_management"),
1453 "repo_management should be gated out without a workspace; tools were {names:?}"
1454 );
1455 }
1456
1457 #[test]
1458 fn repo_management_present_when_workspace_bound() {
1459 use crate::server::workspace::Workspace;
1462 let dir = tempfile::tempdir().unwrap();
1463 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1464 let opts = ServerOptions::default().with_workspace(ws);
1465 let server = McpServer::new(opts);
1466 let tools = server.tool_router.list_all();
1467 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1468 assert!(
1469 names.contains(&"repo_management"),
1470 "repo_management should be registered with a workspace; tools were {names:?}"
1471 );
1472 }
1473
1474 #[test]
1475 fn result_postprocess_appends_footer_and_sees_ctx() {
1476 use std::sync::Mutex;
1477 type Seen = Option<(String, serde_json::Value, String, Vec<String>)>;
1479 let seen: Arc<Mutex<Seen>> = Arc::new(Mutex::new(None));
1480 let seen_c = seen.clone();
1481 let hook: ResultPostprocessHook = Arc::new(move |tool, args, body, ctx| {
1482 *seen_c.lock().unwrap() = Some((
1483 tool.to_string(),
1484 args.clone(),
1485 body.to_string(),
1486 ctx.source_roots.clone(),
1487 ));
1488 if tool == "grep" {
1490 Some("↳ prefer cypher_query".to_string())
1491 } else {
1492 None
1493 }
1494 });
1495 let opts = ServerOptions::default()
1496 .with_static_source_roots(vec!["/src".to_string()])
1497 .with_result_postprocess(hook);
1498 let server = McpServer::new(opts);
1499
1500 let args = serde_json::json!({ "pattern": "^fn " });
1501 let out = server.finish("grep", &args, "match line".to_string());
1502 assert_eq!(out, "match line\n\n↳ prefer cypher_query");
1503
1504 let rec = seen.lock().unwrap().clone().unwrap();
1505 assert_eq!(rec.0, "grep");
1506 assert_eq!(rec.1, args);
1507 assert_eq!(rec.2, "match line");
1508 assert_eq!(rec.3, vec!["/src".to_string()]);
1509
1510 let out2 = server.finish("read_source", &args, "file body".to_string());
1512 assert_eq!(out2, "file body");
1513 }
1514
1515 #[test]
1516 fn no_result_postprocess_leaves_body_unchanged() {
1517 let server = McpServer::new(ServerOptions::default());
1518 let out = server.finish("grep", &serde_json::Value::Null, "x".to_string());
1519 assert_eq!(out, "x");
1520 }
1521
1522 #[test]
1523 fn append_footer_ignores_empty_footers() {
1524 assert_eq!(append_footer("a".to_string(), None), "a");
1525 assert_eq!(append_footer("a".to_string(), Some(String::new())), "a");
1526 assert_eq!(
1527 append_footer("a".to_string(), Some("b".to_string())),
1528 "a\n\nb"
1529 );
1530 }
1531
1532 #[test]
1533 fn dynamic_provider_swaps_at_call_time() {
1534 use std::sync::Mutex;
1535 let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
1536 let s2 = state.clone();
1537 let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
1538 let opts = ServerOptions::default().with_dynamic_source_roots(provider);
1539 let server = McpServer::new(opts);
1540 assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
1541 *state.lock().unwrap() = vec!["/swapped".to_string()];
1542 assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
1543 }
1544
1545 fn build_test_registry(
1548 skills: &[(&str, &str, &str, bool)],
1549 ) -> crate::server::skills::ResolvedRegistry {
1550 use crate::server::skills::Registry;
1551 let dir = tempfile::tempdir().unwrap();
1552 let yaml_path = dir.path().join("manifest.yaml");
1553 let skills_dir = dir.path().join("manifest.skills");
1554 std::fs::create_dir_all(&skills_dir).unwrap();
1555 for (name, description, body, auto_inject) in skills {
1556 let auto = if *auto_inject { "true" } else { "false" };
1557 let content = format!(
1558 "---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
1559 );
1560 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1561 }
1562 Registry::new()
1563 .auto_detect_project_layer(&yaml_path)
1564 .finalise()
1565 .unwrap()
1566 }
1567
1568 fn build_registry_with_refs(
1573 skills: &[(&str, &str, &str, &str)],
1574 ) -> crate::server::skills::ResolvedRegistry {
1575 use crate::server::skills::Registry;
1576 let dir = tempfile::tempdir().unwrap();
1577 let yaml_path = dir.path().join("manifest.yaml");
1578 let skills_dir = dir.path().join("manifest.skills");
1579 std::fs::create_dir_all(&skills_dir).unwrap();
1580 for (name, description, body, references_tools) in skills {
1581 let content = format!(
1582 "---\nname: {name}\ndescription: {description}\n\
1583 auto_inject_hint: true\nreferences_tools: {references_tools}\n---\n\n{body}\n"
1584 );
1585 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1586 }
1587 Registry::new()
1588 .auto_detect_project_layer(&yaml_path)
1589 .finalise()
1590 .unwrap()
1591 }
1592
1593 fn tool_desc(server: &McpServer, tool: &str) -> String {
1594 server
1595 .tool_router
1596 .get(tool)
1597 .and_then(|t| t.description.clone())
1598 .map(|c| c.into_owned())
1599 .unwrap_or_default()
1600 }
1601
1602 #[test]
1603 fn prompt_router_empty_by_default() {
1604 let server = McpServer::new(ServerOptions::default());
1605 assert!(server.prompt_router.map.is_empty());
1606 }
1607
1608 #[test]
1609 fn get_info_no_prompts_capability_when_empty() {
1610 let server = McpServer::new(ServerOptions::default());
1614 let info = server.get_info();
1615 assert!(
1616 info.capabilities.prompts.is_none(),
1617 "prompts capability must be absent when no skills are registered"
1618 );
1619 }
1620
1621 #[test]
1622 fn serve_prompts_registers_routes_with_metadata() {
1623 let registry = build_test_registry(&[
1624 ("alpha", "First skill.", "Alpha body.", true),
1625 ("beta", "Second skill.", "Beta body.", true),
1626 ]);
1627 let mut server = McpServer::new(ServerOptions::default());
1628 super::serve_prompts(®istry, &mut server);
1629
1630 let prompts = server.prompt_router.list_all();
1631 let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
1632 assert_eq!(names, vec!["alpha", "beta"]);
1633
1634 let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
1635 assert_eq!(alpha.description.as_deref(), Some("First skill."));
1636 assert!(alpha.arguments.is_none());
1637 }
1638
1639 #[test]
1640 fn serve_prompts_empty_registry_is_noop() {
1641 let registry = crate::server::skills::ResolvedRegistry::default();
1642 let mut server = McpServer::new(ServerOptions::default());
1643 super::serve_prompts(®istry, &mut server);
1644 assert!(server.prompt_router.map.is_empty());
1645 assert!(server.get_info().capabilities.prompts.is_none());
1646 }
1647
1648 #[test]
1649 fn get_info_advertises_prompts_when_present() {
1650 let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
1651 let mut server = McpServer::new(ServerOptions::default());
1652 super::serve_prompts(®istry, &mut server);
1653 let info = server.get_info();
1654 assert!(
1655 info.capabilities.prompts.is_some(),
1656 "prompts capability must be advertised once a skill is registered"
1657 );
1658 }
1659
1660 #[test]
1661 fn serve_prompts_auto_injects_full_body_into_matching_tool() {
1662 let registry =
1670 build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
1671 let mut server = McpServer::new(ServerOptions::default());
1672 let before = server
1673 .tool_router
1674 .get("ping")
1675 .and_then(|t| t.description.clone())
1676 .map(|c| c.into_owned())
1677 .unwrap_or_default();
1678 super::serve_prompts(®istry, &mut server);
1679 let after = server
1680 .tool_router
1681 .get("ping")
1682 .and_then(|t| t.description.clone())
1683 .map(|c| c.into_owned())
1684 .unwrap_or_default();
1685 assert!(after.starts_with(&before), "original description preserved");
1686 assert!(
1687 after.contains("## Methodology"),
1688 "inject should include a Methodology header; got: {after}"
1689 );
1690 assert!(
1691 after.contains("PING-BODY-SENTINEL"),
1692 "inject should embed the full skill body; got: {after}"
1693 );
1694 assert!(
1695 !after.contains("prompts/get"),
1696 "post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
1697 );
1698 }
1699
1700 #[test]
1701 fn serve_prompts_skips_injection_when_disabled() {
1702 let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
1703 let mut server = McpServer::new(ServerOptions::default());
1704 let before = server
1705 .tool_router
1706 .get("ping")
1707 .and_then(|t| t.description.clone())
1708 .map(|c| c.into_owned())
1709 .unwrap_or_default();
1710 super::serve_prompts(®istry, &mut server);
1711 let after = server
1712 .tool_router
1713 .get("ping")
1714 .and_then(|t| t.description.clone())
1715 .map(|c| c.into_owned())
1716 .unwrap_or_default();
1717 assert_eq!(
1718 before, after,
1719 "auto_inject_hint=false must leave tool description untouched"
1720 );
1721 }
1722
1723 #[test]
1724 fn serve_prompts_skips_injection_when_no_matching_tool() {
1725 let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
1728 let mut server = McpServer::new(ServerOptions::default());
1729 super::serve_prompts(®istry, &mut server);
1730 assert!(server.prompt_router.map.contains_key("no_such_tool"));
1731 let ping_desc = server
1734 .tool_router
1735 .get("ping")
1736 .and_then(|t| t.description.clone())
1737 .map(|c| c.into_owned())
1738 .unwrap_or_default();
1739 assert!(!ping_desc.contains("no_such_tool"));
1740 }
1741
1742 #[test]
1743 fn serve_prompts_injects_description_under_when_to_use() {
1744 let registry = build_test_registry(&[("ping", "ROUTING-SENTINEL", "BODY-SENTINEL", true)]);
1748 let mut server = McpServer::new(ServerOptions::default());
1749 super::serve_prompts(®istry, &mut server);
1750 let desc = tool_desc(&server, "ping");
1751 assert!(
1752 desc.contains("## When to use\n\nROUTING-SENTINEL"),
1753 "description should be injected under `## When to use`; got: {desc}"
1754 );
1755 assert!(
1756 desc.contains("<!-- mcp-skill:ping -->"),
1757 "injection should carry the per-skill idempotency marker; got: {desc}"
1758 );
1759 let when = desc.find("## When to use").unwrap();
1761 let method = desc.find("## Methodology").unwrap();
1762 assert!(when < method, "`When to use` must precede `Methodology`");
1763 }
1764
1765 #[test]
1766 fn serve_prompts_honors_references_tools() {
1767 let registry = build_registry_with_refs(&[(
1771 "graph_strategy",
1772 "Map structure first.",
1773 "GRAPH-BODY-SENTINEL",
1774 "[ping]",
1775 )]);
1776 let mut server = McpServer::new(ServerOptions::default());
1777 super::serve_prompts(®istry, &mut server);
1778 assert!(server.prompt_router.map.contains_key("graph_strategy"));
1780 let desc = tool_desc(&server, "ping");
1782 assert!(
1783 desc.contains("<!-- mcp-skill:graph_strategy -->"),
1784 "referenced tool should carry the skill marker; got: {desc}"
1785 );
1786 assert!(
1787 desc.contains("Map structure first."),
1788 "referenced tool should carry the skill routing; got: {desc}"
1789 );
1790 assert!(
1791 desc.contains("GRAPH-BODY-SENTINEL"),
1792 "referenced tool should carry the skill body; got: {desc}"
1793 );
1794 }
1795
1796 #[test]
1797 fn serve_prompts_idempotent_when_skill_self_references() {
1798 let registry = build_registry_with_refs(&[("ping", "Routing.", "Body.", "[ping]")]);
1802 let mut server = McpServer::new(ServerOptions::default());
1803 super::serve_prompts(®istry, &mut server);
1804 let desc = tool_desc(&server, "ping");
1805 let marker_count = desc.matches("<!-- mcp-skill:ping -->").count();
1806 assert_eq!(
1807 marker_count, 1,
1808 "self-referencing skill must inject exactly once; got {marker_count}: {desc}"
1809 );
1810 }
1811
1812 #[test]
1813 fn serve_prompts_idempotent_across_repeated_passes() {
1814 let registry = build_test_registry(&[("ping", "Routing.", "Body.", true)]);
1817 let mut server = McpServer::new(ServerOptions::default());
1818 super::serve_prompts(®istry, &mut server);
1819 let once = tool_desc(&server, "ping");
1820 super::serve_prompts(®istry, &mut server);
1821 let twice = tool_desc(&server, "ping");
1822 assert_eq!(
1823 once, twice,
1824 "second pass must be a no-op for an already-injected tool"
1825 );
1826 }
1827
1828 #[test]
1829 fn serve_prompts_multiple_skills_stack_on_one_tool() {
1830 let registry = build_registry_with_refs(&[
1834 ("ping", "Ping routing.", "PING-BODY", "[]"),
1835 ("ping_strategy", "Strategy routing.", "STRAT-BODY", "[ping]"),
1836 ]);
1837 let mut server = McpServer::new(ServerOptions::default());
1838 super::serve_prompts(®istry, &mut server);
1839 let desc = tool_desc(&server, "ping");
1840 assert!(desc.contains("<!-- mcp-skill:ping -->"), "got: {desc}");
1841 assert!(
1842 desc.contains("<!-- mcp-skill:ping_strategy -->"),
1843 "got: {desc}"
1844 );
1845 assert!(
1846 desc.contains("PING-BODY") && desc.contains("STRAT-BODY"),
1847 "got: {desc}"
1848 );
1849 }
1850
1851 fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
1852 let dir = tempfile::tempdir().unwrap();
1853 let yaml = dir.path().join("test_mcp.yaml");
1854 std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1855 let skills_dir = dir.path().join("test_mcp.skills");
1856 std::fs::create_dir(&skills_dir).unwrap();
1857 std::fs::write(
1858 skills_dir.join("gated_skill.md"),
1859 format!(
1860 "---\n\
1861 name: gated_skill\n\
1862 description: A predicate-gated skill for testing.\n\
1863 applies_when:\n\
1864 {applies_when_yaml}\n\
1865 ---\n\n\
1866 Body.\n",
1867 ),
1868 )
1869 .unwrap();
1870 dir
1871 }
1872
1873 #[test]
1874 fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
1875 use crate::server::skills::Registry as SkillsBuilder;
1879 let dir = write_gated_project_skill(" tool_registered: nonexistent_tool");
1880 let yaml = dir.path().join("test_mcp.yaml");
1881 let registry = SkillsBuilder::new()
1882 .auto_detect_project_layer(&yaml)
1883 .finalise()
1884 .unwrap();
1885 let mut server = McpServer::new(ServerOptions::default());
1886 super::serve_prompts(®istry, &mut server);
1887 assert!(
1888 !server.prompt_router.map.contains_key("gated_skill"),
1889 "skill with unsatisfied predicate must be suppressed"
1890 );
1891 }
1892
1893 #[test]
1894 fn serve_prompts_keeps_skill_with_satisfied_predicate() {
1895 use crate::server::skills::Registry as SkillsBuilder;
1898 let dir = write_gated_project_skill(" tool_registered: ping");
1899 let yaml = dir.path().join("test_mcp.yaml");
1900 let registry = SkillsBuilder::new()
1901 .auto_detect_project_layer(&yaml)
1902 .finalise()
1903 .unwrap();
1904 let mut server = McpServer::new(ServerOptions::default());
1905 super::serve_prompts(®istry, &mut server);
1906 assert!(
1907 server.prompt_router.map.contains_key("gated_skill"),
1908 "skill with satisfied predicate must register"
1909 );
1910 }
1911
1912 #[test]
1913 fn serve_prompts_evaluates_extension_enabled_from_manifest() {
1914 use crate::server::skills::Registry as SkillsBuilder;
1918 let dir = write_gated_project_skill(" extension_enabled: csv_http_server");
1919 let yaml = dir.path().join("test_mcp.yaml");
1920 let registry = SkillsBuilder::new()
1921 .auto_detect_project_layer(&yaml)
1922 .finalise()
1923 .unwrap();
1924
1925 let mut server = McpServer::new(ServerOptions::default());
1927 super::serve_prompts(®istry, &mut server);
1928 assert!(!server.prompt_router.map.contains_key("gated_skill"));
1929
1930 let mut extensions = serde_json::Map::new();
1932 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1933 let opts = ServerOptions {
1934 extensions,
1935 ..ServerOptions::default()
1936 };
1937 let mut server = McpServer::new(opts);
1938 super::serve_prompts(®istry, &mut server);
1939 assert!(server.prompt_router.map.contains_key("gated_skill"));
1940 }
1941}