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")]
271 pub revs: Option<crate::server::workspace::RevsRequest>,
272}
273
274#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
275pub struct RepoManagementArgs {
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub name: Option<String>,
279 #[serde(default)]
281 pub delete: bool,
282 #[serde(default)]
284 pub update: bool,
285 #[serde(default)]
289 pub force_rebuild: bool,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub revs: Option<crate::server::workspace::RevsRequest>,
298}
299
300#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
301pub struct GithubIssuesArgs {
302 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub number: Option<u64>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub repo_name: Option<String>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub query: Option<String>,
311 #[serde(default = "default_kind")]
313 pub kind: String,
314 #[serde(default = "default_state")]
316 pub state: String,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub sort: Option<String>,
320 #[serde(default = "default_limit")]
322 pub limit: usize,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub labels: Option<String>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub element_id: Option<String>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub lines: Option<String>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub grep: Option<String>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub context: Option<usize>,
345 #[serde(default)]
348 pub refresh: bool,
349}
350
351fn default_kind() -> String {
352 "all".to_string()
353}
354fn default_state() -> String {
355 "open".to_string()
356}
357fn default_limit() -> usize {
358 20
359}
360
361impl Default for GithubIssuesArgs {
362 fn default() -> Self {
363 Self {
364 number: None,
365 repo_name: None,
366 query: None,
367 kind: default_kind(),
368 state: default_state(),
369 sort: None,
370 limit: default_limit(),
371 labels: None,
372 element_id: None,
373 lines: None,
374 grep: None,
375 context: None,
376 refresh: false,
377 }
378 }
379}
380
381#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
382pub struct GithubApiArgs {
383 pub path: String,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub repo_name: Option<String>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub truncate_at: Option<usize>,
396}
397
398#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
399pub struct ListSourceArgs {
400 #[serde(default = "default_path")]
402 pub path: String,
403 #[serde(default = "default_depth")]
405 pub depth: usize,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub glob: Option<String>,
409 #[serde(default)]
411 pub dirs_only: bool,
412}
413
414fn default_path() -> String {
415 ".".to_string()
416}
417fn default_depth() -> usize {
418 1
419}
420
421#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
422pub struct ScreenStargazersArgs {
423 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub repo: Option<String>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub users: Option<String>,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub preset: Option<String>,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub rank_by: Option<String>,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub top: Option<usize>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub min_keywords: Option<usize>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub active_since: Option<String>,
447 #[serde(default)]
449 pub adopters_only: bool,
450 #[serde(default)]
452 pub stack_only: bool,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub keywords: Option<String>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub stack: Option<String>,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub max_stargazers: Option<usize>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub element_id: Option<String>,
472 #[serde(default)]
474 pub refresh: bool,
475}
476
477#[derive(Clone)]
482pub struct McpServer {
483 options: ServerOptions,
484 tool_router: ToolRouter<McpServer>,
485 prompt_router: PromptRouter<McpServer>,
490}
491
492#[tool_router]
493impl McpServer {
494 pub fn new(options: ServerOptions) -> Self {
495 let mut server = Self {
496 options,
497 tool_router: Self::tool_router(),
498 prompt_router: PromptRouter::new(),
499 };
500 server.register_github_tools_if_authorized();
501 server.register_local_workspace_tools();
502 server.gate_workspace_tools();
503 server
504 }
505
506 fn gate_workspace_tools(&mut self) {
514 if self.options.workspace.is_none() {
515 self.tool_router.remove_route("repo_management");
516 }
517 }
518
519 fn register_local_workspace_tools(&mut self) {
523 let Some(ws) = self.options.workspace.clone() else {
524 return;
525 };
526 if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
527 return;
528 }
529 self.register_typed_tool::<SetRootDirArgs, _>(
530 "set_root_dir",
531 "Swap the active source root (local-workspace mode only). Pass `path` \
532 to a directory; the framework canonicalises it, rebinds the source \
533 tools (`read_source`, `grep`, `list_source`), and fires the post-\
534 activate hook so any downstream graph rebuilds against the new root. \
535 Pass `revs` (an integer N, or a list of git revspecs) to load multiple \
536 revisions of the root into one graph — requires the root to be a git \
537 repo. Inventory persists across swaps; SHA-gating skips rebuilds when \
538 the same root is re-bound with no content changes.",
539 move |args: SetRootDirArgs| {
540 let p = std::path::PathBuf::from(&args.path);
541 ws.set_root_dir(&p, args.revs.as_ref())
542 },
543 );
544 }
545
546 fn register_github_tools_if_authorized(&mut self) {
552 if !crate::github::has_git_token() {
553 tracing::info!(
554 "GITHUB_TOKEN not set — github_issues / github_api tools hidden from the agent. \
555 Set the env var and restart to enable them."
556 );
557 return;
558 }
559 let default_repo = self.options.default_repo.clone();
560 let repo_provider = default_repo.clone();
561 let cache: Arc<Mutex<crate::cache::ElementCache>> =
567 Arc::new(Mutex::new(crate::cache::ElementCache::new()));
568 let cache_for_issues = cache.clone();
569 self.register_typed_tool::<GithubIssuesArgs, _>(
570 "github_issues",
571 "Search, list, or fetch GitHub issues / pull requests / Discussions. \
572 Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
573 for SEARCH (across issues+PRs and Discussions); neither for LIST. \
574 `kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
575 `state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
576 result count (default 20). `labels` is a comma-separated string. \
577 `repo_name=\"org/repo\"` overrides the active repo for one call. \
578 FETCH responses collapse big code blocks / patches / comments into \
579 `cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
580 `element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
581 element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
582 `refresh=true` bypasses the cache for re-fetch.",
583 move |args: GithubIssuesArgs| {
584 let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
585 Ok(r) => r,
586 Err(msg) => return msg,
587 };
588 if let Some(number) = args.number {
594 let context = args.context.unwrap_or(3);
595 let mut guard = cache_for_issues.lock().unwrap();
596 return guard.fetch_issue(
597 &repo,
598 number,
599 args.element_id.as_deref(),
600 args.lines.as_deref(),
601 args.grep.as_deref(),
602 context,
603 args.refresh,
604 );
605 }
606 if args.element_id.is_some() {
607 return "element_id requires `number=N` (the issue/PR being drilled into)."
608 .to_string();
609 }
610 crate::github::github_issues_rust(
612 Some(&repo),
613 args.number,
614 args.query.as_deref(),
615 &args.kind,
616 &args.state,
617 args.sort.as_deref(),
618 args.limit,
619 args.labels.as_deref(),
620 )
621 },
622 );
623 let repo_provider = default_repo.clone();
624 let repo_for_screen = default_repo;
625 self.register_typed_tool::<GithubApiArgs, _>(
626 "github_api",
627 "Read-only GET against the GitHub REST API. `path` may be a \
628 repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
629 \"branches\", \"compare/main...feature\") which is auto-prefixed \
630 with /repos/<repo_name>/, or a top-level resource (\"search/issues?q=...\", \
631 \"users/octocat\", \"repos/owner/name\") which passes through. A \
632 leading slash is optional and accepted on either form. Returns \
633 JSON, truncated at 80 KB by default.",
634 move |args: GithubApiArgs| match resolve_repo_from(
635 repo_provider.as_ref(),
636 args.repo_name.clone(),
637 ) {
638 Ok(repo) => {
639 let truncate_at = args.truncate_at.unwrap_or(80_000);
640 crate::github::git_api_internal(&repo, &args.path, truncate_at)
641 }
642 Err(msg) => msg,
643 },
644 );
645
646 if self.options.builtins.screen_stargazers {
654 let screen_store: Arc<Mutex<crate::screen::ScreenStore>> =
655 Arc::new(Mutex::new(crate::screen::ScreenStore::new()));
656 self.register_typed_tool::<ScreenStargazersArgs, _>(
657 "screen_stargazers",
658 "Screen the people around a GitHub project to find relevant developers, \
659 notable/legendary devs, architectural peers, and actual users — cheaply. \
660 Seed on a repo (`repo=\"owner/repo\"` → screens its stargazers) OR an \
661 explicit user list (`users=\"alice,bob\"` → screens them directly). With \
662 just a repo it auto-derives relevance keywords + tech stack from the repo \
663 itself, bulk-fetches each person's public repo portfolio over plain REST \
664 (~1 request per person, no GraphQL, no READMEs), classifies them, and \
665 enriches a bounded shortlist with follower counts, dependency-adoption, \
666 stack co-location, and contributions. Every person gets a normalized \
667 0–100 score vector on four axes — relatedness, popularity, effort, \
668 recency. RANK/FILTER: pass a `preset` (\"outreach\"=relevant+active by \
669 reach, \"peers\"=your stack by effort, \"legends\"=biggest reach any \
670 domain, \"intel\"=on-domain by popularity, \"adopters\"=actual users), or \
671 `rank_by`=relatedness|popularity|effort|recency with filters \
672 (`min_keywords`, `active_since`, `adopters_only`, `stack_only`) and \
673 `top`=N (rank-then-take-N, default 10) for a focused filter→rank→take \
674 view; with none, the full multi-lens browse: \
675 `✅ ADOPTERS` (stargazers whose repos actually declare your package as a \
676 dependency — real users, not just watchers), `★ MOST RELEVANT` \
677 (relatedness — repos matching your topic keywords, with follower counts \
678 and external contributions), `🏆 NOTABLE` (popularity/reach lens — your \
679 highest-traction stargazers, flagged `LEGEND` for big audiences/projects), \
680 `✦ QUALITY` (best-kept maintained projects), `⚙ STACK MATCH` (architectural \
681 peers who build in your stack — co-location-confirmed where possible), and \
682 a cohort inventory. Override the auto-config with `keywords=\"graph,rag,agent\"` \
683 (single words — \"knowledge,graph\" not \"knowledge-graph\") and \
684 `stack=\"Rust,Python\"`; re-calling with new values re-ranks the cached \
685 fetch for free. Treat description-based leads as candidates to verify by \
686 drilling. DRILL via `element_id`: `\"cohort:<key>\"` (established / single / \
687 prolific / casual / dormant / consumers — the overview lists each key), \
688 `\"user:<login>\"` (portfolio), `\"user:<login>/repo:<name>\"` (repo profile), \
689 or `\"user:<login>/repo:<name>/readme\"` (README gist — the only drill that \
690 costs a request). `max_stargazers` samples the most-recent N (the overview \
691 reports if results are partial); `refresh=true` re-fetches.",
692 move |args: ScreenStargazersArgs| {
693 use crate::screen::{self, Filters, RankBy, Seed, Selection};
694 let split_csv = |s: Option<String>| -> Vec<String> {
695 s.map(|v| {
696 v.split(',')
697 .map(|t| t.trim().to_string())
698 .filter(|t| !t.is_empty())
699 .collect()
700 })
701 .unwrap_or_default()
702 };
703 let seed = if let Some(u) = &args.users {
705 Seed::Users(split_csv(Some(u.clone())))
706 } else {
707 let repo =
708 match resolve_repo_from(repo_for_screen.as_ref(), args.repo.clone()) {
709 Ok(r) => r,
710 Err(msg) => return msg,
711 };
712 if let Some(err) = crate::git_refs::validate_repo(&repo) {
713 return err;
714 }
715 Seed::Repo(repo)
716 };
717 let cfg = screen::ScreenConfig {
718 max_stargazers: args.max_stargazers,
719 max_repos_per_user: 100,
720 relevance_keywords: split_csv(args.keywords)
721 .into_iter()
722 .map(|k| k.to_lowercase())
723 .collect(),
724 stack_languages: split_csv(args.stack),
725 };
726 let top = args.top.unwrap_or(10);
728 let filters = Filters {
729 min_keywords: args.min_keywords,
730 active_since: args.active_since.clone(),
731 adopters_only: args.adopters_only,
732 stack_only: args.stack_only,
733 ..Default::default()
734 };
735 let filters_active = filters.min_keywords.is_some()
736 || filters.active_since.is_some()
737 || filters.adopters_only
738 || filters.stack_only;
739 let selection: Option<Selection> = if let Some(name) = &args.preset {
740 screen::preset(name, top)
741 } else if args.rank_by.is_some() || filters_active {
742 Some(Selection {
743 filters,
744 rank: args
745 .rank_by
746 .as_deref()
747 .and_then(RankBy::parse)
748 .unwrap_or(RankBy::Relatedness),
749 label: "SELECTION".into(),
750 take: top,
751 })
752 } else {
753 None
754 };
755 screen::screen_dispatch(
756 &screen_store,
757 &seed,
758 &cfg,
759 selection.as_ref(),
760 args.element_id.as_deref(),
761 args.refresh,
762 )
763 },
764 );
765 }
766 }
767
768 pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
775 &self.options.builtins
776 }
777
778 pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
784 &mut self.tool_router
785 }
786
787 pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
792 &mut self.prompt_router
793 }
794
795 pub fn register_typed_tool<T, F>(
810 &mut self,
811 name: &'static str,
812 description: &'static str,
813 handler: F,
814 ) where
815 T: for<'de> serde::Deserialize<'de>
816 + schemars::JsonSchema
817 + Default
818 + Send
819 + Sync
820 + 'static,
821 F: Fn(T) -> String + Send + Sync + 'static,
822 {
823 use std::pin::Pin;
824 type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
825
826 let schema_obj = serde_json::to_value(schemars::schema_for!(T))
827 .ok()
828 .and_then(|v| v.as_object().cloned())
829 .unwrap_or_default();
830 let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
831 let handler = std::sync::Arc::new(handler);
832 let tool_name = name;
837 let postprocess = self.options.result_postprocess.clone();
838 let source_roots = self.options.source_roots.clone();
839 let workspace = self.options.workspace.clone();
840
841 self.tool_router
842 .add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
843 attr,
844 move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
845 -> DynFut<'_, Result<rmcp::model::CallToolResult, rmcp::ErrorData>> {
846 let handler = handler.clone();
847 let arguments = ctx.arguments.clone();
848 let postprocess = postprocess.clone();
849 let source_roots = source_roots.clone();
850 let workspace = workspace.clone();
851 Box::pin(async move {
852 let args_json = match &arguments {
855 Some(map) => serde_json::Value::Object(map.clone()),
856 None => serde_json::Value::Null,
857 };
858 let args: T = match arguments {
859 Some(map) => {
860 match serde_json::from_value(serde_json::Value::Object(map)) {
861 Ok(a) => a,
862 Err(e) => {
863 return Ok(rmcp::model::CallToolResult::success(vec![
864 rmcp::model::Content::text(format!(
865 "invalid arguments: {e}"
866 )),
867 ]));
868 }
869 }
870 }
871 None => T::default(),
872 };
873 let body = handler(args);
874 let body = match &postprocess {
875 Some(hook) => {
876 let ctx = ResultCtx {
877 source_roots: source_roots
878 .as_ref()
879 .map(|p| p())
880 .unwrap_or_default(),
881 active_repo: workspace
882 .as_ref()
883 .and_then(|w| w.active_repo_name()),
884 };
885 let footer = hook(tool_name, &args_json, &body, &ctx);
886 append_footer(body, footer)
887 }
888 None => body,
889 };
890 Ok(rmcp::model::CallToolResult::success(vec![
891 rmcp::model::Content::text(body),
892 ]))
893 })
894 },
895 ));
896 }
897
898 fn current_source_roots(&self) -> Vec<String> {
899 match &self.options.source_roots {
900 Some(provider) => provider(),
901 None => Vec::new(),
902 }
903 }
904
905 fn finish(&self, tool: &str, args: &serde_json::Value, body: String) -> String {
912 let Some(hook) = &self.options.result_postprocess else {
913 return body;
914 };
915 let ctx = ResultCtx {
916 source_roots: self.current_source_roots(),
917 active_repo: self
918 .options
919 .workspace
920 .as_ref()
921 .and_then(|w| w.active_repo_name()),
922 };
923 let footer = hook(tool, args, &body, &ctx);
924 append_footer(body, footer)
925 }
926
927 #[allow(dead_code)]
932 fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
933 resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
934 }
935
936 #[tool(
937 description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
938 Use to confirm the server framework is wired correctly before \
939 relying on graph- or source-aware tools."
940 )]
941 async fn ping(
942 &self,
943 Parameters(args): Parameters<PingArgs>,
944 ) -> Result<CallToolResult, McpError> {
945 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
946 let body = args.message.unwrap_or_else(|| "pong".to_string());
947 let body = self.finish("ping", &args_json, body);
948 Ok(CallToolResult::success(vec![Content::text(body)]))
949 }
950
951 #[tool(description = "Read a file from the configured source root(s). Pass \
952 `start_line`/`end_line` to slice, `grep` to filter to matching \
953 lines, `max_chars` to cap output. Pass `rev` (a tag, branch, or \
954 commit SHA) to read the file's content at that git revision via \
955 `git show` instead of the working tree — useful for comparing a \
956 file across releases (requires a git repo source root). Path \
957 traversal attempts are rejected. Available only when source roots \
958 are configured.")]
959 async fn read_source(
960 &self,
961 Parameters(args): Parameters<ReadSourceArgs>,
962 ) -> Result<CallToolResult, McpError> {
963 let roots = self.current_source_roots();
964 if roots.is_empty() {
965 return Ok(CallToolResult::success(vec![Content::text(
966 "Cannot read source: no active source root. Configure source_root in your manifest \
967 or activate one (e.g. via repo_management in workspace mode).",
968 )]));
969 }
970 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
971 let opts = ReadOpts {
972 start_line: args.start_line,
973 end_line: args.end_line,
974 grep: args.grep,
975 grep_context: args.grep_context,
976 max_matches: args.max_matches,
977 max_chars: args.max_chars,
978 rev: args.rev,
979 };
980 let body = source::read_source(&args.file_path, &roots, &opts);
981 let body = self.finish("read_source", &args_json, body);
982 Ok(CallToolResult::success(vec![Content::text(body)]))
983 }
984
985 #[tool(
986 description = "Search source files using ripgrep. `pattern` is a regex (Rust \
987 syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
988 N surrounding lines per match. Set `case_insensitive=true` for \
989 case-insensitive matching. `max_results` caps total matches \
990 (default 50)."
991 )]
992 async fn grep(
993 &self,
994 Parameters(args): Parameters<GrepArgs>,
995 ) -> Result<CallToolResult, McpError> {
996 let roots = self.current_source_roots();
997 if roots.is_empty() {
998 return Ok(CallToolResult::success(vec![Content::text(
999 "Cannot grep: no active source root. Configure source_root in your manifest \
1000 or activate one (e.g. via repo_management in workspace mode).",
1001 )]));
1002 }
1003 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1004 let opts = GrepOpts {
1005 glob: args.glob,
1006 context: args.context,
1007 max_results: Some(args.max_results.unwrap_or(50)),
1008 case_insensitive: args.case_insensitive,
1009 };
1010 let body = source::grep(&roots, &args.pattern, &opts);
1011 let body = self.finish("grep", &args_json, body);
1012 Ok(CallToolResult::success(vec![Content::text(body)]))
1013 }
1014
1015 #[tool(
1016 description = "List directory contents under the configured source root. `path` \
1017 is resolved against the first source root (\".\" lists the root \
1018 itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
1019 `glob` filters entry names. `dirs_only=true` shows only \
1020 directories."
1021 )]
1022 async fn list_source(
1023 &self,
1024 Parameters(args): Parameters<ListSourceArgs>,
1025 ) -> Result<CallToolResult, McpError> {
1026 let roots = self.current_source_roots();
1027 if roots.is_empty() {
1028 return Ok(CallToolResult::success(vec![Content::text(
1029 "Cannot list source: no active source root. Configure source_root in your \
1030 manifest or activate one (e.g. via repo_management in workspace mode).",
1031 )]));
1032 }
1033 let primary = std::path::PathBuf::from(&roots[0]);
1034 let target = match resolve_dir_under_roots(&args.path, &roots) {
1035 Some(p) => p,
1036 None => {
1037 return Ok(CallToolResult::success(vec![Content::text(format!(
1038 "Error: path '{}' resolves outside the configured source roots.",
1039 args.path
1040 ))]));
1041 }
1042 };
1043 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1044 let opts = ListOpts {
1045 depth: args.depth,
1046 glob: args.glob,
1047 dirs_only: args.dirs_only,
1048 };
1049 let body = source::list_source(&target, &primary, &opts);
1050 let body = self.finish("list_source", &args_json, body);
1051 Ok(CallToolResult::success(vec![Content::text(body)]))
1052 }
1053
1054 #[tool(
1055 description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
1056 clone (if missing) and activate it as the source root for \
1057 read_source / grep / list_source. Pass `delete=true` to remove a \
1058 repo. Pass `update=true` to fetch upstream changes for the active \
1059 repo (rebuild auto-skipped when HEAD hasn't moved since the last \
1060 build; set `force_rebuild=true` to bypass). Pass `revs` (an \
1061 integer N, or a list of git revspecs) to load multiple revisions \
1062 of the repo into one graph — N loads the last N release tags plus \
1063 HEAD; a revs request always rebuilds. Call with no \
1064 arguments to list all known repos with their last-access counts. \
1065 Idle repos auto-sweep on each call (default 7 days, configurable \
1066 via --stale-after-days)."
1067 )]
1068 async fn repo_management(
1069 &self,
1070 Parameters(args): Parameters<RepoManagementArgs>,
1071 ) -> Result<CallToolResult, McpError> {
1072 let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
1073 let body = match &self.options.workspace {
1074 Some(ws) => ws.repo_management(
1075 args.name.as_deref(),
1076 args.delete,
1077 args.update,
1078 args.force_rebuild,
1079 args.revs.as_ref(),
1080 ),
1081 None => "repo_management requires --workspace mode.".to_string(),
1082 };
1083 let body = self.finish("repo_management", &args_json, body);
1084 Ok(CallToolResult::success(vec![Content::text(body)]))
1085 }
1086}
1087
1088fn resolve_repo_from(
1096 default_repo: Option<&RepoProvider>,
1097 override_repo: Option<String>,
1098) -> Result<String, String> {
1099 if let Some(r) = override_repo {
1100 if let Some(err) = crate::git_refs::validate_repo(&r) {
1101 return Err(err);
1102 }
1103 return Ok(r);
1104 }
1105 if let Some(provider) = default_repo {
1106 if let Some(r) = provider() {
1107 if let Some(err) = crate::git_refs::validate_repo(&r) {
1108 return Err(err);
1109 }
1110 return Ok(r);
1111 }
1112 }
1113 if let Some(detected) = crate::github::detect_git_repo(".") {
1114 if crate::git_refs::validate_repo(&detected).is_none() {
1115 return Ok(detected);
1116 }
1117 }
1118 Err(
1119 "No active repository. Pass `repo_name='org/repo'`, configure a default in the \
1120 server, or run from a directory whose git remote points at github.com."
1121 .to_string(),
1122 )
1123}
1124
1125pub fn serve_prompts(registry: &ResolvedRegistry, server: &mut McpServer) {
1139 use std::borrow::Cow;
1140 use std::collections::HashSet;
1141
1142 let registered_tools: HashSet<String> = server
1147 .tool_router
1148 .list_all()
1149 .iter()
1150 .map(|t| t.name.to_string())
1151 .collect();
1152 let extensions = server.options.extensions.clone();
1153
1154 struct InjectSkill {
1160 name: String,
1161 description: String,
1162 body: String,
1163 references_tools: Vec<String>,
1164 }
1165 let mut auto_inject: Vec<InjectSkill> = Vec::new();
1166
1167 for name in registry.skill_names() {
1168 let Some(skill) = registry.get(&name) else {
1169 continue;
1170 };
1171
1172 let activation = registry.activation_for(skill, ®istered_tools, &extensions);
1176 if !activation.active {
1177 let failed_clauses: Vec<&str> = activation
1178 .clauses
1179 .iter()
1180 .filter(|(_, outcome)| {
1181 *outcome != crate::server::skills::PredicateOutcome::Satisfied
1182 })
1183 .map(|(clause, _)| clause.as_str())
1184 .collect();
1185 tracing::info!(
1186 skill = %name,
1187 suppressed_by = ?failed_clauses,
1188 "skill suppressed by applies_when predicates"
1189 );
1190 continue;
1191 }
1192
1193 let prompt = Prompt::new(
1194 skill.name().to_string(),
1195 Some(skill.description().to_string()),
1196 None,
1197 );
1198 let body = skill.body.clone();
1199 let route = PromptRoute::new_dyn(prompt, move |_ctx| {
1200 let body = body.clone();
1201 Box::pin(async move {
1202 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
1203 PromptMessageRole::Assistant,
1204 body,
1205 )]))
1206 })
1207 });
1208 server.prompt_router.add_route(route);
1209
1210 if skill.frontmatter.auto_inject_hint {
1211 auto_inject.push(InjectSkill {
1212 name: skill.name().to_string(),
1213 description: skill.description().to_string(),
1214 body: skill.body.clone(),
1215 references_tools: skill.frontmatter.references_tools.clone(),
1216 });
1217 }
1218 }
1219
1220 for inj in &auto_inject {
1256 let mut targets: Vec<&str> = Vec::new();
1259 let mut seen: HashSet<&str> = HashSet::new();
1260 for tool in std::iter::once(inj.name.as_str())
1261 .chain(inj.references_tools.iter().map(String::as_str))
1262 {
1263 if seen.insert(tool) {
1264 targets.push(tool);
1265 }
1266 }
1267
1268 let marker = format!("<!-- mcp-skill:{} -->", inj.name);
1271 let mut block = format!("\n\n{marker}");
1272 let description = inj.description.trim();
1273 if !description.is_empty() {
1274 block.push_str("\n\n## When to use\n\n");
1275 block.push_str(description);
1276 }
1277 block.push_str("\n\n## Methodology\n\n");
1278 block.push_str(inj.body.trim());
1279
1280 for tool in targets {
1281 let key = Cow::<'static, str>::Owned(tool.to_string());
1282 let Some(route) = server.tool_router.map.get_mut(&key) else {
1283 continue;
1284 };
1285 if route
1288 .attr
1289 .description
1290 .as_deref()
1291 .is_some_and(|d| d.contains(&marker))
1292 {
1293 continue;
1294 }
1295 let new_desc = match route.attr.description.take() {
1296 Some(existing) => format!("{existing}{block}"),
1297 None => block.trim_start().to_string(),
1298 };
1299 route.attr.description = Some(Cow::Owned(new_desc));
1300 }
1301 }
1302}
1303
1304#[tool_handler(router = self.tool_router)]
1305impl ServerHandler for McpServer {
1306 fn get_info(&self) -> ServerInfo {
1307 let name = self
1308 .options
1309 .name
1310 .clone()
1311 .unwrap_or_else(|| "MCP Server".to_string());
1312 let mut caps = ServerCapabilities::builder().enable_tools().build();
1319 if !self.prompt_router.map.is_empty() {
1320 caps.prompts = Some(PromptsCapability::default());
1321 }
1322 let mut info = ServerInfo::new(caps)
1323 .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
1324 .with_protocol_version(ProtocolVersion::V_2024_11_05);
1325 if let Some(text) = &self.options.instructions {
1326 info = info.with_instructions(text.clone());
1327 }
1328 info
1329 }
1330
1331 async fn list_prompts(
1332 &self,
1333 _request: Option<PaginatedRequestParams>,
1334 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
1335 ) -> Result<ListPromptsResult, McpError> {
1336 Ok(ListPromptsResult {
1337 meta: None,
1338 next_cursor: None,
1339 prompts: self.prompt_router.list_all(),
1340 })
1341 }
1342
1343 async fn get_prompt(
1344 &self,
1345 request: GetPromptRequestParams,
1346 context: rmcp::service::RequestContext<rmcp::RoleServer>,
1347 ) -> Result<GetPromptResult, McpError> {
1348 let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
1349 self,
1350 request.name,
1351 request.arguments,
1352 context,
1353 );
1354 self.prompt_router.get_prompt(prompt_context).await
1355 }
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360 use super::*;
1361
1362 #[test]
1363 fn options_from_manifest_uses_name_when_set() {
1364 let opts = ServerOptions::from_manifest(None, "Fallback");
1365 assert_eq!(opts.name.as_deref(), Some("Fallback"));
1366 }
1367
1368 #[test]
1369 fn builtins_exposed_via_server() {
1370 use crate::server::manifest::{BuiltinsConfig, TempCleanup};
1371 let opts = ServerOptions {
1372 builtins: BuiltinsConfig {
1373 save_graph: true,
1374 temp_cleanup: TempCleanup::OnOverview,
1375 ..Default::default()
1376 },
1377 ..ServerOptions::default()
1378 };
1379 let server = McpServer::new(opts);
1380 assert!(server.builtins().save_graph);
1381 assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
1382 }
1383
1384 #[test]
1385 fn server_constructs() {
1386 let _server = McpServer::new(ServerOptions::default());
1387 }
1388
1389 #[test]
1390 fn static_source_roots_provider() {
1391 let opts = ServerOptions::default()
1392 .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
1393 let server = McpServer::new(opts);
1394 assert_eq!(
1395 server.current_source_roots(),
1396 vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
1397 );
1398 }
1399
1400 #[test]
1401 fn no_provider_returns_empty_roots() {
1402 let server = McpServer::new(ServerOptions::default());
1403 assert!(server.current_source_roots().is_empty());
1404 }
1405
1406 #[test]
1407 fn repo_management_gated_to_workspace_mode() {
1408 let server = McpServer::new(ServerOptions::default());
1411 let tools = server.tool_router.list_all();
1412 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1413 assert!(
1414 !names.contains(&"repo_management"),
1415 "repo_management should be gated out without a workspace; tools were {names:?}"
1416 );
1417 }
1418
1419 #[test]
1420 fn repo_management_present_when_workspace_bound() {
1421 use crate::server::workspace::Workspace;
1424 let dir = tempfile::tempdir().unwrap();
1425 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1426 let opts = ServerOptions::default().with_workspace(ws);
1427 let server = McpServer::new(opts);
1428 let tools = server.tool_router.list_all();
1429 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1430 assert!(
1431 names.contains(&"repo_management"),
1432 "repo_management should be registered with a workspace; tools were {names:?}"
1433 );
1434 }
1435
1436 #[test]
1437 fn result_postprocess_appends_footer_and_sees_ctx() {
1438 use std::sync::Mutex;
1439 type Seen = Option<(String, serde_json::Value, String, Vec<String>)>;
1441 let seen: Arc<Mutex<Seen>> = Arc::new(Mutex::new(None));
1442 let seen_c = seen.clone();
1443 let hook: ResultPostprocessHook = Arc::new(move |tool, args, body, ctx| {
1444 *seen_c.lock().unwrap() = Some((
1445 tool.to_string(),
1446 args.clone(),
1447 body.to_string(),
1448 ctx.source_roots.clone(),
1449 ));
1450 if tool == "grep" {
1452 Some("↳ prefer cypher_query".to_string())
1453 } else {
1454 None
1455 }
1456 });
1457 let opts = ServerOptions::default()
1458 .with_static_source_roots(vec!["/src".to_string()])
1459 .with_result_postprocess(hook);
1460 let server = McpServer::new(opts);
1461
1462 let args = serde_json::json!({ "pattern": "^fn " });
1463 let out = server.finish("grep", &args, "match line".to_string());
1464 assert_eq!(out, "match line\n\n↳ prefer cypher_query");
1465
1466 let rec = seen.lock().unwrap().clone().unwrap();
1467 assert_eq!(rec.0, "grep");
1468 assert_eq!(rec.1, args);
1469 assert_eq!(rec.2, "match line");
1470 assert_eq!(rec.3, vec!["/src".to_string()]);
1471
1472 let out2 = server.finish("read_source", &args, "file body".to_string());
1474 assert_eq!(out2, "file body");
1475 }
1476
1477 #[test]
1478 fn no_result_postprocess_leaves_body_unchanged() {
1479 let server = McpServer::new(ServerOptions::default());
1480 let out = server.finish("grep", &serde_json::Value::Null, "x".to_string());
1481 assert_eq!(out, "x");
1482 }
1483
1484 #[test]
1485 fn append_footer_ignores_empty_footers() {
1486 assert_eq!(append_footer("a".to_string(), None), "a");
1487 assert_eq!(append_footer("a".to_string(), Some(String::new())), "a");
1488 assert_eq!(
1489 append_footer("a".to_string(), Some("b".to_string())),
1490 "a\n\nb"
1491 );
1492 }
1493
1494 #[test]
1495 fn dynamic_provider_swaps_at_call_time() {
1496 use std::sync::Mutex;
1497 let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
1498 let s2 = state.clone();
1499 let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
1500 let opts = ServerOptions::default().with_dynamic_source_roots(provider);
1501 let server = McpServer::new(opts);
1502 assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
1503 *state.lock().unwrap() = vec!["/swapped".to_string()];
1504 assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
1505 }
1506
1507 fn build_test_registry(
1510 skills: &[(&str, &str, &str, bool)],
1511 ) -> crate::server::skills::ResolvedRegistry {
1512 use crate::server::skills::Registry;
1513 let dir = tempfile::tempdir().unwrap();
1514 let yaml_path = dir.path().join("manifest.yaml");
1515 let skills_dir = dir.path().join("manifest.skills");
1516 std::fs::create_dir_all(&skills_dir).unwrap();
1517 for (name, description, body, auto_inject) in skills {
1518 let auto = if *auto_inject { "true" } else { "false" };
1519 let content = format!(
1520 "---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
1521 );
1522 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1523 }
1524 Registry::new()
1525 .auto_detect_project_layer(&yaml_path)
1526 .finalise()
1527 .unwrap()
1528 }
1529
1530 fn build_registry_with_refs(
1535 skills: &[(&str, &str, &str, &str)],
1536 ) -> crate::server::skills::ResolvedRegistry {
1537 use crate::server::skills::Registry;
1538 let dir = tempfile::tempdir().unwrap();
1539 let yaml_path = dir.path().join("manifest.yaml");
1540 let skills_dir = dir.path().join("manifest.skills");
1541 std::fs::create_dir_all(&skills_dir).unwrap();
1542 for (name, description, body, references_tools) in skills {
1543 let content = format!(
1544 "---\nname: {name}\ndescription: {description}\n\
1545 auto_inject_hint: true\nreferences_tools: {references_tools}\n---\n\n{body}\n"
1546 );
1547 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1548 }
1549 Registry::new()
1550 .auto_detect_project_layer(&yaml_path)
1551 .finalise()
1552 .unwrap()
1553 }
1554
1555 fn tool_desc(server: &McpServer, tool: &str) -> String {
1556 server
1557 .tool_router
1558 .get(tool)
1559 .and_then(|t| t.description.clone())
1560 .map(|c| c.into_owned())
1561 .unwrap_or_default()
1562 }
1563
1564 #[test]
1565 fn prompt_router_empty_by_default() {
1566 let server = McpServer::new(ServerOptions::default());
1567 assert!(server.prompt_router.map.is_empty());
1568 }
1569
1570 #[test]
1571 fn get_info_no_prompts_capability_when_empty() {
1572 let server = McpServer::new(ServerOptions::default());
1576 let info = server.get_info();
1577 assert!(
1578 info.capabilities.prompts.is_none(),
1579 "prompts capability must be absent when no skills are registered"
1580 );
1581 }
1582
1583 #[test]
1584 fn serve_prompts_registers_routes_with_metadata() {
1585 let registry = build_test_registry(&[
1586 ("alpha", "First skill.", "Alpha body.", true),
1587 ("beta", "Second skill.", "Beta body.", true),
1588 ]);
1589 let mut server = McpServer::new(ServerOptions::default());
1590 super::serve_prompts(®istry, &mut server);
1591
1592 let prompts = server.prompt_router.list_all();
1593 let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
1594 assert_eq!(names, vec!["alpha", "beta"]);
1595
1596 let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
1597 assert_eq!(alpha.description.as_deref(), Some("First skill."));
1598 assert!(alpha.arguments.is_none());
1599 }
1600
1601 #[test]
1602 fn serve_prompts_empty_registry_is_noop() {
1603 let registry = crate::server::skills::ResolvedRegistry::default();
1604 let mut server = McpServer::new(ServerOptions::default());
1605 super::serve_prompts(®istry, &mut server);
1606 assert!(server.prompt_router.map.is_empty());
1607 assert!(server.get_info().capabilities.prompts.is_none());
1608 }
1609
1610 #[test]
1611 fn get_info_advertises_prompts_when_present() {
1612 let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
1613 let mut server = McpServer::new(ServerOptions::default());
1614 super::serve_prompts(®istry, &mut server);
1615 let info = server.get_info();
1616 assert!(
1617 info.capabilities.prompts.is_some(),
1618 "prompts capability must be advertised once a skill is registered"
1619 );
1620 }
1621
1622 #[test]
1623 fn serve_prompts_auto_injects_full_body_into_matching_tool() {
1624 let registry =
1632 build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
1633 let mut server = McpServer::new(ServerOptions::default());
1634 let before = server
1635 .tool_router
1636 .get("ping")
1637 .and_then(|t| t.description.clone())
1638 .map(|c| c.into_owned())
1639 .unwrap_or_default();
1640 super::serve_prompts(®istry, &mut server);
1641 let after = server
1642 .tool_router
1643 .get("ping")
1644 .and_then(|t| t.description.clone())
1645 .map(|c| c.into_owned())
1646 .unwrap_or_default();
1647 assert!(after.starts_with(&before), "original description preserved");
1648 assert!(
1649 after.contains("## Methodology"),
1650 "inject should include a Methodology header; got: {after}"
1651 );
1652 assert!(
1653 after.contains("PING-BODY-SENTINEL"),
1654 "inject should embed the full skill body; got: {after}"
1655 );
1656 assert!(
1657 !after.contains("prompts/get"),
1658 "post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
1659 );
1660 }
1661
1662 #[test]
1663 fn serve_prompts_skips_injection_when_disabled() {
1664 let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
1665 let mut server = McpServer::new(ServerOptions::default());
1666 let before = server
1667 .tool_router
1668 .get("ping")
1669 .and_then(|t| t.description.clone())
1670 .map(|c| c.into_owned())
1671 .unwrap_or_default();
1672 super::serve_prompts(®istry, &mut server);
1673 let after = server
1674 .tool_router
1675 .get("ping")
1676 .and_then(|t| t.description.clone())
1677 .map(|c| c.into_owned())
1678 .unwrap_or_default();
1679 assert_eq!(
1680 before, after,
1681 "auto_inject_hint=false must leave tool description untouched"
1682 );
1683 }
1684
1685 #[test]
1686 fn serve_prompts_skips_injection_when_no_matching_tool() {
1687 let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
1690 let mut server = McpServer::new(ServerOptions::default());
1691 super::serve_prompts(®istry, &mut server);
1692 assert!(server.prompt_router.map.contains_key("no_such_tool"));
1693 let ping_desc = server
1696 .tool_router
1697 .get("ping")
1698 .and_then(|t| t.description.clone())
1699 .map(|c| c.into_owned())
1700 .unwrap_or_default();
1701 assert!(!ping_desc.contains("no_such_tool"));
1702 }
1703
1704 #[test]
1705 fn serve_prompts_injects_description_under_when_to_use() {
1706 let registry = build_test_registry(&[("ping", "ROUTING-SENTINEL", "BODY-SENTINEL", true)]);
1710 let mut server = McpServer::new(ServerOptions::default());
1711 super::serve_prompts(®istry, &mut server);
1712 let desc = tool_desc(&server, "ping");
1713 assert!(
1714 desc.contains("## When to use\n\nROUTING-SENTINEL"),
1715 "description should be injected under `## When to use`; got: {desc}"
1716 );
1717 assert!(
1718 desc.contains("<!-- mcp-skill:ping -->"),
1719 "injection should carry the per-skill idempotency marker; got: {desc}"
1720 );
1721 let when = desc.find("## When to use").unwrap();
1723 let method = desc.find("## Methodology").unwrap();
1724 assert!(when < method, "`When to use` must precede `Methodology`");
1725 }
1726
1727 #[test]
1728 fn serve_prompts_honors_references_tools() {
1729 let registry = build_registry_with_refs(&[(
1733 "graph_strategy",
1734 "Map structure first.",
1735 "GRAPH-BODY-SENTINEL",
1736 "[ping]",
1737 )]);
1738 let mut server = McpServer::new(ServerOptions::default());
1739 super::serve_prompts(®istry, &mut server);
1740 assert!(server.prompt_router.map.contains_key("graph_strategy"));
1742 let desc = tool_desc(&server, "ping");
1744 assert!(
1745 desc.contains("<!-- mcp-skill:graph_strategy -->"),
1746 "referenced tool should carry the skill marker; got: {desc}"
1747 );
1748 assert!(
1749 desc.contains("Map structure first."),
1750 "referenced tool should carry the skill routing; got: {desc}"
1751 );
1752 assert!(
1753 desc.contains("GRAPH-BODY-SENTINEL"),
1754 "referenced tool should carry the skill body; got: {desc}"
1755 );
1756 }
1757
1758 #[test]
1759 fn serve_prompts_idempotent_when_skill_self_references() {
1760 let registry = build_registry_with_refs(&[("ping", "Routing.", "Body.", "[ping]")]);
1764 let mut server = McpServer::new(ServerOptions::default());
1765 super::serve_prompts(®istry, &mut server);
1766 let desc = tool_desc(&server, "ping");
1767 let marker_count = desc.matches("<!-- mcp-skill:ping -->").count();
1768 assert_eq!(
1769 marker_count, 1,
1770 "self-referencing skill must inject exactly once; got {marker_count}: {desc}"
1771 );
1772 }
1773
1774 #[test]
1775 fn serve_prompts_idempotent_across_repeated_passes() {
1776 let registry = build_test_registry(&[("ping", "Routing.", "Body.", true)]);
1779 let mut server = McpServer::new(ServerOptions::default());
1780 super::serve_prompts(®istry, &mut server);
1781 let once = tool_desc(&server, "ping");
1782 super::serve_prompts(®istry, &mut server);
1783 let twice = tool_desc(&server, "ping");
1784 assert_eq!(
1785 once, twice,
1786 "second pass must be a no-op for an already-injected tool"
1787 );
1788 }
1789
1790 #[test]
1791 fn serve_prompts_multiple_skills_stack_on_one_tool() {
1792 let registry = build_registry_with_refs(&[
1796 ("ping", "Ping routing.", "PING-BODY", "[]"),
1797 ("ping_strategy", "Strategy routing.", "STRAT-BODY", "[ping]"),
1798 ]);
1799 let mut server = McpServer::new(ServerOptions::default());
1800 super::serve_prompts(®istry, &mut server);
1801 let desc = tool_desc(&server, "ping");
1802 assert!(desc.contains("<!-- mcp-skill:ping -->"), "got: {desc}");
1803 assert!(
1804 desc.contains("<!-- mcp-skill:ping_strategy -->"),
1805 "got: {desc}"
1806 );
1807 assert!(
1808 desc.contains("PING-BODY") && desc.contains("STRAT-BODY"),
1809 "got: {desc}"
1810 );
1811 }
1812
1813 fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
1814 let dir = tempfile::tempdir().unwrap();
1815 let yaml = dir.path().join("test_mcp.yaml");
1816 std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1817 let skills_dir = dir.path().join("test_mcp.skills");
1818 std::fs::create_dir(&skills_dir).unwrap();
1819 std::fs::write(
1820 skills_dir.join("gated_skill.md"),
1821 format!(
1822 "---\n\
1823 name: gated_skill\n\
1824 description: A predicate-gated skill for testing.\n\
1825 applies_when:\n\
1826 {applies_when_yaml}\n\
1827 ---\n\n\
1828 Body.\n",
1829 ),
1830 )
1831 .unwrap();
1832 dir
1833 }
1834
1835 #[test]
1836 fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
1837 use crate::server::skills::Registry as SkillsBuilder;
1841 let dir = write_gated_project_skill(" tool_registered: nonexistent_tool");
1842 let yaml = dir.path().join("test_mcp.yaml");
1843 let registry = SkillsBuilder::new()
1844 .auto_detect_project_layer(&yaml)
1845 .finalise()
1846 .unwrap();
1847 let mut server = McpServer::new(ServerOptions::default());
1848 super::serve_prompts(®istry, &mut server);
1849 assert!(
1850 !server.prompt_router.map.contains_key("gated_skill"),
1851 "skill with unsatisfied predicate must be suppressed"
1852 );
1853 }
1854
1855 #[test]
1856 fn serve_prompts_keeps_skill_with_satisfied_predicate() {
1857 use crate::server::skills::Registry as SkillsBuilder;
1860 let dir = write_gated_project_skill(" tool_registered: ping");
1861 let yaml = dir.path().join("test_mcp.yaml");
1862 let registry = SkillsBuilder::new()
1863 .auto_detect_project_layer(&yaml)
1864 .finalise()
1865 .unwrap();
1866 let mut server = McpServer::new(ServerOptions::default());
1867 super::serve_prompts(®istry, &mut server);
1868 assert!(
1869 server.prompt_router.map.contains_key("gated_skill"),
1870 "skill with satisfied predicate must register"
1871 );
1872 }
1873
1874 #[test]
1875 fn serve_prompts_evaluates_extension_enabled_from_manifest() {
1876 use crate::server::skills::Registry as SkillsBuilder;
1880 let dir = write_gated_project_skill(" extension_enabled: csv_http_server");
1881 let yaml = dir.path().join("test_mcp.yaml");
1882 let registry = SkillsBuilder::new()
1883 .auto_detect_project_layer(&yaml)
1884 .finalise()
1885 .unwrap();
1886
1887 let mut server = McpServer::new(ServerOptions::default());
1889 super::serve_prompts(®istry, &mut server);
1890 assert!(!server.prompt_router.map.contains_key("gated_skill"));
1891
1892 let mut extensions = serde_json::Map::new();
1894 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1895 let opts = ServerOptions {
1896 extensions,
1897 ..ServerOptions::default()
1898 };
1899 let mut server = McpServer::new(opts);
1900 super::serve_prompts(®istry, &mut server);
1901 assert!(server.prompt_router.map.contains_key("gated_skill"));
1902 }
1903}