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
54#[derive(Clone, Default)]
56pub struct ServerOptions {
57 pub name: Option<String>,
59 pub instructions: Option<String>,
61 pub source_roots: Option<SourceRootsProvider>,
64 pub default_repo: Option<RepoProvider>,
67 pub workspace: Option<crate::server::workspace::Workspace>,
69 pub builtins: crate::server::manifest::BuiltinsConfig,
74 pub extensions: serde_json::Map<String, serde_json::Value>,
79}
80
81impl std::fmt::Debug for ServerOptions {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct("ServerOptions")
84 .field("name", &self.name)
85 .field("instructions", &self.instructions)
86 .field(
87 "source_roots",
88 &self.source_roots.as_ref().map(|_| "<provider>"),
89 )
90 .field(
91 "default_repo",
92 &self.default_repo.as_ref().map(|_| "<provider>"),
93 )
94 .finish()
95 }
96}
97
98impl ServerOptions {
99 pub fn from_manifest(manifest: Option<&Manifest>, fallback_name: &str) -> Self {
100 Self {
101 name: manifest
102 .and_then(|m| m.name.clone())
103 .or_else(|| Some(fallback_name.to_string())),
104 instructions: manifest.and_then(|m| m.instructions.clone()),
105 source_roots: None,
106 default_repo: None,
107 workspace: None,
108 builtins: manifest.map(|m| m.builtins.clone()).unwrap_or_default(),
109 extensions: manifest.map(|m| m.extensions.clone()).unwrap_or_default(),
110 }
111 }
112
113 pub fn with_static_source_roots(mut self, roots: Vec<String>) -> Self {
114 let captured = Arc::new(roots);
115 self.source_roots = Some(Arc::new(move || captured.as_ref().clone()));
116 self
117 }
118
119 pub fn with_dynamic_source_roots(mut self, provider: SourceRootsProvider) -> Self {
120 self.source_roots = Some(provider);
121 self
122 }
123
124 pub fn with_static_repo(mut self, repo: String) -> Self {
125 self.default_repo = Some(Arc::new(move || Some(repo.clone())));
126 self
127 }
128
129 pub fn with_dynamic_repo(mut self, provider: RepoProvider) -> Self {
130 self.default_repo = Some(provider);
131 self
132 }
133
134 pub fn with_workspace(mut self, ws: crate::server::workspace::Workspace) -> Self {
139 let ws_for_roots = ws.clone();
140 let ws_for_repo = ws.clone();
141 self.workspace = Some(ws);
142 self.source_roots = Some(Arc::new(move || {
143 ws_for_roots
144 .active_repo_path()
145 .map(|p| vec![p.to_string_lossy().into_owned()])
146 .unwrap_or_default()
147 }));
148 self.default_repo = Some(Arc::new(move || ws_for_repo.default_github_repo()));
149 self
150 }
151}
152
153#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
154pub struct PingArgs {
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub message: Option<String>,
158}
159
160#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
161pub struct ReadSourceArgs {
162 pub file_path: String,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub start_line: Option<usize>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub end_line: Option<usize>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub grep: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub grep_context: Option<usize>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub max_matches: Option<usize>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub max_chars: Option<usize>,
182}
183
184#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
185pub struct GrepArgs {
186 pub pattern: String,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub glob: Option<String>,
191 #[serde(default)]
193 pub context: usize,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub max_results: Option<usize>,
197 #[serde(default)]
199 pub case_insensitive: bool,
200}
201
202#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
203pub struct SetRootDirArgs {
204 pub path: String,
206}
207
208#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
209pub struct RepoManagementArgs {
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub name: Option<String>,
213 #[serde(default)]
215 pub delete: bool,
216 #[serde(default)]
218 pub update: bool,
219 #[serde(default)]
223 pub force_rebuild: bool,
224}
225
226#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
227pub struct GithubIssuesArgs {
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub number: Option<u64>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub repo_name: Option<String>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub query: Option<String>,
237 #[serde(default = "default_kind")]
239 pub kind: String,
240 #[serde(default = "default_state")]
242 pub state: String,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub sort: Option<String>,
246 #[serde(default = "default_limit")]
248 pub limit: usize,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub labels: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub element_id: Option<String>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub lines: Option<String>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub grep: Option<String>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub context: Option<usize>,
271 #[serde(default)]
274 pub refresh: bool,
275}
276
277fn default_kind() -> String {
278 "all".to_string()
279}
280fn default_state() -> String {
281 "open".to_string()
282}
283fn default_limit() -> usize {
284 20
285}
286
287impl Default for GithubIssuesArgs {
288 fn default() -> Self {
289 Self {
290 number: None,
291 repo_name: None,
292 query: None,
293 kind: default_kind(),
294 state: default_state(),
295 sort: None,
296 limit: default_limit(),
297 labels: None,
298 element_id: None,
299 lines: None,
300 grep: None,
301 context: None,
302 refresh: false,
303 }
304 }
305}
306
307#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
308pub struct GithubApiArgs {
309 pub path: String,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub repo_name: Option<String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub truncate_at: Option<usize>,
322}
323
324#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
325pub struct ListSourceArgs {
326 #[serde(default = "default_path")]
328 pub path: String,
329 #[serde(default = "default_depth")]
331 pub depth: usize,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub glob: Option<String>,
335 #[serde(default)]
337 pub dirs_only: bool,
338}
339
340fn default_path() -> String {
341 ".".to_string()
342}
343fn default_depth() -> usize {
344 1
345}
346
347#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
348pub struct ScreenStargazersArgs {
349 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub repo: Option<String>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub users: Option<String>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub preset: Option<String>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub rank_by: Option<String>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub top: Option<usize>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub min_keywords: Option<usize>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub active_since: Option<String>,
373 #[serde(default)]
375 pub adopters_only: bool,
376 #[serde(default)]
378 pub stack_only: bool,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub keywords: Option<String>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub stack: Option<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub max_stargazers: Option<usize>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub element_id: Option<String>,
398 #[serde(default)]
400 pub refresh: bool,
401}
402
403#[derive(Clone)]
408pub struct McpServer {
409 options: ServerOptions,
410 tool_router: ToolRouter<McpServer>,
411 prompt_router: PromptRouter<McpServer>,
416}
417
418#[tool_router]
419impl McpServer {
420 pub fn new(options: ServerOptions) -> Self {
421 let mut server = Self {
422 options,
423 tool_router: Self::tool_router(),
424 prompt_router: PromptRouter::new(),
425 };
426 server.register_github_tools_if_authorized();
427 server.register_local_workspace_tools();
428 server.gate_workspace_tools();
429 server
430 }
431
432 fn gate_workspace_tools(&mut self) {
440 if self.options.workspace.is_none() {
441 self.tool_router.remove_route("repo_management");
442 }
443 }
444
445 fn register_local_workspace_tools(&mut self) {
449 let Some(ws) = self.options.workspace.clone() else {
450 return;
451 };
452 if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
453 return;
454 }
455 self.register_typed_tool::<SetRootDirArgs, _>(
456 "set_root_dir",
457 "Swap the active source root (local-workspace mode only). Pass `path` \
458 to a directory; the framework canonicalises it, rebinds the source \
459 tools (`read_source`, `grep`, `list_source`), and fires the post-\
460 activate hook so any downstream graph rebuilds against the new root. \
461 Inventory persists across swaps; SHA-gating skips rebuilds when the \
462 same root is re-bound with no content changes.",
463 move |args: SetRootDirArgs| {
464 let p = std::path::PathBuf::from(&args.path);
465 ws.set_root_dir(&p)
466 },
467 );
468 }
469
470 fn register_github_tools_if_authorized(&mut self) {
476 if !crate::github::has_git_token() {
477 tracing::info!(
478 "GITHUB_TOKEN not set — github_issues / github_api tools hidden from the agent. \
479 Set the env var and restart to enable them."
480 );
481 return;
482 }
483 let default_repo = self.options.default_repo.clone();
484 let repo_provider = default_repo.clone();
485 let cache: Arc<Mutex<crate::cache::ElementCache>> =
491 Arc::new(Mutex::new(crate::cache::ElementCache::new()));
492 let cache_for_issues = cache.clone();
493 self.register_typed_tool::<GithubIssuesArgs, _>(
494 "github_issues",
495 "Search, list, or fetch GitHub issues / pull requests / Discussions. \
496 Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
497 for SEARCH (across issues+PRs and Discussions); neither for LIST. \
498 `kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
499 `state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
500 result count (default 20). `labels` is a comma-separated string. \
501 `repo_name=\"org/repo\"` overrides the active repo for one call. \
502 FETCH responses collapse big code blocks / patches / comments into \
503 `cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
504 `element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
505 element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
506 `refresh=true` bypasses the cache for re-fetch.",
507 move |args: GithubIssuesArgs| {
508 let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
509 Ok(r) => r,
510 Err(msg) => return msg,
511 };
512 if let Some(number) = args.number {
518 let context = args.context.unwrap_or(3);
519 let mut guard = cache_for_issues.lock().unwrap();
520 return guard.fetch_issue(
521 &repo,
522 number,
523 args.element_id.as_deref(),
524 args.lines.as_deref(),
525 args.grep.as_deref(),
526 context,
527 args.refresh,
528 );
529 }
530 if args.element_id.is_some() {
531 return "element_id requires `number=N` (the issue/PR being drilled into)."
532 .to_string();
533 }
534 crate::github::github_issues_rust(
536 Some(&repo),
537 args.number,
538 args.query.as_deref(),
539 &args.kind,
540 &args.state,
541 args.sort.as_deref(),
542 args.limit,
543 args.labels.as_deref(),
544 )
545 },
546 );
547 let repo_provider = default_repo.clone();
548 let repo_for_screen = default_repo;
549 self.register_typed_tool::<GithubApiArgs, _>(
550 "github_api",
551 "Read-only GET against the GitHub REST API. `path` may be a \
552 repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
553 \"branches\", \"compare/main...feature\") which is auto-prefixed \
554 with /repos/<repo_name>/, or a top-level resource (\"search/issues?q=...\", \
555 \"users/octocat\", \"repos/owner/name\") which passes through. A \
556 leading slash is optional and accepted on either form. Returns \
557 JSON, truncated at 80 KB by default.",
558 move |args: GithubApiArgs| match resolve_repo_from(
559 repo_provider.as_ref(),
560 args.repo_name.clone(),
561 ) {
562 Ok(repo) => {
563 let truncate_at = args.truncate_at.unwrap_or(80_000);
564 crate::github::git_api_internal(&repo, &args.path, truncate_at)
565 }
566 Err(msg) => msg,
567 },
568 );
569
570 if self.options.builtins.screen_stargazers {
578 let screen_store: Arc<Mutex<crate::screen::ScreenStore>> =
579 Arc::new(Mutex::new(crate::screen::ScreenStore::new()));
580 self.register_typed_tool::<ScreenStargazersArgs, _>(
581 "screen_stargazers",
582 "Screen the people around a GitHub project to find relevant developers, \
583 notable/legendary devs, architectural peers, and actual users — cheaply. \
584 Seed on a repo (`repo=\"owner/repo\"` → screens its stargazers) OR an \
585 explicit user list (`users=\"alice,bob\"` → screens them directly). With \
586 just a repo it auto-derives relevance keywords + tech stack from the repo \
587 itself, bulk-fetches each person's public repo portfolio over plain REST \
588 (~1 request per person, no GraphQL, no READMEs), classifies them, and \
589 enriches a bounded shortlist with follower counts, dependency-adoption, \
590 stack co-location, and contributions. Every person gets a normalized \
591 0–100 score vector on four axes — relatedness, popularity, effort, \
592 recency. RANK/FILTER: pass a `preset` (\"outreach\"=relevant+active by \
593 reach, \"peers\"=your stack by effort, \"legends\"=biggest reach any \
594 domain, \"intel\"=on-domain by popularity, \"adopters\"=actual users), or \
595 `rank_by`=relatedness|popularity|effort|recency with filters \
596 (`min_keywords`, `active_since`, `adopters_only`, `stack_only`) and \
597 `top`=N (rank-then-take-N, default 10) for a focused filter→rank→take \
598 view; with none, the full multi-lens browse: \
599 `✅ ADOPTERS` (stargazers whose repos actually declare your package as a \
600 dependency — real users, not just watchers), `★ MOST RELEVANT` \
601 (relatedness — repos matching your topic keywords, with follower counts \
602 and external contributions), `🏆 NOTABLE` (popularity/reach lens — your \
603 highest-traction stargazers, flagged `LEGEND` for big audiences/projects), \
604 `✦ QUALITY` (best-kept maintained projects), `⚙ STACK MATCH` (architectural \
605 peers who build in your stack — co-location-confirmed where possible), and \
606 a cohort inventory. Override the auto-config with `keywords=\"graph,rag,agent\"` \
607 (single words — \"knowledge,graph\" not \"knowledge-graph\") and \
608 `stack=\"Rust,Python\"`; re-calling with new values re-ranks the cached \
609 fetch for free. Treat description-based leads as candidates to verify by \
610 drilling. DRILL via `element_id`: `\"cohort:<key>\"` (established / single / \
611 prolific / casual / dormant / consumers — the overview lists each key), \
612 `\"user:<login>\"` (portfolio), `\"user:<login>/repo:<name>\"` (repo profile), \
613 or `\"user:<login>/repo:<name>/readme\"` (README gist — the only drill that \
614 costs a request). `max_stargazers` samples the most-recent N (the overview \
615 reports if results are partial); `refresh=true` re-fetches.",
616 move |args: ScreenStargazersArgs| {
617 use crate::screen::{self, Filters, RankBy, Seed, Selection};
618 let split_csv = |s: Option<String>| -> Vec<String> {
619 s.map(|v| {
620 v.split(',')
621 .map(|t| t.trim().to_string())
622 .filter(|t| !t.is_empty())
623 .collect()
624 })
625 .unwrap_or_default()
626 };
627 let seed = if let Some(u) = &args.users {
629 Seed::Users(split_csv(Some(u.clone())))
630 } else {
631 let repo =
632 match resolve_repo_from(repo_for_screen.as_ref(), args.repo.clone()) {
633 Ok(r) => r,
634 Err(msg) => return msg,
635 };
636 if let Some(err) = crate::git_refs::validate_repo(&repo) {
637 return err;
638 }
639 Seed::Repo(repo)
640 };
641 let cfg = screen::ScreenConfig {
642 max_stargazers: args.max_stargazers,
643 max_repos_per_user: 100,
644 relevance_keywords: split_csv(args.keywords)
645 .into_iter()
646 .map(|k| k.to_lowercase())
647 .collect(),
648 stack_languages: split_csv(args.stack),
649 };
650 let top = args.top.unwrap_or(10);
652 let filters = Filters {
653 min_keywords: args.min_keywords,
654 active_since: args.active_since.clone(),
655 adopters_only: args.adopters_only,
656 stack_only: args.stack_only,
657 ..Default::default()
658 };
659 let filters_active = filters.min_keywords.is_some()
660 || filters.active_since.is_some()
661 || filters.adopters_only
662 || filters.stack_only;
663 let selection: Option<Selection> = if let Some(name) = &args.preset {
664 screen::preset(name, top)
665 } else if args.rank_by.is_some() || filters_active {
666 Some(Selection {
667 filters,
668 rank: args
669 .rank_by
670 .as_deref()
671 .and_then(RankBy::parse)
672 .unwrap_or(RankBy::Relatedness),
673 label: "SELECTION".into(),
674 take: top,
675 })
676 } else {
677 None
678 };
679 screen::screen_dispatch(
680 &screen_store,
681 &seed,
682 &cfg,
683 selection.as_ref(),
684 args.element_id.as_deref(),
685 args.refresh,
686 )
687 },
688 );
689 }
690 }
691
692 pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
699 &self.options.builtins
700 }
701
702 pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
708 &mut self.tool_router
709 }
710
711 pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
716 &mut self.prompt_router
717 }
718
719 pub fn register_typed_tool<T, F>(
734 &mut self,
735 name: &'static str,
736 description: &'static str,
737 handler: F,
738 ) where
739 T: for<'de> serde::Deserialize<'de>
740 + schemars::JsonSchema
741 + Default
742 + Send
743 + Sync
744 + 'static,
745 F: Fn(T) -> String + Send + Sync + 'static,
746 {
747 use std::pin::Pin;
748 type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
749
750 let schema_obj = serde_json::to_value(schemars::schema_for!(T))
751 .ok()
752 .and_then(|v| v.as_object().cloned())
753 .unwrap_or_default();
754 let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
755 let handler = std::sync::Arc::new(handler);
756
757 self.tool_router
758 .add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
759 attr,
760 move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
761 -> DynFut<'_, Result<rmcp::model::CallToolResult, rmcp::ErrorData>> {
762 let handler = handler.clone();
763 let arguments = ctx.arguments.clone();
764 Box::pin(async move {
765 let args: T = match arguments {
766 Some(map) => {
767 match serde_json::from_value(serde_json::Value::Object(map)) {
768 Ok(a) => a,
769 Err(e) => {
770 return Ok(rmcp::model::CallToolResult::success(vec![
771 rmcp::model::Content::text(format!(
772 "invalid arguments: {e}"
773 )),
774 ]));
775 }
776 }
777 }
778 None => T::default(),
779 };
780 let body = handler(args);
781 Ok(rmcp::model::CallToolResult::success(vec![
782 rmcp::model::Content::text(body),
783 ]))
784 })
785 },
786 ));
787 }
788
789 fn current_source_roots(&self) -> Vec<String> {
790 match &self.options.source_roots {
791 Some(provider) => provider(),
792 None => Vec::new(),
793 }
794 }
795
796 #[allow(dead_code)]
801 fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
802 resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
803 }
804
805 #[tool(
806 description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
807 Use to confirm the server framework is wired correctly before \
808 relying on graph- or source-aware tools."
809 )]
810 async fn ping(
811 &self,
812 Parameters(args): Parameters<PingArgs>,
813 ) -> Result<CallToolResult, McpError> {
814 let body = args.message.unwrap_or_else(|| "pong".to_string());
815 Ok(CallToolResult::success(vec![Content::text(body)]))
816 }
817
818 #[tool(description = "Read a file from the configured source root(s). Pass \
819 `start_line`/`end_line` to slice, `grep` to filter to matching \
820 lines, `max_chars` to cap output. Path traversal attempts are \
821 rejected. Available only when source roots are configured.")]
822 async fn read_source(
823 &self,
824 Parameters(args): Parameters<ReadSourceArgs>,
825 ) -> Result<CallToolResult, McpError> {
826 let roots = self.current_source_roots();
827 if roots.is_empty() {
828 return Ok(CallToolResult::success(vec![Content::text(
829 "Cannot read source: no active source root. Configure source_root in your manifest \
830 or activate one (e.g. via repo_management in workspace mode).",
831 )]));
832 }
833 let opts = ReadOpts {
834 start_line: args.start_line,
835 end_line: args.end_line,
836 grep: args.grep,
837 grep_context: args.grep_context,
838 max_matches: args.max_matches,
839 max_chars: args.max_chars,
840 };
841 let body = source::read_source(&args.file_path, &roots, &opts);
842 Ok(CallToolResult::success(vec![Content::text(body)]))
843 }
844
845 #[tool(
846 description = "Search source files using ripgrep. `pattern` is a regex (Rust \
847 syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
848 N surrounding lines per match. Set `case_insensitive=true` for \
849 case-insensitive matching. `max_results` caps total matches \
850 (default 50)."
851 )]
852 async fn grep(
853 &self,
854 Parameters(args): Parameters<GrepArgs>,
855 ) -> Result<CallToolResult, McpError> {
856 let roots = self.current_source_roots();
857 if roots.is_empty() {
858 return Ok(CallToolResult::success(vec![Content::text(
859 "Cannot grep: no active source root. Configure source_root in your manifest \
860 or activate one (e.g. via repo_management in workspace mode).",
861 )]));
862 }
863 let opts = GrepOpts {
864 glob: args.glob,
865 context: args.context,
866 max_results: Some(args.max_results.unwrap_or(50)),
867 case_insensitive: args.case_insensitive,
868 };
869 let body = source::grep(&roots, &args.pattern, &opts);
870 Ok(CallToolResult::success(vec![Content::text(body)]))
871 }
872
873 #[tool(
874 description = "List directory contents under the configured source root. `path` \
875 is resolved against the first source root (\".\" lists the root \
876 itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
877 `glob` filters entry names. `dirs_only=true` shows only \
878 directories."
879 )]
880 async fn list_source(
881 &self,
882 Parameters(args): Parameters<ListSourceArgs>,
883 ) -> Result<CallToolResult, McpError> {
884 let roots = self.current_source_roots();
885 if roots.is_empty() {
886 return Ok(CallToolResult::success(vec![Content::text(
887 "Cannot list source: no active source root. Configure source_root in your \
888 manifest or activate one (e.g. via repo_management in workspace mode).",
889 )]));
890 }
891 let primary = std::path::PathBuf::from(&roots[0]);
892 let target = match resolve_dir_under_roots(&args.path, &roots) {
893 Some(p) => p,
894 None => {
895 return Ok(CallToolResult::success(vec![Content::text(format!(
896 "Error: path '{}' resolves outside the configured source roots.",
897 args.path
898 ))]));
899 }
900 };
901 let opts = ListOpts {
902 depth: args.depth,
903 glob: args.glob,
904 dirs_only: args.dirs_only,
905 };
906 let body = source::list_source(&target, &primary, &opts);
907 Ok(CallToolResult::success(vec![Content::text(body)]))
908 }
909
910 #[tool(
911 description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
912 clone (if missing) and activate it as the source root for \
913 read_source / grep / list_source. Pass `delete=true` to remove a \
914 repo. Pass `update=true` to fetch upstream changes for the active \
915 repo (rebuild auto-skipped when HEAD hasn't moved since the last \
916 build; set `force_rebuild=true` to bypass). Call with no \
917 arguments to list all known repos with their last-access counts. \
918 Idle repos auto-sweep on each call (default 7 days, configurable \
919 via --stale-after-days)."
920 )]
921 async fn repo_management(
922 &self,
923 Parameters(args): Parameters<RepoManagementArgs>,
924 ) -> Result<CallToolResult, McpError> {
925 let body = match &self.options.workspace {
926 Some(ws) => ws.repo_management(
927 args.name.as_deref(),
928 args.delete,
929 args.update,
930 args.force_rebuild,
931 ),
932 None => "repo_management requires --workspace mode.".to_string(),
933 };
934 Ok(CallToolResult::success(vec![Content::text(body)]))
935 }
936}
937
938fn resolve_repo_from(
946 default_repo: Option<&RepoProvider>,
947 override_repo: Option<String>,
948) -> Result<String, String> {
949 if let Some(r) = override_repo {
950 if let Some(err) = crate::git_refs::validate_repo(&r) {
951 return Err(err);
952 }
953 return Ok(r);
954 }
955 if let Some(provider) = default_repo {
956 if let Some(r) = provider() {
957 if let Some(err) = crate::git_refs::validate_repo(&r) {
958 return Err(err);
959 }
960 return Ok(r);
961 }
962 }
963 if let Some(detected) = crate::github::detect_git_repo(".") {
964 if crate::git_refs::validate_repo(&detected).is_none() {
965 return Ok(detected);
966 }
967 }
968 Err(
969 "No active repository. Pass `repo_name='org/repo'`, configure a default in the \
970 server, or run from a directory whose git remote points at github.com."
971 .to_string(),
972 )
973}
974
975pub fn serve_prompts(registry: &ResolvedRegistry, server: &mut McpServer) {
989 use std::borrow::Cow;
990 use std::collections::HashSet;
991
992 let registered_tools: HashSet<String> = server
997 .tool_router
998 .list_all()
999 .iter()
1000 .map(|t| t.name.to_string())
1001 .collect();
1002 let extensions = server.options.extensions.clone();
1003
1004 struct InjectSkill {
1010 name: String,
1011 description: String,
1012 body: String,
1013 references_tools: Vec<String>,
1014 }
1015 let mut auto_inject: Vec<InjectSkill> = Vec::new();
1016
1017 for name in registry.skill_names() {
1018 let Some(skill) = registry.get(&name) else {
1019 continue;
1020 };
1021
1022 let activation = registry.activation_for(skill, ®istered_tools, &extensions);
1026 if !activation.active {
1027 let failed_clauses: Vec<&str> = activation
1028 .clauses
1029 .iter()
1030 .filter(|(_, outcome)| {
1031 *outcome != crate::server::skills::PredicateOutcome::Satisfied
1032 })
1033 .map(|(clause, _)| clause.as_str())
1034 .collect();
1035 tracing::info!(
1036 skill = %name,
1037 suppressed_by = ?failed_clauses,
1038 "skill suppressed by applies_when predicates"
1039 );
1040 continue;
1041 }
1042
1043 let prompt = Prompt::new(
1044 skill.name().to_string(),
1045 Some(skill.description().to_string()),
1046 None,
1047 );
1048 let body = skill.body.clone();
1049 let route = PromptRoute::new_dyn(prompt, move |_ctx| {
1050 let body = body.clone();
1051 Box::pin(async move {
1052 Ok(GetPromptResult::new(vec![PromptMessage::new_text(
1053 PromptMessageRole::Assistant,
1054 body,
1055 )]))
1056 })
1057 });
1058 server.prompt_router.add_route(route);
1059
1060 if skill.frontmatter.auto_inject_hint {
1061 auto_inject.push(InjectSkill {
1062 name: skill.name().to_string(),
1063 description: skill.description().to_string(),
1064 body: skill.body.clone(),
1065 references_tools: skill.frontmatter.references_tools.clone(),
1066 });
1067 }
1068 }
1069
1070 for inj in &auto_inject {
1106 let mut targets: Vec<&str> = Vec::new();
1109 let mut seen: HashSet<&str> = HashSet::new();
1110 for tool in std::iter::once(inj.name.as_str())
1111 .chain(inj.references_tools.iter().map(String::as_str))
1112 {
1113 if seen.insert(tool) {
1114 targets.push(tool);
1115 }
1116 }
1117
1118 let marker = format!("<!-- mcp-skill:{} -->", inj.name);
1121 let mut block = format!("\n\n{marker}");
1122 let description = inj.description.trim();
1123 if !description.is_empty() {
1124 block.push_str("\n\n## When to use\n\n");
1125 block.push_str(description);
1126 }
1127 block.push_str("\n\n## Methodology\n\n");
1128 block.push_str(inj.body.trim());
1129
1130 for tool in targets {
1131 let key = Cow::<'static, str>::Owned(tool.to_string());
1132 let Some(route) = server.tool_router.map.get_mut(&key) else {
1133 continue;
1134 };
1135 if route
1138 .attr
1139 .description
1140 .as_deref()
1141 .is_some_and(|d| d.contains(&marker))
1142 {
1143 continue;
1144 }
1145 let new_desc = match route.attr.description.take() {
1146 Some(existing) => format!("{existing}{block}"),
1147 None => block.trim_start().to_string(),
1148 };
1149 route.attr.description = Some(Cow::Owned(new_desc));
1150 }
1151 }
1152}
1153
1154#[tool_handler(router = self.tool_router)]
1155impl ServerHandler for McpServer {
1156 fn get_info(&self) -> ServerInfo {
1157 let name = self
1158 .options
1159 .name
1160 .clone()
1161 .unwrap_or_else(|| "MCP Server".to_string());
1162 let mut caps = ServerCapabilities::builder().enable_tools().build();
1169 if !self.prompt_router.map.is_empty() {
1170 caps.prompts = Some(PromptsCapability::default());
1171 }
1172 let mut info = ServerInfo::new(caps)
1173 .with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
1174 .with_protocol_version(ProtocolVersion::V_2024_11_05);
1175 if let Some(text) = &self.options.instructions {
1176 info = info.with_instructions(text.clone());
1177 }
1178 info
1179 }
1180
1181 async fn list_prompts(
1182 &self,
1183 _request: Option<PaginatedRequestParams>,
1184 _context: rmcp::service::RequestContext<rmcp::RoleServer>,
1185 ) -> Result<ListPromptsResult, McpError> {
1186 Ok(ListPromptsResult {
1187 meta: None,
1188 next_cursor: None,
1189 prompts: self.prompt_router.list_all(),
1190 })
1191 }
1192
1193 async fn get_prompt(
1194 &self,
1195 request: GetPromptRequestParams,
1196 context: rmcp::service::RequestContext<rmcp::RoleServer>,
1197 ) -> Result<GetPromptResult, McpError> {
1198 let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
1199 self,
1200 request.name,
1201 request.arguments,
1202 context,
1203 );
1204 self.prompt_router.get_prompt(prompt_context).await
1205 }
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210 use super::*;
1211
1212 #[test]
1213 fn options_from_manifest_uses_name_when_set() {
1214 let opts = ServerOptions::from_manifest(None, "Fallback");
1215 assert_eq!(opts.name.as_deref(), Some("Fallback"));
1216 }
1217
1218 #[test]
1219 fn builtins_exposed_via_server() {
1220 use crate::server::manifest::{BuiltinsConfig, TempCleanup};
1221 let opts = ServerOptions {
1222 builtins: BuiltinsConfig {
1223 save_graph: true,
1224 temp_cleanup: TempCleanup::OnOverview,
1225 ..Default::default()
1226 },
1227 ..ServerOptions::default()
1228 };
1229 let server = McpServer::new(opts);
1230 assert!(server.builtins().save_graph);
1231 assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
1232 }
1233
1234 #[test]
1235 fn server_constructs() {
1236 let _server = McpServer::new(ServerOptions::default());
1237 }
1238
1239 #[test]
1240 fn static_source_roots_provider() {
1241 let opts = ServerOptions::default()
1242 .with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
1243 let server = McpServer::new(opts);
1244 assert_eq!(
1245 server.current_source_roots(),
1246 vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
1247 );
1248 }
1249
1250 #[test]
1251 fn no_provider_returns_empty_roots() {
1252 let server = McpServer::new(ServerOptions::default());
1253 assert!(server.current_source_roots().is_empty());
1254 }
1255
1256 #[test]
1257 fn repo_management_gated_to_workspace_mode() {
1258 let server = McpServer::new(ServerOptions::default());
1261 let tools = server.tool_router.list_all();
1262 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1263 assert!(
1264 !names.contains(&"repo_management"),
1265 "repo_management should be gated out without a workspace; tools were {names:?}"
1266 );
1267 }
1268
1269 #[test]
1270 fn repo_management_present_when_workspace_bound() {
1271 use crate::server::workspace::Workspace;
1274 let dir = tempfile::tempdir().unwrap();
1275 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1276 let opts = ServerOptions::default().with_workspace(ws);
1277 let server = McpServer::new(opts);
1278 let tools = server.tool_router.list_all();
1279 let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
1280 assert!(
1281 names.contains(&"repo_management"),
1282 "repo_management should be registered with a workspace; tools were {names:?}"
1283 );
1284 }
1285
1286 #[test]
1287 fn dynamic_provider_swaps_at_call_time() {
1288 use std::sync::Mutex;
1289 let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
1290 let s2 = state.clone();
1291 let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
1292 let opts = ServerOptions::default().with_dynamic_source_roots(provider);
1293 let server = McpServer::new(opts);
1294 assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
1295 *state.lock().unwrap() = vec!["/swapped".to_string()];
1296 assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
1297 }
1298
1299 fn build_test_registry(
1302 skills: &[(&str, &str, &str, bool)],
1303 ) -> crate::server::skills::ResolvedRegistry {
1304 use crate::server::skills::Registry;
1305 let dir = tempfile::tempdir().unwrap();
1306 let yaml_path = dir.path().join("manifest.yaml");
1307 let skills_dir = dir.path().join("manifest.skills");
1308 std::fs::create_dir_all(&skills_dir).unwrap();
1309 for (name, description, body, auto_inject) in skills {
1310 let auto = if *auto_inject { "true" } else { "false" };
1311 let content = format!(
1312 "---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
1313 );
1314 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1315 }
1316 Registry::new()
1317 .auto_detect_project_layer(&yaml_path)
1318 .finalise()
1319 .unwrap()
1320 }
1321
1322 fn build_registry_with_refs(
1327 skills: &[(&str, &str, &str, &str)],
1328 ) -> crate::server::skills::ResolvedRegistry {
1329 use crate::server::skills::Registry;
1330 let dir = tempfile::tempdir().unwrap();
1331 let yaml_path = dir.path().join("manifest.yaml");
1332 let skills_dir = dir.path().join("manifest.skills");
1333 std::fs::create_dir_all(&skills_dir).unwrap();
1334 for (name, description, body, references_tools) in skills {
1335 let content = format!(
1336 "---\nname: {name}\ndescription: {description}\n\
1337 auto_inject_hint: true\nreferences_tools: {references_tools}\n---\n\n{body}\n"
1338 );
1339 std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
1340 }
1341 Registry::new()
1342 .auto_detect_project_layer(&yaml_path)
1343 .finalise()
1344 .unwrap()
1345 }
1346
1347 fn tool_desc(server: &McpServer, tool: &str) -> String {
1348 server
1349 .tool_router
1350 .get(tool)
1351 .and_then(|t| t.description.clone())
1352 .map(|c| c.into_owned())
1353 .unwrap_or_default()
1354 }
1355
1356 #[test]
1357 fn prompt_router_empty_by_default() {
1358 let server = McpServer::new(ServerOptions::default());
1359 assert!(server.prompt_router.map.is_empty());
1360 }
1361
1362 #[test]
1363 fn get_info_no_prompts_capability_when_empty() {
1364 let server = McpServer::new(ServerOptions::default());
1368 let info = server.get_info();
1369 assert!(
1370 info.capabilities.prompts.is_none(),
1371 "prompts capability must be absent when no skills are registered"
1372 );
1373 }
1374
1375 #[test]
1376 fn serve_prompts_registers_routes_with_metadata() {
1377 let registry = build_test_registry(&[
1378 ("alpha", "First skill.", "Alpha body.", true),
1379 ("beta", "Second skill.", "Beta body.", true),
1380 ]);
1381 let mut server = McpServer::new(ServerOptions::default());
1382 super::serve_prompts(®istry, &mut server);
1383
1384 let prompts = server.prompt_router.list_all();
1385 let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
1386 assert_eq!(names, vec!["alpha", "beta"]);
1387
1388 let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
1389 assert_eq!(alpha.description.as_deref(), Some("First skill."));
1390 assert!(alpha.arguments.is_none());
1391 }
1392
1393 #[test]
1394 fn serve_prompts_empty_registry_is_noop() {
1395 let registry = crate::server::skills::ResolvedRegistry::default();
1396 let mut server = McpServer::new(ServerOptions::default());
1397 super::serve_prompts(®istry, &mut server);
1398 assert!(server.prompt_router.map.is_empty());
1399 assert!(server.get_info().capabilities.prompts.is_none());
1400 }
1401
1402 #[test]
1403 fn get_info_advertises_prompts_when_present() {
1404 let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
1405 let mut server = McpServer::new(ServerOptions::default());
1406 super::serve_prompts(®istry, &mut server);
1407 let info = server.get_info();
1408 assert!(
1409 info.capabilities.prompts.is_some(),
1410 "prompts capability must be advertised once a skill is registered"
1411 );
1412 }
1413
1414 #[test]
1415 fn serve_prompts_auto_injects_full_body_into_matching_tool() {
1416 let registry =
1424 build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
1425 let mut server = McpServer::new(ServerOptions::default());
1426 let before = server
1427 .tool_router
1428 .get("ping")
1429 .and_then(|t| t.description.clone())
1430 .map(|c| c.into_owned())
1431 .unwrap_or_default();
1432 super::serve_prompts(®istry, &mut server);
1433 let after = server
1434 .tool_router
1435 .get("ping")
1436 .and_then(|t| t.description.clone())
1437 .map(|c| c.into_owned())
1438 .unwrap_or_default();
1439 assert!(after.starts_with(&before), "original description preserved");
1440 assert!(
1441 after.contains("## Methodology"),
1442 "inject should include a Methodology header; got: {after}"
1443 );
1444 assert!(
1445 after.contains("PING-BODY-SENTINEL"),
1446 "inject should embed the full skill body; got: {after}"
1447 );
1448 assert!(
1449 !after.contains("prompts/get"),
1450 "post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
1451 );
1452 }
1453
1454 #[test]
1455 fn serve_prompts_skips_injection_when_disabled() {
1456 let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
1457 let mut server = McpServer::new(ServerOptions::default());
1458 let before = server
1459 .tool_router
1460 .get("ping")
1461 .and_then(|t| t.description.clone())
1462 .map(|c| c.into_owned())
1463 .unwrap_or_default();
1464 super::serve_prompts(®istry, &mut server);
1465 let after = server
1466 .tool_router
1467 .get("ping")
1468 .and_then(|t| t.description.clone())
1469 .map(|c| c.into_owned())
1470 .unwrap_or_default();
1471 assert_eq!(
1472 before, after,
1473 "auto_inject_hint=false must leave tool description untouched"
1474 );
1475 }
1476
1477 #[test]
1478 fn serve_prompts_skips_injection_when_no_matching_tool() {
1479 let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
1482 let mut server = McpServer::new(ServerOptions::default());
1483 super::serve_prompts(®istry, &mut server);
1484 assert!(server.prompt_router.map.contains_key("no_such_tool"));
1485 let ping_desc = server
1488 .tool_router
1489 .get("ping")
1490 .and_then(|t| t.description.clone())
1491 .map(|c| c.into_owned())
1492 .unwrap_or_default();
1493 assert!(!ping_desc.contains("no_such_tool"));
1494 }
1495
1496 #[test]
1497 fn serve_prompts_injects_description_under_when_to_use() {
1498 let registry = build_test_registry(&[("ping", "ROUTING-SENTINEL", "BODY-SENTINEL", true)]);
1502 let mut server = McpServer::new(ServerOptions::default());
1503 super::serve_prompts(®istry, &mut server);
1504 let desc = tool_desc(&server, "ping");
1505 assert!(
1506 desc.contains("## When to use\n\nROUTING-SENTINEL"),
1507 "description should be injected under `## When to use`; got: {desc}"
1508 );
1509 assert!(
1510 desc.contains("<!-- mcp-skill:ping -->"),
1511 "injection should carry the per-skill idempotency marker; got: {desc}"
1512 );
1513 let when = desc.find("## When to use").unwrap();
1515 let method = desc.find("## Methodology").unwrap();
1516 assert!(when < method, "`When to use` must precede `Methodology`");
1517 }
1518
1519 #[test]
1520 fn serve_prompts_honors_references_tools() {
1521 let registry = build_registry_with_refs(&[(
1525 "graph_strategy",
1526 "Map structure first.",
1527 "GRAPH-BODY-SENTINEL",
1528 "[ping]",
1529 )]);
1530 let mut server = McpServer::new(ServerOptions::default());
1531 super::serve_prompts(®istry, &mut server);
1532 assert!(server.prompt_router.map.contains_key("graph_strategy"));
1534 let desc = tool_desc(&server, "ping");
1536 assert!(
1537 desc.contains("<!-- mcp-skill:graph_strategy -->"),
1538 "referenced tool should carry the skill marker; got: {desc}"
1539 );
1540 assert!(
1541 desc.contains("Map structure first."),
1542 "referenced tool should carry the skill routing; got: {desc}"
1543 );
1544 assert!(
1545 desc.contains("GRAPH-BODY-SENTINEL"),
1546 "referenced tool should carry the skill body; got: {desc}"
1547 );
1548 }
1549
1550 #[test]
1551 fn serve_prompts_idempotent_when_skill_self_references() {
1552 let registry = build_registry_with_refs(&[("ping", "Routing.", "Body.", "[ping]")]);
1556 let mut server = McpServer::new(ServerOptions::default());
1557 super::serve_prompts(®istry, &mut server);
1558 let desc = tool_desc(&server, "ping");
1559 let marker_count = desc.matches("<!-- mcp-skill:ping -->").count();
1560 assert_eq!(
1561 marker_count, 1,
1562 "self-referencing skill must inject exactly once; got {marker_count}: {desc}"
1563 );
1564 }
1565
1566 #[test]
1567 fn serve_prompts_idempotent_across_repeated_passes() {
1568 let registry = build_test_registry(&[("ping", "Routing.", "Body.", true)]);
1571 let mut server = McpServer::new(ServerOptions::default());
1572 super::serve_prompts(®istry, &mut server);
1573 let once = tool_desc(&server, "ping");
1574 super::serve_prompts(®istry, &mut server);
1575 let twice = tool_desc(&server, "ping");
1576 assert_eq!(
1577 once, twice,
1578 "second pass must be a no-op for an already-injected tool"
1579 );
1580 }
1581
1582 #[test]
1583 fn serve_prompts_multiple_skills_stack_on_one_tool() {
1584 let registry = build_registry_with_refs(&[
1588 ("ping", "Ping routing.", "PING-BODY", "[]"),
1589 ("ping_strategy", "Strategy routing.", "STRAT-BODY", "[ping]"),
1590 ]);
1591 let mut server = McpServer::new(ServerOptions::default());
1592 super::serve_prompts(®istry, &mut server);
1593 let desc = tool_desc(&server, "ping");
1594 assert!(desc.contains("<!-- mcp-skill:ping -->"), "got: {desc}");
1595 assert!(
1596 desc.contains("<!-- mcp-skill:ping_strategy -->"),
1597 "got: {desc}"
1598 );
1599 assert!(
1600 desc.contains("PING-BODY") && desc.contains("STRAT-BODY"),
1601 "got: {desc}"
1602 );
1603 }
1604
1605 fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
1606 let dir = tempfile::tempdir().unwrap();
1607 let yaml = dir.path().join("test_mcp.yaml");
1608 std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
1609 let skills_dir = dir.path().join("test_mcp.skills");
1610 std::fs::create_dir(&skills_dir).unwrap();
1611 std::fs::write(
1612 skills_dir.join("gated_skill.md"),
1613 format!(
1614 "---\n\
1615 name: gated_skill\n\
1616 description: A predicate-gated skill for testing.\n\
1617 applies_when:\n\
1618 {applies_when_yaml}\n\
1619 ---\n\n\
1620 Body.\n",
1621 ),
1622 )
1623 .unwrap();
1624 dir
1625 }
1626
1627 #[test]
1628 fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
1629 use crate::server::skills::Registry as SkillsBuilder;
1633 let dir = write_gated_project_skill(" tool_registered: nonexistent_tool");
1634 let yaml = dir.path().join("test_mcp.yaml");
1635 let registry = SkillsBuilder::new()
1636 .auto_detect_project_layer(&yaml)
1637 .finalise()
1638 .unwrap();
1639 let mut server = McpServer::new(ServerOptions::default());
1640 super::serve_prompts(®istry, &mut server);
1641 assert!(
1642 !server.prompt_router.map.contains_key("gated_skill"),
1643 "skill with unsatisfied predicate must be suppressed"
1644 );
1645 }
1646
1647 #[test]
1648 fn serve_prompts_keeps_skill_with_satisfied_predicate() {
1649 use crate::server::skills::Registry as SkillsBuilder;
1652 let dir = write_gated_project_skill(" tool_registered: ping");
1653 let yaml = dir.path().join("test_mcp.yaml");
1654 let registry = SkillsBuilder::new()
1655 .auto_detect_project_layer(&yaml)
1656 .finalise()
1657 .unwrap();
1658 let mut server = McpServer::new(ServerOptions::default());
1659 super::serve_prompts(®istry, &mut server);
1660 assert!(
1661 server.prompt_router.map.contains_key("gated_skill"),
1662 "skill with satisfied predicate must register"
1663 );
1664 }
1665
1666 #[test]
1667 fn serve_prompts_evaluates_extension_enabled_from_manifest() {
1668 use crate::server::skills::Registry as SkillsBuilder;
1672 let dir = write_gated_project_skill(" extension_enabled: csv_http_server");
1673 let yaml = dir.path().join("test_mcp.yaml");
1674 let registry = SkillsBuilder::new()
1675 .auto_detect_project_layer(&yaml)
1676 .finalise()
1677 .unwrap();
1678
1679 let mut server = McpServer::new(ServerOptions::default());
1681 super::serve_prompts(®istry, &mut server);
1682 assert!(!server.prompt_router.map.contains_key("gated_skill"));
1683
1684 let mut extensions = serde_json::Map::new();
1686 extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
1687 let opts = ServerOptions {
1688 extensions,
1689 ..ServerOptions::default()
1690 };
1691 let mut server = McpServer::new(opts);
1692 super::serve_prompts(®istry, &mut server);
1693 assert!(server.prompt_router.map.contains_key("gated_skill"));
1694 }
1695}