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}
235
236#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
237pub struct GrepArgs {
238 pub pattern: String,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub glob: Option<String>,
243 #[serde(default)]
245 pub context: usize,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub max_results: Option<usize>,
249 #[serde(default)]
251 pub case_insensitive: bool,
252}
253
254#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
255pub struct SetRootDirArgs {
256 pub path: String,
258}
259
260#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
261pub struct RepoManagementArgs {
262 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub name: Option<String>,
265 #[serde(default)]
267 pub delete: bool,
268 #[serde(default)]
270 pub update: bool,
271 #[serde(default)]
275 pub force_rebuild: bool,
276}
277
278#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
279pub struct GithubIssuesArgs {
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub number: Option<u64>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub repo_name: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub query: Option<String>,
289 #[serde(default = "default_kind")]
291 pub kind: String,
292 #[serde(default = "default_state")]
294 pub state: String,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub sort: Option<String>,
298 #[serde(default = "default_limit")]
300 pub limit: usize,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub labels: Option<String>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub element_id: Option<String>,
310 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub lines: Option<String>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub grep: Option<String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub context: Option<usize>,
323 #[serde(default)]
326 pub refresh: bool,
327}
328
329fn default_kind() -> String {
330 "all".to_string()
331}
332fn default_state() -> String {
333 "open".to_string()
334}
335fn default_limit() -> usize {
336 20
337}
338
339impl Default for GithubIssuesArgs {
340 fn default() -> Self {
341 Self {
342 number: None,
343 repo_name: None,
344 query: None,
345 kind: default_kind(),
346 state: default_state(),
347 sort: None,
348 limit: default_limit(),
349 labels: None,
350 element_id: None,
351 lines: None,
352 grep: None,
353 context: None,
354 refresh: false,
355 }
356 }
357}
358
359#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
360pub struct GithubApiArgs {
361 pub path: String,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub repo_name: Option<String>,
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub truncate_at: Option<usize>,
374}
375
376#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
377pub struct ListSourceArgs {
378 #[serde(default = "default_path")]
380 pub path: String,
381 #[serde(default = "default_depth")]
383 pub depth: usize,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub glob: Option<String>,
387 #[serde(default)]
389 pub dirs_only: bool,
390}
391
392fn default_path() -> String {
393 ".".to_string()
394}
395fn default_depth() -> usize {
396 1
397}
398
399#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
400pub struct ScreenStargazersArgs {
401 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub repo: Option<String>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub users: Option<String>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub preset: Option<String>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub rank_by: Option<String>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub top: Option<usize>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub min_keywords: Option<usize>,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub active_since: Option<String>,
425 #[serde(default)]
427 pub adopters_only: bool,
428 #[serde(default)]
430 pub stack_only: bool,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub keywords: Option<String>,
437 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub stack: Option<String>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub max_stargazers: Option<usize>,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub element_id: Option<String>,
450 #[serde(default)]
452 pub refresh: bool,
453}
454
455#[derive(Clone)]
460pub struct McpServer {
461 options: ServerOptions,
462 tool_router: ToolRouter<McpServer>,
463 prompt_router: PromptRouter<McpServer>,
468}
469
470#[tool_router]
471impl McpServer {
472 pub fn new(options: ServerOptions) -> Self {
473 let mut server = Self {
474 options,
475 tool_router: Self::tool_router(),
476 prompt_router: PromptRouter::new(),
477 };
478 server.register_github_tools_if_authorized();
479 server.register_local_workspace_tools();
480 server.gate_workspace_tools();
481 server
482 }
483
484 fn gate_workspace_tools(&mut self) {
492 if self.options.workspace.is_none() {
493 self.tool_router.remove_route("repo_management");
494 }
495 }
496
497 fn register_local_workspace_tools(&mut self) {
501 let Some(ws) = self.options.workspace.clone() else {
502 return;
503 };
504 if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
505 return;
506 }
507 self.register_typed_tool::<SetRootDirArgs, _>(
508 "set_root_dir",
509 "Swap the active source root (local-workspace mode only). Pass `path` \
510 to a directory; the framework canonicalises it, rebinds the source \
511 tools (`read_source`, `grep`, `list_source`), and fires the post-\
512 activate hook so any downstream graph rebuilds against the new root. \
513 Inventory persists across swaps; SHA-gating skips rebuilds when the \
514 same root is re-bound with no content changes.",
515 move |args: SetRootDirArgs| {
516 let p = std::path::PathBuf::from(&args.path);
517 ws.set_root_dir(&p)
518 },
519 );
520 }
521
522 fn register_github_tools_if_authorized(&mut self) {
528 if !crate::github::has_git_token() {
529 tracing::info!(
530 "GITHUB_TOKEN not set — github_issues / github_api tools hidden from the agent. \
531 Set the env var and restart to enable them."
532 );
533 return;
534 }
535 let default_repo = self.options.default_repo.clone();
536 let repo_provider = default_repo.clone();
537 let cache: Arc<Mutex<crate::cache::ElementCache>> =
543 Arc::new(Mutex::new(crate::cache::ElementCache::new()));
544 let cache_for_issues = cache.clone();
545 self.register_typed_tool::<GithubIssuesArgs, _>(
546 "github_issues",
547 "Search, list, or fetch GitHub issues / pull requests / Discussions. \
548 Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
549 for SEARCH (across issues+PRs and Discussions); neither for LIST. \
550 `kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
551 `state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
552 result count (default 20). `labels` is a comma-separated string. \
553 `repo_name=\"org/repo\"` overrides the active repo for one call. \
554 FETCH responses collapse big code blocks / patches / comments into \
555 `cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
556 `element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
557 element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
558 `refresh=true` bypasses the cache for re-fetch.",
559 move |args: GithubIssuesArgs| {
560 let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
561 Ok(r) => r,
562 Err(msg) => return msg,
563 };
564 if let Some(number) = args.number {
570 let context = args.context.unwrap_or(3);
571 let mut guard = cache_for_issues.lock().unwrap();
572 return guard.fetch_issue(
573 &repo,
574 number,
575 args.element_id.as_deref(),
576 args.lines.as_deref(),
577 args.grep.as_deref(),
578 context,
579 args.refresh,
580 );
581 }
582 if args.element_id.is_some() {
583 return "element_id requires `number=N` (the issue/PR being drilled into)."
584 .to_string();
585 }
586 crate::github::github_issues_rust(
588 Some(&repo),
589 args.number,
590 args.query.as_deref(),
591 &args.kind,
592 &args.state,
593 args.sort.as_deref(),
594 args.limit,
595 args.labels.as_deref(),
596 )
597 },
598 );
599 let repo_provider = default_repo.clone();
600 let repo_for_screen = default_repo;
601 self.register_typed_tool::<GithubApiArgs, _>(
602 "github_api",
603 "Read-only GET against the GitHub REST API. `path` may be a \
604 repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
605 \"branches\", \"compare/main...feature\") which is auto-prefixed \
606 with /repos/<repo_name>/, or a top-level resource (\"search/issues?q=...\", \
607 \"users/octocat\", \"repos/owner/name\") which passes through. A \
608 leading slash is optional and accepted on either form. Returns \
609 JSON, truncated at 80 KB by default.",
610 move |args: GithubApiArgs| match resolve_repo_from(
611 repo_provider.as_ref(),
612 args.repo_name.clone(),
613 ) {
614 Ok(repo) => {
615 let truncate_at = args.truncate_at.unwrap_or(80_000);
616 crate::github::git_api_internal(&repo, &args.path, truncate_at)
617 }
618 Err(msg) => msg,
619 },
620 );
621
622 if self.options.builtins.screen_stargazers {
630 let screen_store: Arc<Mutex<crate::screen::ScreenStore>> =
631 Arc::new(Mutex::new(crate::screen::ScreenStore::new()));
632 self.register_typed_tool::<ScreenStargazersArgs, _>(
633 "screen_stargazers",
634 "Screen the people around a GitHub project to find relevant developers, \
635 notable/legendary devs, architectural peers, and actual users — cheaply. \
636 Seed on a repo (`repo=\"owner/repo\"` → screens its stargazers) OR an \
637 explicit user list (`users=\"alice,bob\"` → screens them directly). With \
638 just a repo it auto-derives relevance keywords + tech stack from the repo \
639 itself, bulk-fetches each person's public repo portfolio over plain REST \
640 (~1 request per person, no GraphQL, no READMEs), classifies them, and \
641 enriches a bounded shortlist with follower counts, dependency-adoption, \
642 stack co-location, and contributions. Every person gets a normalized \
643 0–100 score vector on four axes — relatedness, popularity, effort, \
644 recency. RANK/FILTER: pass a `preset` (\"outreach\"=relevant+active by \
645 reach, \"peers\"=your stack by effort, \"legends\"=biggest reach any \
646 domain, \"intel\"=on-domain by popularity, \"adopters\"=actual users), or \
647 `rank_by`=relatedness|popularity|effort|recency with filters \
648 (`min_keywords`, `active_since`, `adopters_only`, `stack_only`) and \
649 `top`=N (rank-then-take-N, default 10) for a focused filter→rank→take \
650 view; with none, the full multi-lens browse: \
651 `✅ ADOPTERS` (stargazers whose repos actually declare your package as a \
652 dependency — real users, not just watchers), `★ MOST RELEVANT` \
653 (relatedness — repos matching your topic keywords, with follower counts \
654 and external contributions), `🏆 NOTABLE` (popularity/reach lens — your \
655 highest-traction stargazers, flagged `LEGEND` for big audiences/projects), \
656 `✦ QUALITY` (best-kept maintained projects), `⚙ STACK MATCH` (architectural \
657 peers who build in your stack — co-location-confirmed where possible), and \
658 a cohort inventory. Override the auto-config with `keywords=\"graph,rag,agent\"` \
659 (single words — \"knowledge,graph\" not \"knowledge-graph\") and \
660 `stack=\"Rust,Python\"`; re-calling with new values re-ranks the cached \
661 fetch for free. Treat description-based leads as candidates to verify by \
662 drilling. DRILL via `element_id`: `\"cohort:<key>\"` (established / single / \
663 prolific / casual / dormant / consumers — the overview lists each key), \
664 `\"user:<login>\"` (portfolio), `\"user:<login>/repo:<name>\"` (repo profile), \
665 or `\"user:<login>/repo:<name>/readme\"` (README gist — the only drill that \
666 costs a request). `max_stargazers` samples the most-recent N (the overview \
667 reports if results are partial); `refresh=true` re-fetches.",
668 move |args: ScreenStargazersArgs| {
669 use crate::screen::{self, Filters, RankBy, Seed, Selection};
670 let split_csv = |s: Option<String>| -> Vec<String> {
671 s.map(|v| {
672 v.split(',')
673 .map(|t| t.trim().to_string())
674 .filter(|t| !t.is_empty())
675 .collect()
676 })
677 .unwrap_or_default()
678 };
679 let seed = if let Some(u) = &args.users {
681 Seed::Users(split_csv(Some(u.clone())))
682 } else {
683 let repo =
684 match resolve_repo_from(repo_for_screen.as_ref(), args.repo.clone()) {
685 Ok(r) => r,
686 Err(msg) => return msg,
687 };
688 if let Some(err) = crate::git_refs::validate_repo(&repo) {
689 return err;
690 }
691 Seed::Repo(repo)
692 };
693 let cfg = screen::ScreenConfig {
694 max_stargazers: args.max_stargazers,
695 max_repos_per_user: 100,
696 relevance_keywords: split_csv(args.keywords)
697 .into_iter()
698 .map(|k| k.to_lowercase())
699 .collect(),
700 stack_languages: split_csv(args.stack),
701 };
702 let top = args.top.unwrap_or(10);
704 let filters = Filters {
705 min_keywords: args.min_keywords,
706 active_since: args.active_since.clone(),
707 adopters_only: args.adopters_only,
708 stack_only: args.stack_only,
709 ..Default::default()
710 };
711 let filters_active = filters.min_keywords.is_some()
712 || filters.active_since.is_some()
713 || filters.adopters_only
714 || filters.stack_only;
715 let selection: Option<Selection> = if let Some(name) = &args.preset {
716 screen::preset(name, top)
717 } else if args.rank_by.is_some() || filters_active {
718 Some(Selection {
719 filters,
720 rank: args
721 .rank_by
722 .as_deref()
723 .and_then(RankBy::parse)
724 .unwrap_or(RankBy::Relatedness),
725 label: "SELECTION".into(),
726 take: top,
727 })
728 } else {
729 None
730 };
731 screen::screen_dispatch(
732 &screen_store,
733 &seed,
734 &cfg,
735 selection.as_ref(),
736 args.element_id.as_deref(),
737 args.refresh,
738 )
739 },
740 );
741 }
742 }
743
744 pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
751 &self.options.builtins
752 }
753
754 pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
760 &mut self.tool_router
761 }
762
763 pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
768 &mut self.prompt_router
769 }
770
771 pub fn register_typed_tool<T, F>(
786 &mut self,
787 name: &'static str,
788 description: &'static str,
789 handler: F,
790 ) where
791 T: for<'de> serde::Deserialize<'de>
792 + schemars::JsonSchema
793 + Default
794 + Send
795 + Sync
796 + 'static,
797 F: Fn(T) -> String + Send + Sync + 'static,
798 {
799 use std::pin::Pin;
800 type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
801
802 let schema_obj = serde_json::to_value(schemars::schema_for!(T))
803 .ok()
804 .and_then(|v| v.as_object().cloned())
805 .unwrap_or_default();
806 let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
807 let handler = std::sync::Arc::new(handler);
808 let tool_name = name;
813 let postprocess = self.options.result_postprocess.clone();
814 let source_roots = self.options.source_roots.clone();
815 let workspace = self.options.workspace.clone();
816
817 self.tool_router
818 .add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
819 attr,
820 move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
821 -> DynFut<'_, Result<rmcp::model::CallToolResult, rmcp::ErrorData>> {
822 let handler = handler.clone();
823 let arguments = ctx.arguments.clone();
824 let postprocess = postprocess.clone();
825 let source_roots = source_roots.clone();
826 let workspace = workspace.clone();
827 Box::pin(async move {
828 let args_json = match &arguments {
831 Some(map) => serde_json::Value::Object(map.clone()),
832 None => serde_json::Value::Null,
833 };
834 let args: T = match arguments {
835 Some(map) => {
836 match serde_json::from_value(serde_json::Value::Object(map)) {
837 Ok(a) => a,
838 Err(e) => {
839 return Ok(rmcp::model::CallToolResult::success(vec![
840 rmcp::model::Content::text(format!(
841 "invalid arguments: {e}"
842 )),
843 ]));
844 }
845 }
846 }
847 None => T::default(),
848 };
849 let body = handler(args);
850 let body = match &postprocess {
851 Some(hook) => {
852 let ctx = ResultCtx {
853 source_roots: source_roots
854 .as_ref()
855 .map(|p| p())
856 .unwrap_or_default(),
857 active_repo: workspace
858 .as_ref()
859 .and_then(|w| w.active_repo_name()),
860 };
861 let footer = hook(tool_name, &args_json, &body, &ctx);
862 append_footer(body, footer)
863 }
864 None => body,
865 };
866 Ok(rmcp::model::CallToolResult::success(vec![
867 rmcp::model::Content::text(body),
868 ]))
869 })
870 },
871 ));
872 }
873
874 fn current_source_roots(&self) -> Vec<String> {
875 match &self.options.source_roots {
876 Some(provider) => provider(),
877 None => Vec::new(),
878 }
879 }
880
881 fn finish(&self, tool: &str, args: &serde_json::Value, body: String) -> String {
888 let Some(hook) = &self.options.result_postprocess else {
889 return body;
890 };
891 let ctx = ResultCtx {
892 source_roots: self.current_source_roots(),
893 active_repo: self
894 .options
895 .workspace
896 .as_ref()
897 .and_then(|w| w.active_repo_name()),
898 };
899 let footer = hook(tool, args, &body, &ctx);
900 append_footer(body, footer)
901 }
902
903 #[allow(dead_code)]
908 fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
909 resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
910 }
911
912 #[tool(
913 description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
914 Use to confirm the server framework is wired correctly before \
915 relying on graph- or source-aware tools."
916 )]
917 async fn ping(
918 &self,
919 Parameters(args): Parameters<PingArgs>,
920 ) -> Result<CallToolResult, McpError> {
921 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
922 let body = args.message.unwrap_or_else(|| "pong".to_string());
923 let body = self.finish("ping", &args_json, body);
924 Ok(CallToolResult::success(vec![Content::text(body)]))
925 }
926
927 #[tool(description = "Read a file from the configured source root(s). Pass \
928 `start_line`/`end_line` to slice, `grep` to filter to matching \
929 lines, `max_chars` to cap output. Path traversal attempts are \
930 rejected. Available only when source roots are configured.")]
931 async fn read_source(
932 &self,
933 Parameters(args): Parameters<ReadSourceArgs>,
934 ) -> Result<CallToolResult, McpError> {
935 let roots = self.current_source_roots();
936 if roots.is_empty() {
937 return Ok(CallToolResult::success(vec![Content::text(
938 "Cannot read source: no active source root. Configure source_root in your manifest \
939 or activate one (e.g. via repo_management in workspace mode).",
940 )]));
941 }
942 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
943 let opts = ReadOpts {
944 start_line: args.start_line,
945 end_line: args.end_line,
946 grep: args.grep,
947 grep_context: args.grep_context,
948 max_matches: args.max_matches,
949 max_chars: args.max_chars,
950 };
951 let body = source::read_source(&args.file_path, &roots, &opts);
952 let body = self.finish("read_source", &args_json, body);
953 Ok(CallToolResult::success(vec![Content::text(body)]))
954 }
955
956 #[tool(
957 description = "Search source files using ripgrep. `pattern` is a regex (Rust \
958 syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
959 N surrounding lines per match. Set `case_insensitive=true` for \
960 case-insensitive matching. `max_results` caps total matches \
961 (default 50)."
962 )]
963 async fn grep(
964 &self,
965 Parameters(args): Parameters<GrepArgs>,
966 ) -> Result<CallToolResult, McpError> {
967 let roots = self.current_source_roots();
968 if roots.is_empty() {
969 return Ok(CallToolResult::success(vec![Content::text(
970 "Cannot grep: no active source root. Configure source_root in your manifest \
971 or activate one (e.g. via repo_management in workspace mode).",
972 )]));
973 }
974 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
975 let opts = GrepOpts {
976 glob: args.glob,
977 context: args.context,
978 max_results: Some(args.max_results.unwrap_or(50)),
979 case_insensitive: args.case_insensitive,
980 };
981 let body = source::grep(&roots, &args.pattern, &opts);
982 let body = self.finish("grep", &args_json, body);
983 Ok(CallToolResult::success(vec![Content::text(body)]))
984 }
985
986 #[tool(
987 description = "List directory contents under the configured source root. `path` \
988 is resolved against the first source root (\".\" lists the root \
989 itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
990 `glob` filters entry names. `dirs_only=true` shows only \
991 directories."
992 )]
993 async fn list_source(
994 &self,
995 Parameters(args): Parameters<ListSourceArgs>,
996 ) -> Result<CallToolResult, McpError> {
997 let roots = self.current_source_roots();
998 if roots.is_empty() {
999 return Ok(CallToolResult::success(vec![Content::text(
1000 "Cannot list source: no active source root. Configure source_root in your \
1001 manifest or activate one (e.g. via repo_management in workspace mode).",
1002 )]));
1003 }
1004 let primary = std::path::PathBuf::from(&roots[0]);
1005 let target = match resolve_dir_under_roots(&args.path, &roots) {
1006 Some(p) => p,
1007 None => {
1008 return Ok(CallToolResult::success(vec![Content::text(format!(
1009 "Error: path '{}' resolves outside the configured source roots.",
1010 args.path
1011 ))]));
1012 }
1013 };
1014 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1015 let opts = ListOpts {
1016 depth: args.depth,
1017 glob: args.glob,
1018 dirs_only: args.dirs_only,
1019 };
1020 let body = source::list_source(&target, &primary, &opts);
1021 let body = self.finish("list_source", &args_json, body);
1022 Ok(CallToolResult::success(vec![Content::text(body)]))
1023 }
1024
1025 #[tool(
1026 description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
1027 clone (if missing) and activate it as the source root for \
1028 read_source / grep / list_source. Pass `delete=true` to remove a \
1029 repo. Pass `update=true` to fetch upstream changes for the active \
1030 repo (rebuild auto-skipped when HEAD hasn't moved since the last \
1031 build; set `force_rebuild=true` to bypass). Call with no \
1032 arguments to list all known repos with their last-access counts. \
1033 Idle repos auto-sweep on each call (default 7 days, configurable \
1034 via --stale-after-days)."
1035 )]
1036 async fn repo_management(
1037 &self,
1038 Parameters(args): Parameters<RepoManagementArgs>,
1039 ) -> Result<CallToolResult, McpError> {
1040 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1041 let body = match &self.options.workspace {
1042 Some(ws) => ws.repo_management(
1043 args.name.as_deref(),
1044 args.delete,
1045 args.update,
1046 args.force_rebuild,
1047 ),
1048 None => "repo_management requires --workspace mode.".to_string(),
1049 };
1050 let body = self.finish("repo_management", &args_json, body);
1051 Ok(CallToolResult::success(vec![Content::text(body)]))
1052 }
1053}
1054
1055fn resolve_repo_from(
1063 default_repo: Option<&RepoProvider>,
1064 override_repo: Option<String>,
1065) -> Result<String, String> {
1066 if let Some(r) = override_repo {
1067 if let Some(err) = crate::git_refs::validate_repo(&r) {
1068 return Err(err);
1069 }
1070 return Ok(r);
1071 }
1072 if let Some(provider) = default_repo {
1073 if let Some(r) = provider() {
1074 if let Some(err) = crate::git_refs::validate_repo(&r) {
1075 return Err(err);
1076 }
1077 return Ok(r);
1078 }
1079 }
1080 if let Some(detected) = crate::github::detect_git_repo(".") {
1081 if crate::git_refs::validate_repo(&detected).is_none() {
1082 return Ok(detected);
1083 }
1084 }
1085 Err(
1086 "No active repository. Pass `repo_name='org/repo'`, configure a default in the \
1087 server, or run from a directory whose git remote points at github.com."
1088 .to_string(),
1089 )
1090}
1091
1092pub fn serve_prompts(registry: &ResolvedRegistry, server: &mut McpServer) {
1106 use std::borrow::Cow;
1107 use std::collections::HashSet;
1108
1109 let registered_tools: HashSet<String> = server
1114 .tool_router
1115 .list_all()
1116 .iter()
1117 .map(|t| t.name.to_string())
1118 .collect();
1119 let extensions = server.options.extensions.clone();
1120
1121 struct InjectSkill {
1127 name: String,
1128 description: String,
1129 body: String,
1130 references_tools: Vec<String>,
1131 }
1132 let mut auto_inject: Vec<InjectSkill> = Vec::new();
1133
1134 for name in registry.skill_names() {
1135 let Some(skill) = registry.get(&name) else {
1136 continue;
1137 };
1138
1139 let activation = registry.activation_for(skill, ®istered_tools, &extensions);
1143 if !activation.active {
1144 let failed_clauses: Vec<&str> = activation
1145 .clauses
1146 .iter()
1147 .filter(|(_, outcome)| {
1148 *outcome != crate::server::skills::PredicateOutcome::Satisfied
1149 })
1150 .map(|(clause, _)| clause.as_str())
1151 .collect();
1152 tracing::info!(
1153 skill = %name,
1154 suppressed_by = ?failed_clauses,
1155 "skill suppressed by applies_when predicates"
1156 );
1157 continue;
1158 }
1159
1160 let prompt = Prompt::new(
1161 skill.name().to_string(),
1162 Some(skill.description().to_string()),
1163 None,
1164 );
1165 let body = skill.body.clone();
1166 let route = PromptRoute::new_dyn(prompt, move |_ctx| {
1167 let body = body.clone();
1168 Box::pin(async move {
1169 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
1170 PromptMessageRole::Assistant,
1171 body,
1172 )]))
1173 })
1174 });
1175 server.prompt_router.add_route(route);
1176
1177 if skill.frontmatter.auto_inject_hint {
1178 auto_inject.push(InjectSkill {
1179 name: skill.name().to_string(),
1180 description: skill.description().to_string(),
1181 body: skill.body.clone(),
1182 references_tools: skill.frontmatter.references_tools.clone(),
1183 });
1184 }
1185 }
1186
1187 for inj in &auto_inject {
1223 let mut targets: Vec<&str> = Vec::new();
1226 let mut seen: HashSet<&str> = HashSet::new();
1227 for tool in std::iter::once(inj.name.as_str())
1228 .chain(inj.references_tools.iter().map(String::as_str))
1229 {
1230 if seen.insert(tool) {
1231 targets.push(tool);
1232 }
1233 }
1234
1235 let marker = format!("<!-- mcp-skill:{} -->", inj.name);
1238 let mut block = format!("\n\n{marker}");
1239 let description = inj.description.trim();
1240 if !description.is_empty() {
1241 block.push_str("\n\n## When to use\n\n");
1242 block.push_str(description);
1243 }
1244 block.push_str("\n\n## Methodology\n\n");
1245 block.push_str(inj.body.trim());
1246
1247 for tool in targets {
1248 let key = Cow::<'static, str>::Owned(tool.to_string());
1249 let Some(route) = server.tool_router.map.get_mut(&key) else {
1250 continue;
1251 };
1252 if route
1255 .attr
1256 .description
1257 .as_deref()
1258 .is_some_and(|d| d.contains(&marker))
1259 {
1260 continue;
1261 }
1262 let new_desc = match route.attr.description.take() {
1263 Some(existing) => format!("{existing}{block}"),
1264 None => block.trim_start().to_string(),
1265 };
1266 route.attr.description = Some(Cow::Owned(new_desc));
1267 }
1268 }
1269}
1270
1271#[tool_handler(router = self.tool_router)]
1272impl ServerHandler for McpServer {
1273 fn get_info(&self) -> ServerInfo {
1274 let name = self
1275 .options
1276 .name
1277 .clone()
1278 .unwrap_or_else(|| "MCP Server".to_string());
1279 let mut caps = ServerCapabilities::builder().enable_tools().build();
1286 if !self.prompt_router.map.is_empty() {
1287 caps.prompts = Some(PromptsCapability::default());
1288 }
1289 let mut info = ServerInfo::new(caps)
1290 .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
1291 .with_protocol_version(ProtocolVersion::V_2024_11_05);
1292 if let Some(text) = &self.options.instructions {
1293 info = info.with_instructions(text.clone());
1294 }
1295 info
1296 }
1297
1298 async fn list_prompts(
1299 &self,
1300 _request: Option<PaginatedRequestParams>,
1301 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
1302 ) -> Result<ListPromptsResult, McpError> {
1303 Ok(ListPromptsResult {
1304 meta: None,
1305 next_cursor: None,
1306 prompts: self.prompt_router.list_all(),
1307 })
1308 }
1309
1310 async fn get_prompt(
1311 &self,
1312 request: GetPromptRequestParams,
1313 context: rmcp::service::RequestContext<rmcp::RoleServer>,
1314 ) -> Result<GetPromptResult, McpError> {
1315 let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
1316 self,
1317 request.name,
1318 request.arguments,
1319 context,
1320 );
1321 self.prompt_router.get_prompt(prompt_context).await
1322 }
1323}
1324
1325#[cfg(test)]
1326mod tests {
1327 use super::*;
1328
1329 #[test]
1330 fn options_from_manifest_uses_name_when_set() {
1331 let opts = ServerOptions::from_manifest(None, "Fallback");
1332 assert_eq!(opts.name.as_deref(), Some("Fallback"));
1333 }
1334
1335 #[test]
1336 fn builtins_exposed_via_server() {
1337 use crate::server::manifest::{BuiltinsConfig, TempCleanup};
1338 let opts = ServerOptions {
1339 builtins: BuiltinsConfig {
1340 save_graph: true,
1341 temp_cleanup: TempCleanup::OnOverview,
1342 ..Default::default()
1343 },
1344 ..ServerOptions::default()
1345 };
1346 let server = McpServer::new(opts);
1347 assert!(server.builtins().save_graph);
1348 assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
1349 }
1350
1351 #[test]
1352 fn server_constructs() {
1353 let _server = McpServer::new(ServerOptions::default());
1354 }
1355
1356 #[test]
1357 fn static_source_roots_provider() {
1358 let opts = ServerOptions::default()
1359 .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
1360 let server = McpServer::new(opts);
1361 assert_eq!(
1362 server.current_source_roots(),
1363 vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
1364 );
1365 }
1366
1367 #[test]
1368 fn no_provider_returns_empty_roots() {
1369 let server = McpServer::new(ServerOptions::default());
1370 assert!(server.current_source_roots().is_empty());
1371 }
1372
1373 #[test]
1374 fn repo_management_gated_to_workspace_mode() {
1375 let server = McpServer::new(ServerOptions::default());
1378 let tools = server.tool_router.list_all();
1379 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1380 assert!(
1381 !names.contains(&"repo_management"),
1382 "repo_management should be gated out without a workspace; tools were {names:?}"
1383 );
1384 }
1385
1386 #[test]
1387 fn repo_management_present_when_workspace_bound() {
1388 use crate::server::workspace::Workspace;
1391 let dir = tempfile::tempdir().unwrap();
1392 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1393 let opts = ServerOptions::default().with_workspace(ws);
1394 let server = McpServer::new(opts);
1395 let tools = server.tool_router.list_all();
1396 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1397 assert!(
1398 names.contains(&"repo_management"),
1399 "repo_management should be registered with a workspace; tools were {names:?}"
1400 );
1401 }
1402
1403 #[test]
1404 fn result_postprocess_appends_footer_and_sees_ctx() {
1405 use std::sync::Mutex;
1406 type Seen = Option<(String, serde_json::Value, String, Vec<String>)>;
1408 let seen: Arc<Mutex<Seen>> = Arc::new(Mutex::new(None));
1409 let seen_c = seen.clone();
1410 let hook: ResultPostprocessHook = Arc::new(move |tool, args, body, ctx| {
1411 *seen_c.lock().unwrap() = Some((
1412 tool.to_string(),
1413 args.clone(),
1414 body.to_string(),
1415 ctx.source_roots.clone(),
1416 ));
1417 if tool == "grep" {
1419 Some("↳ prefer cypher_query".to_string())
1420 } else {
1421 None
1422 }
1423 });
1424 let opts = ServerOptions::default()
1425 .with_static_source_roots(vec!["/src".to_string()])
1426 .with_result_postprocess(hook);
1427 let server = McpServer::new(opts);
1428
1429 let args = serde_json::json!({ "pattern": "^fn " });
1430 let out = server.finish("grep", &args, "match line".to_string());
1431 assert_eq!(out, "match line\n\n↳ prefer cypher_query");
1432
1433 let rec = seen.lock().unwrap().clone().unwrap();
1434 assert_eq!(rec.0, "grep");
1435 assert_eq!(rec.1, args);
1436 assert_eq!(rec.2, "match line");
1437 assert_eq!(rec.3, vec!["/src".to_string()]);
1438
1439 let out2 = server.finish("read_source", &args, "file body".to_string());
1441 assert_eq!(out2, "file body");
1442 }
1443
1444 #[test]
1445 fn no_result_postprocess_leaves_body_unchanged() {
1446 let server = McpServer::new(ServerOptions::default());
1447 let out = server.finish("grep", &serde_json::Value::Null, "x".to_string());
1448 assert_eq!(out, "x");
1449 }
1450
1451 #[test]
1452 fn append_footer_ignores_empty_footers() {
1453 assert_eq!(append_footer("a".to_string(), None), "a");
1454 assert_eq!(append_footer("a".to_string(), Some(String::new())), "a");
1455 assert_eq!(
1456 append_footer("a".to_string(), Some("b".to_string())),
1457 "a\n\nb"
1458 );
1459 }
1460
1461 #[test]
1462 fn dynamic_provider_swaps_at_call_time() {
1463 use std::sync::Mutex;
1464 let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
1465 let s2 = state.clone();
1466 let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
1467 let opts = ServerOptions::default().with_dynamic_source_roots(provider);
1468 let server = McpServer::new(opts);
1469 assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
1470 *state.lock().unwrap() = vec!["/swapped".to_string()];
1471 assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
1472 }
1473
1474 fn build_test_registry(
1477 skills: &[(&str, &str, &str, bool)],
1478 ) -> crate::server::skills::ResolvedRegistry {
1479 use crate::server::skills::Registry;
1480 let dir = tempfile::tempdir().unwrap();
1481 let yaml_path = dir.path().join("manifest.yaml");
1482 let skills_dir = dir.path().join("manifest.skills");
1483 std::fs::create_dir_all(&skills_dir).unwrap();
1484 for (name, description, body, auto_inject) in skills {
1485 let auto = if *auto_inject { "true" } else { "false" };
1486 let content = format!(
1487 "---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
1488 );
1489 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1490 }
1491 Registry::new()
1492 .auto_detect_project_layer(&yaml_path)
1493 .finalise()
1494 .unwrap()
1495 }
1496
1497 fn build_registry_with_refs(
1502 skills: &[(&str, &str, &str, &str)],
1503 ) -> crate::server::skills::ResolvedRegistry {
1504 use crate::server::skills::Registry;
1505 let dir = tempfile::tempdir().unwrap();
1506 let yaml_path = dir.path().join("manifest.yaml");
1507 let skills_dir = dir.path().join("manifest.skills");
1508 std::fs::create_dir_all(&skills_dir).unwrap();
1509 for (name, description, body, references_tools) in skills {
1510 let content = format!(
1511 "---\nname: {name}\ndescription: {description}\n\
1512 auto_inject_hint: true\nreferences_tools: {references_tools}\n---\n\n{body}\n"
1513 );
1514 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1515 }
1516 Registry::new()
1517 .auto_detect_project_layer(&yaml_path)
1518 .finalise()
1519 .unwrap()
1520 }
1521
1522 fn tool_desc(server: &McpServer, tool: &str) -> String {
1523 server
1524 .tool_router
1525 .get(tool)
1526 .and_then(|t| t.description.clone())
1527 .map(|c| c.into_owned())
1528 .unwrap_or_default()
1529 }
1530
1531 #[test]
1532 fn prompt_router_empty_by_default() {
1533 let server = McpServer::new(ServerOptions::default());
1534 assert!(server.prompt_router.map.is_empty());
1535 }
1536
1537 #[test]
1538 fn get_info_no_prompts_capability_when_empty() {
1539 let server = McpServer::new(ServerOptions::default());
1543 let info = server.get_info();
1544 assert!(
1545 info.capabilities.prompts.is_none(),
1546 "prompts capability must be absent when no skills are registered"
1547 );
1548 }
1549
1550 #[test]
1551 fn serve_prompts_registers_routes_with_metadata() {
1552 let registry = build_test_registry(&[
1553 ("alpha", "First skill.", "Alpha body.", true),
1554 ("beta", "Second skill.", "Beta body.", true),
1555 ]);
1556 let mut server = McpServer::new(ServerOptions::default());
1557 super::serve_prompts(®istry, &mut server);
1558
1559 let prompts = server.prompt_router.list_all();
1560 let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
1561 assert_eq!(names, vec!["alpha", "beta"]);
1562
1563 let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
1564 assert_eq!(alpha.description.as_deref(), Some("First skill."));
1565 assert!(alpha.arguments.is_none());
1566 }
1567
1568 #[test]
1569 fn serve_prompts_empty_registry_is_noop() {
1570 let registry = crate::server::skills::ResolvedRegistry::default();
1571 let mut server = McpServer::new(ServerOptions::default());
1572 super::serve_prompts(®istry, &mut server);
1573 assert!(server.prompt_router.map.is_empty());
1574 assert!(server.get_info().capabilities.prompts.is_none());
1575 }
1576
1577 #[test]
1578 fn get_info_advertises_prompts_when_present() {
1579 let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
1580 let mut server = McpServer::new(ServerOptions::default());
1581 super::serve_prompts(®istry, &mut server);
1582 let info = server.get_info();
1583 assert!(
1584 info.capabilities.prompts.is_some(),
1585 "prompts capability must be advertised once a skill is registered"
1586 );
1587 }
1588
1589 #[test]
1590 fn serve_prompts_auto_injects_full_body_into_matching_tool() {
1591 let registry =
1599 build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
1600 let mut server = McpServer::new(ServerOptions::default());
1601 let before = server
1602 .tool_router
1603 .get("ping")
1604 .and_then(|t| t.description.clone())
1605 .map(|c| c.into_owned())
1606 .unwrap_or_default();
1607 super::serve_prompts(®istry, &mut server);
1608 let after = server
1609 .tool_router
1610 .get("ping")
1611 .and_then(|t| t.description.clone())
1612 .map(|c| c.into_owned())
1613 .unwrap_or_default();
1614 assert!(after.starts_with(&before), "original description preserved");
1615 assert!(
1616 after.contains("## Methodology"),
1617 "inject should include a Methodology header; got: {after}"
1618 );
1619 assert!(
1620 after.contains("PING-BODY-SENTINEL"),
1621 "inject should embed the full skill body; got: {after}"
1622 );
1623 assert!(
1624 !after.contains("prompts/get"),
1625 "post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
1626 );
1627 }
1628
1629 #[test]
1630 fn serve_prompts_skips_injection_when_disabled() {
1631 let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
1632 let mut server = McpServer::new(ServerOptions::default());
1633 let before = server
1634 .tool_router
1635 .get("ping")
1636 .and_then(|t| t.description.clone())
1637 .map(|c| c.into_owned())
1638 .unwrap_or_default();
1639 super::serve_prompts(®istry, &mut server);
1640 let after = server
1641 .tool_router
1642 .get("ping")
1643 .and_then(|t| t.description.clone())
1644 .map(|c| c.into_owned())
1645 .unwrap_or_default();
1646 assert_eq!(
1647 before, after,
1648 "auto_inject_hint=false must leave tool description untouched"
1649 );
1650 }
1651
1652 #[test]
1653 fn serve_prompts_skips_injection_when_no_matching_tool() {
1654 let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
1657 let mut server = McpServer::new(ServerOptions::default());
1658 super::serve_prompts(®istry, &mut server);
1659 assert!(server.prompt_router.map.contains_key("no_such_tool"));
1660 let ping_desc = server
1663 .tool_router
1664 .get("ping")
1665 .and_then(|t| t.description.clone())
1666 .map(|c| c.into_owned())
1667 .unwrap_or_default();
1668 assert!(!ping_desc.contains("no_such_tool"));
1669 }
1670
1671 #[test]
1672 fn serve_prompts_injects_description_under_when_to_use() {
1673 let registry = build_test_registry(&[("ping", "ROUTING-SENTINEL", "BODY-SENTINEL", true)]);
1677 let mut server = McpServer::new(ServerOptions::default());
1678 super::serve_prompts(®istry, &mut server);
1679 let desc = tool_desc(&server, "ping");
1680 assert!(
1681 desc.contains("## When to use\n\nROUTING-SENTINEL"),
1682 "description should be injected under `## When to use`; got: {desc}"
1683 );
1684 assert!(
1685 desc.contains("<!-- mcp-skill:ping -->"),
1686 "injection should carry the per-skill idempotency marker; got: {desc}"
1687 );
1688 let when = desc.find("## When to use").unwrap();
1690 let method = desc.find("## Methodology").unwrap();
1691 assert!(when < method, "`When to use` must precede `Methodology`");
1692 }
1693
1694 #[test]
1695 fn serve_prompts_honors_references_tools() {
1696 let registry = build_registry_with_refs(&[(
1700 "graph_strategy",
1701 "Map structure first.",
1702 "GRAPH-BODY-SENTINEL",
1703 "[ping]",
1704 )]);
1705 let mut server = McpServer::new(ServerOptions::default());
1706 super::serve_prompts(®istry, &mut server);
1707 assert!(server.prompt_router.map.contains_key("graph_strategy"));
1709 let desc = tool_desc(&server, "ping");
1711 assert!(
1712 desc.contains("<!-- mcp-skill:graph_strategy -->"),
1713 "referenced tool should carry the skill marker; got: {desc}"
1714 );
1715 assert!(
1716 desc.contains("Map structure first."),
1717 "referenced tool should carry the skill routing; got: {desc}"
1718 );
1719 assert!(
1720 desc.contains("GRAPH-BODY-SENTINEL"),
1721 "referenced tool should carry the skill body; got: {desc}"
1722 );
1723 }
1724
1725 #[test]
1726 fn serve_prompts_idempotent_when_skill_self_references() {
1727 let registry = build_registry_with_refs(&[("ping", "Routing.", "Body.", "[ping]")]);
1731 let mut server = McpServer::new(ServerOptions::default());
1732 super::serve_prompts(®istry, &mut server);
1733 let desc = tool_desc(&server, "ping");
1734 let marker_count = desc.matches("<!-- mcp-skill:ping -->").count();
1735 assert_eq!(
1736 marker_count, 1,
1737 "self-referencing skill must inject exactly once; got {marker_count}: {desc}"
1738 );
1739 }
1740
1741 #[test]
1742 fn serve_prompts_idempotent_across_repeated_passes() {
1743 let registry = build_test_registry(&[("ping", "Routing.", "Body.", true)]);
1746 let mut server = McpServer::new(ServerOptions::default());
1747 super::serve_prompts(®istry, &mut server);
1748 let once = tool_desc(&server, "ping");
1749 super::serve_prompts(®istry, &mut server);
1750 let twice = tool_desc(&server, "ping");
1751 assert_eq!(
1752 once, twice,
1753 "second pass must be a no-op for an already-injected tool"
1754 );
1755 }
1756
1757 #[test]
1758 fn serve_prompts_multiple_skills_stack_on_one_tool() {
1759 let registry = build_registry_with_refs(&[
1763 ("ping", "Ping routing.", "PING-BODY", "[]"),
1764 ("ping_strategy", "Strategy routing.", "STRAT-BODY", "[ping]"),
1765 ]);
1766 let mut server = McpServer::new(ServerOptions::default());
1767 super::serve_prompts(®istry, &mut server);
1768 let desc = tool_desc(&server, "ping");
1769 assert!(desc.contains("<!-- mcp-skill:ping -->"), "got: {desc}");
1770 assert!(
1771 desc.contains("<!-- mcp-skill:ping_strategy -->"),
1772 "got: {desc}"
1773 );
1774 assert!(
1775 desc.contains("PING-BODY") && desc.contains("STRAT-BODY"),
1776 "got: {desc}"
1777 );
1778 }
1779
1780 fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
1781 let dir = tempfile::tempdir().unwrap();
1782 let yaml = dir.path().join("test_mcp.yaml");
1783 std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1784 let skills_dir = dir.path().join("test_mcp.skills");
1785 std::fs::create_dir(&skills_dir).unwrap();
1786 std::fs::write(
1787 skills_dir.join("gated_skill.md"),
1788 format!(
1789 "---\n\
1790 name: gated_skill\n\
1791 description: A predicate-gated skill for testing.\n\
1792 applies_when:\n\
1793 {applies_when_yaml}\n\
1794 ---\n\n\
1795 Body.\n",
1796 ),
1797 )
1798 .unwrap();
1799 dir
1800 }
1801
1802 #[test]
1803 fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
1804 use crate::server::skills::Registry as SkillsBuilder;
1808 let dir = write_gated_project_skill(" tool_registered: nonexistent_tool");
1809 let yaml = dir.path().join("test_mcp.yaml");
1810 let registry = SkillsBuilder::new()
1811 .auto_detect_project_layer(&yaml)
1812 .finalise()
1813 .unwrap();
1814 let mut server = McpServer::new(ServerOptions::default());
1815 super::serve_prompts(®istry, &mut server);
1816 assert!(
1817 !server.prompt_router.map.contains_key("gated_skill"),
1818 "skill with unsatisfied predicate must be suppressed"
1819 );
1820 }
1821
1822 #[test]
1823 fn serve_prompts_keeps_skill_with_satisfied_predicate() {
1824 use crate::server::skills::Registry as SkillsBuilder;
1827 let dir = write_gated_project_skill(" tool_registered: ping");
1828 let yaml = dir.path().join("test_mcp.yaml");
1829 let registry = SkillsBuilder::new()
1830 .auto_detect_project_layer(&yaml)
1831 .finalise()
1832 .unwrap();
1833 let mut server = McpServer::new(ServerOptions::default());
1834 super::serve_prompts(®istry, &mut server);
1835 assert!(
1836 server.prompt_router.map.contains_key("gated_skill"),
1837 "skill with satisfied predicate must register"
1838 );
1839 }
1840
1841 #[test]
1842 fn serve_prompts_evaluates_extension_enabled_from_manifest() {
1843 use crate::server::skills::Registry as SkillsBuilder;
1847 let dir = write_gated_project_skill(" extension_enabled: csv_http_server");
1848 let yaml = dir.path().join("test_mcp.yaml");
1849 let registry = SkillsBuilder::new()
1850 .auto_detect_project_layer(&yaml)
1851 .finalise()
1852 .unwrap();
1853
1854 let mut server = McpServer::new(ServerOptions::default());
1856 super::serve_prompts(®istry, &mut server);
1857 assert!(!server.prompt_router.map.contains_key("gated_skill"));
1858
1859 let mut extensions = serde_json::Map::new();
1861 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1862 let opts = ServerOptions {
1863 extensions,
1864 ..ServerOptions::default()
1865 };
1866 let mut server = McpServer::new(opts);
1867 super::serve_prompts(®istry, &mut server);
1868 assert!(server.prompt_router.map.contains_key("gated_skill"));
1869 }
1870}