#![allow(dead_code)]
use std::sync::{Arc, Mutex};
use rmcp::handler::server::router::prompt::{PromptRoute, PromptRouter};
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::*;
use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
use serde::{Deserialize, Serialize};
use crate::server::manifest::Manifest;
use crate::server::skills::ResolvedRegistry;
use crate::server::source::{
self, resolve_dir_under_roots, GrepOpts, ListOpts, ReadOpts, SourceRootsProvider,
};
pub type RepoProvider = Arc<dyn Fn() -> Option<String> + Send + Sync>;
pub struct ResultCtx {
pub source_roots: Vec<String>,
pub active_repo: Option<String>,
}
pub type ResultPostprocessHook =
Arc<dyn Fn(&str, &serde_json::Value, &str, &ResultCtx) -> Option<String> + Send + Sync>;
fn append_footer(body: String, footer: Option<String>) -> String {
match footer {
Some(f) if !f.is_empty() => format!("{body}\n\n{f}"),
_ => body,
}
}
fn dispatch_typed_call<T, F>(
tool_name: &str,
arguments: Option<rmcp::model::JsonObject>,
handler: &F,
postprocess: Option<&ResultPostprocessHook>,
source_roots: Option<&SourceRootsProvider>,
workspace: Option<&crate::server::workspace::Workspace>,
) -> rmcp::model::CallToolResult
where
T: for<'de> serde::Deserialize<'de> + Default,
F: Fn(T) -> Result<String, String>,
{
let args_json = match &arguments {
Some(map) => serde_json::Value::Object(map.clone()),
None => serde_json::Value::Null,
};
let outcome = match arguments {
Some(map) => match serde_json::from_value::<T>(serde_json::Value::Object(map)) {
Ok(args) => handler(args),
Err(e) => Err(format!("invalid arguments: {e}")),
},
None => handler(T::default()),
};
let is_error = outcome.is_err();
let body = match outcome {
Ok(body) | Err(body) => body,
};
let body = match postprocess {
Some(hook) => {
let ctx = ResultCtx {
source_roots: source_roots.map(|p| p()).unwrap_or_default(),
active_repo: workspace.and_then(|w| w.active_repo_name()),
};
let footer = hook(tool_name, &args_json, &body, &ctx);
append_footer(body, footer)
}
None => body,
};
let content = vec![rmcp::model::ContentBlock::text(body)];
if is_error {
rmcp::model::CallToolResult::error(content)
} else {
rmcp::model::CallToolResult::success(content)
}
}
#[derive(Clone, Default)]
pub struct ServerOptions {
pub name: Option<String>,
pub instructions: Option<String>,
pub source_roots: Option<SourceRootsProvider>,
pub default_repo: Option<RepoProvider>,
pub workspace: Option<crate::server::workspace::Workspace>,
pub builtins: crate::server::manifest::BuiltinsConfig,
pub extensions: serde_json::Map<String, serde_json::Value>,
pub result_postprocess: Option<ResultPostprocessHook>,
}
impl std::fmt::Debug for ServerOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerOptions")
.field("name", &self.name)
.field("instructions", &self.instructions)
.field(
"source_roots",
&self.source_roots.as_ref().map(|_| "<provider>"),
)
.field(
"default_repo",
&self.default_repo.as_ref().map(|_| "<provider>"),
)
.finish()
}
}
impl ServerOptions {
pub fn from_manifest(manifest: Option<&Manifest>, fallback_name: &str) -> Self {
Self {
name: manifest
.and_then(|m| m.name.clone())
.or_else(|| Some(fallback_name.to_string())),
instructions: manifest.and_then(|m| m.instructions.clone()),
source_roots: None,
default_repo: None,
workspace: None,
builtins: manifest.map(|m| m.builtins.clone()).unwrap_or_default(),
extensions: manifest.map(|m| m.extensions.clone()).unwrap_or_default(),
result_postprocess: None,
}
}
pub fn with_static_source_roots(mut self, roots: Vec<String>) -> Self {
let captured = Arc::new(roots);
self.source_roots = Some(Arc::new(move || captured.as_ref().clone()));
self
}
pub fn with_dynamic_source_roots(mut self, provider: SourceRootsProvider) -> Self {
self.source_roots = Some(provider);
self
}
pub fn with_static_repo(mut self, repo: String) -> Self {
self.default_repo = Some(Arc::new(move || Some(repo.clone())));
self
}
pub fn with_dynamic_repo(mut self, provider: RepoProvider) -> Self {
self.default_repo = Some(provider);
self
}
pub fn with_workspace(mut self, ws: crate::server::workspace::Workspace) -> Self {
let ws_for_roots = ws.clone();
let ws_for_repo = ws.clone();
self.workspace = Some(ws);
self.source_roots = Some(Arc::new(move || {
ws_for_roots
.active_repo_path()
.map(|p| vec![p.to_string_lossy().into_owned()])
.unwrap_or_default()
}));
self.default_repo = Some(Arc::new(move || ws_for_repo.default_github_repo()));
self
}
pub fn with_result_postprocess(mut self, hook: ResultPostprocessHook) -> Self {
self.result_postprocess = Some(hook);
self
}
}
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
pub struct PingArgs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
pub struct ReadSourceArgs {
pub file_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_line: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_line: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grep: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grep_context: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_matches: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_chars: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
pub struct GrepArgs {
pub pattern: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glob: Option<String>,
#[serde(default)]
pub context: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_results: Option<usize>,
#[serde(default)]
pub case_insensitive: bool,
}
#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
pub struct SetRootDirArgs {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revs: Option<crate::server::workspace::RevsRequest>,
}
#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
pub struct RepoManagementArgs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default)]
pub delete: bool,
#[serde(default)]
pub update: bool,
#[serde(default)]
pub force_rebuild: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revs: Option<crate::server::workspace::RevsRequest>,
}
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
pub struct GithubIssuesArgs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub number: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(default = "default_kind")]
pub kind: String,
#[serde(default = "default_state")]
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sort: Option<String>,
#[serde(default = "default_limit")]
pub limit: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub element_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lines: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grep: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<usize>,
#[serde(default)]
pub refresh: bool,
}
fn default_kind() -> String {
"all".to_string()
}
fn default_state() -> String {
"open".to_string()
}
fn default_limit() -> usize {
20
}
impl Default for GithubIssuesArgs {
fn default() -> Self {
Self {
number: None,
repo_name: None,
query: None,
kind: default_kind(),
state: default_state(),
sort: None,
limit: default_limit(),
labels: None,
element_id: None,
lines: None,
grep: None,
context: None,
refresh: false,
}
}
}
#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
pub struct GithubApiArgs {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub truncate_at: Option<usize>,
}
#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)]
pub struct ListSourceArgs {
#[serde(default = "default_path")]
pub path: String,
#[serde(default = "default_depth")]
pub depth: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub glob: Option<String>,
#[serde(default)]
pub dirs_only: bool,
}
fn default_path() -> String {
".".to_string()
}
fn default_depth() -> usize {
1
}
#[derive(Debug, Default, Deserialize, Serialize, schemars::JsonSchema)]
pub struct ScreenStargazersArgs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub users: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rank_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub top: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_keywords: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_since: Option<String>,
#[serde(default)]
pub adopters_only: bool,
#[serde(default)]
pub stack_only: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub keywords: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stack: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_stargazers: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub element_id: Option<String>,
#[serde(default)]
pub refresh: bool,
}
#[derive(Clone)]
pub struct McpServer {
options: ServerOptions,
tool_router: ToolRouter<McpServer>,
prompt_router: PromptRouter<McpServer>,
}
#[tool_router]
impl McpServer {
pub fn new(options: ServerOptions) -> Self {
let mut server = Self {
options,
tool_router: Self::tool_router(),
prompt_router: PromptRouter::new(),
};
server.register_github_tools_if_authorized();
server.register_local_workspace_tools();
server.gate_workspace_tools();
server
}
fn gate_workspace_tools(&mut self) {
if self.options.workspace.is_none() {
self.tool_router.remove_route("repo_management");
}
}
fn register_local_workspace_tools(&mut self) {
let Some(ws) = self.options.workspace.clone() else {
return;
};
if !matches!(ws.kind(), crate::server::workspace::WorkspaceKind::Local) {
return;
}
self.register_typed_tool::<SetRootDirArgs, _>(
"set_root_dir",
"Swap the active source root (local-workspace mode only). Pass `path` \
to a directory; the framework canonicalises it, rebinds the source \
tools (`read_source`, `grep`, `list_source`), and fires the post-\
activate hook so any downstream graph rebuilds against the new root. \
Pass `revs` (an integer N, or a list of git revspecs) to load multiple \
revisions of the root into one graph — N loads the newest N stable \
release tags of the dominant tag family plus HEAD (prereleases and \
unrelated tag families skipped); requires the root to be a git repo. \
Inventory persists across swaps; SHA-gating skips rebuilds when \
the same root is re-bound with no content changes.",
move |args: SetRootDirArgs| {
let p = std::path::PathBuf::from(&args.path);
ws.set_root_dir(&p, args.revs.as_ref())
},
);
}
fn register_github_tools_if_authorized(&mut self) {
if !self.options.builtins.github {
tracing::debug!(
"GitHub tools disabled (default) — set `builtins.github: true` in the manifest \
to register github_issues / github_api / screen_stargazers."
);
return;
}
if !crate::github::has_git_token() {
tracing::info!(
"`builtins.github: true` is set but no GitHub token is reachable — \
github_issues / github_api tools hidden from the agent. Set GITHUB_TOKEN \
(env or the manifest's env_file) and restart to enable them."
);
return;
}
let default_repo = self.options.default_repo.clone();
let repo_provider = default_repo.clone();
let cache: Arc<Mutex<crate::cache::ElementCache>> =
Arc::new(Mutex::new(crate::cache::ElementCache::new()));
let cache_for_issues = cache.clone();
self.register_typed_tool::<GithubIssuesArgs, _>(
"github_issues",
"Search, list, or fetch GitHub issues / pull requests / Discussions. \
Pass `number=N` for FETCH (single issue/PR/discussion); `query=\"...\"` \
for SEARCH (across issues+PRs and Discussions); neither for LIST. \
`kind` ∈ \"issue\" / \"pr\" / \"discussion\" / \"all\" (default). \
`state` ∈ \"open\" (default) / \"closed\" / \"all\". `limit` caps \
result count (default 20). `labels` is a comma-separated string. \
`repo_name=\"org/repo\"` overrides the active repo for one call. \
FETCH responses collapse big code blocks / patches / comments into \
`cb_N` / `patch_N` / `comment_N` / `overflow` placeholders; pass \
`element_id=\"cb_1\"` (with the same `number`) to retrieve a single \
element, optionally narrowed by `lines=\"40-60\"` or `grep=\"pat\"`. \
`refresh=true` bypasses the cache for re-fetch.",
move |args: GithubIssuesArgs| {
let repo = match resolve_repo_from(repo_provider.as_ref(), args.repo_name.clone()) {
Ok(r) => r,
Err(msg) => return msg,
};
if let Some(number) = args.number {
let context = args.context.unwrap_or(3);
let mut guard = cache_for_issues.lock().unwrap();
return guard.fetch_issue(
&repo,
number,
args.element_id.as_deref(),
args.lines.as_deref(),
args.grep.as_deref(),
context,
args.refresh,
);
}
if args.element_id.is_some() {
return "element_id requires `number=N` (the issue/PR being drilled into)."
.to_string();
}
crate::github::github_issues_rust(
Some(&repo),
args.number,
args.query.as_deref(),
&args.kind,
&args.state,
args.sort.as_deref(),
args.limit,
args.labels.as_deref(),
)
},
);
let repo_provider = default_repo.clone();
let repo_for_screen = default_repo;
self.register_typed_tool::<GithubApiArgs, _>(
"github_api",
"Read-only GET against the GitHub REST API. `path` may be a \
repo-relative endpoint (\"pulls?state=open\", \"commits/abc123\", \
\"branches\", \"compare/main...feature\") which is auto-prefixed \
with /repos/<repo_name>/, or a top-level resource (\"search/issues?q=...\", \
\"users/octocat\", \"repos/owner/name\") which passes through. A \
leading slash is optional and accepted on either form. Returns \
JSON, truncated at 80 KB by default.",
move |args: GithubApiArgs| match resolve_repo_from(
repo_provider.as_ref(),
args.repo_name.clone(),
) {
Ok(repo) => {
let truncate_at = args.truncate_at.unwrap_or(80_000);
crate::github::git_api_internal(&repo, &args.path, truncate_at)
}
Err(msg) => msg,
},
);
if self.options.builtins.screen_stargazers {
let screen_store: Arc<Mutex<crate::screen::ScreenStore>> =
Arc::new(Mutex::new(crate::screen::ScreenStore::new()));
self.register_typed_tool::<ScreenStargazersArgs, _>(
"screen_stargazers",
"Screen the people around a GitHub project to find relevant developers, \
notable/legendary devs, architectural peers, and actual users — cheaply. \
Seed on a repo (`repo=\"owner/repo\"` → screens its stargazers) OR an \
explicit user list (`users=\"alice,bob\"` → screens them directly). With \
just a repo it auto-derives relevance keywords + tech stack from the repo \
itself, bulk-fetches each person's public repo portfolio over plain REST \
(~1 request per person, no GraphQL, no READMEs), classifies them, and \
enriches a bounded shortlist with follower counts, dependency-adoption, \
stack co-location, and contributions. Every person gets a normalized \
0–100 score vector on four axes — relatedness, popularity, effort, \
recency. RANK/FILTER: pass a `preset` (\"outreach\"=relevant+active by \
reach, \"peers\"=your stack by effort, \"legends\"=biggest reach any \
domain, \"intel\"=on-domain by popularity, \"adopters\"=actual users), or \
`rank_by`=relatedness|popularity|effort|recency with filters \
(`min_keywords`, `active_since`, `adopters_only`, `stack_only`) and \
`top`=N (rank-then-take-N, default 10) for a focused filter→rank→take \
view; with none, the full multi-lens browse: \
`✅ ADOPTERS` (stargazers whose repos actually declare your package as a \
dependency — real users, not just watchers), `★ MOST RELEVANT` \
(relatedness — repos matching your topic keywords, with follower counts \
and external contributions), `🏆 NOTABLE` (popularity/reach lens — your \
highest-traction stargazers, flagged `LEGEND` for big audiences/projects), \
`✦ QUALITY` (best-kept maintained projects), `⚙ STACK MATCH` (architectural \
peers who build in your stack — co-location-confirmed where possible), and \
a cohort inventory. Override the auto-config with `keywords=\"graph,rag,agent\"` \
(single words — \"knowledge,graph\" not \"knowledge-graph\") and \
`stack=\"Rust,Python\"`; re-calling with new values re-ranks the cached \
fetch for free. Treat description-based leads as candidates to verify by \
drilling. DRILL via `element_id`: `\"cohort:<key>\"` (established / single / \
prolific / casual / dormant / consumers — the overview lists each key), \
`\"user:<login>\"` (portfolio), `\"user:<login>/repo:<name>\"` (repo profile), \
or `\"user:<login>/repo:<name>/readme\"` (README gist — the only drill that \
costs a request). `max_stargazers` samples the most-recent N (the overview \
reports if results are partial); `refresh=true` re-fetches.",
move |args: ScreenStargazersArgs| {
use crate::screen::{self, Filters, RankBy, Seed, Selection};
let split_csv = |s: Option<String>| -> Vec<String> {
s.map(|v| {
v.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect()
})
.unwrap_or_default()
};
let seed = if let Some(u) = &args.users {
Seed::Users(split_csv(Some(u.clone())))
} else {
let repo =
match resolve_repo_from(repo_for_screen.as_ref(), args.repo.clone()) {
Ok(r) => r,
Err(msg) => return msg,
};
if let Some(err) = crate::git_refs::validate_repo(&repo) {
return err;
}
Seed::Repo(repo)
};
let cfg = screen::ScreenConfig {
max_stargazers: args.max_stargazers,
max_repos_per_user: 100,
relevance_keywords: split_csv(args.keywords)
.into_iter()
.map(|k| k.to_lowercase())
.collect(),
stack_languages: split_csv(args.stack),
};
let top = args.top.unwrap_or(10);
let filters = Filters {
min_keywords: args.min_keywords,
active_since: args.active_since.clone(),
adopters_only: args.adopters_only,
stack_only: args.stack_only,
..Default::default()
};
let filters_active = filters.min_keywords.is_some()
|| filters.active_since.is_some()
|| filters.adopters_only
|| filters.stack_only;
let selection: Option<Selection> = if let Some(name) = &args.preset {
screen::preset(name, top)
} else if args.rank_by.is_some() || filters_active {
Some(Selection {
filters,
rank: args
.rank_by
.as_deref()
.and_then(RankBy::parse)
.unwrap_or(RankBy::Relatedness),
label: "SELECTION".into(),
take: top,
})
} else {
None
};
screen::screen_dispatch(
&screen_store,
&seed,
&cfg,
selection.as_ref(),
args.element_id.as_deref(),
args.refresh,
)
},
);
}
}
pub fn builtins(&self) -> &crate::server::manifest::BuiltinsConfig {
&self.options.builtins
}
pub fn tool_router_mut(&mut self) -> &mut ToolRouter<McpServer> {
&mut self.tool_router
}
pub fn prompt_router_mut(&mut self) -> &mut PromptRouter<McpServer> {
&mut self.prompt_router
}
pub fn register_typed_tool<T, F>(
&mut self,
name: &'static str,
description: &'static str,
handler: F,
) where
T: for<'de> serde::Deserialize<'de>
+ schemars::JsonSchema
+ Default
+ Send
+ Sync
+ 'static,
F: Fn(T) -> String + Send + Sync + 'static,
{
self.register_typed_route(name, description, move |args: T| Ok(handler(args)));
}
pub fn register_typed_tool_fallible<T, F>(
&mut self,
name: &'static str,
description: &'static str,
handler: F,
) where
T: for<'de> serde::Deserialize<'de>
+ schemars::JsonSchema
+ Default
+ Send
+ Sync
+ 'static,
F: Fn(T) -> Result<String, String> + Send + Sync + 'static,
{
self.register_typed_route(name, description, handler);
}
fn register_typed_route<T, F>(
&mut self,
name: &'static str,
description: &'static str,
handler: F,
) where
T: for<'de> serde::Deserialize<'de>
+ schemars::JsonSchema
+ Default
+ Send
+ Sync
+ 'static,
F: Fn(T) -> Result<String, String> + Send + Sync + 'static,
{
use std::pin::Pin;
type DynFut<'a, R> = Pin<Box<dyn std::future::Future<Output = R> + Send + 'a>>;
let schema_obj = serde_json::to_value(schemars::schema_for!(T))
.ok()
.and_then(|v| v.as_object().cloned())
.unwrap_or_default();
let attr = rmcp::model::Tool::new(name, description, Arc::new(schema_obj));
let handler = std::sync::Arc::new(handler);
let tool_name = name;
let postprocess = self.options.result_postprocess.clone();
let source_roots = self.options.source_roots.clone();
let workspace = self.options.workspace.clone();
self.tool_router
.add_route(rmcp::handler::server::router::tool::ToolRoute::new_dyn(
attr,
move |ctx: rmcp::handler::server::tool::ToolCallContext<'_, McpServer>|
-> DynFut<'_, Result<rmcp::model::CallToolResponse, rmcp::ErrorData>> {
let handler = handler.clone();
let arguments = ctx.arguments.clone();
let postprocess = postprocess.clone();
let source_roots = source_roots.clone();
let workspace = workspace.clone();
Box::pin(async move {
Ok(dispatch_typed_call(
tool_name,
arguments,
handler.as_ref(),
postprocess.as_ref(),
source_roots.as_ref(),
workspace.as_ref(),
)
.into())
})
},
));
}
fn current_source_roots(&self) -> Vec<String> {
match &self.options.source_roots {
Some(provider) => provider(),
None => Vec::new(),
}
}
fn finish(&self, tool: &str, args: &serde_json::Value, body: String) -> String {
let Some(hook) = &self.options.result_postprocess else {
return body;
};
let ctx = ResultCtx {
source_roots: self.current_source_roots(),
active_repo: self
.options
.workspace
.as_ref()
.and_then(|w| w.active_repo_name()),
};
let footer = hook(tool, args, &body, &ctx);
append_footer(body, footer)
}
#[allow(dead_code)]
fn resolve_repo(&self, override_repo: Option<String>) -> Result<String, String> {
resolve_repo_from(self.options.default_repo.as_ref(), override_repo)
}
#[tool(
description = "Liveness probe — returns 'pong' (or echoes `message` if supplied). \
Use to confirm the server framework is wired correctly before \
relying on graph- or source-aware tools."
)]
async fn ping(
&self,
Parameters(args): Parameters<PingArgs>,
) -> Result<CallToolResult, McpError> {
let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
let body = args.message.unwrap_or_else(|| "pong".to_string());
let body = self.finish("ping", &args_json, body);
Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
}
#[tool(description = "Read a file from the configured source root(s). Pass \
`start_line`/`end_line` to slice, `grep` to filter to matching \
lines, `max_chars` to cap output. Pass `rev` (a tag, branch, or \
commit SHA) to read the file's content at that git revision via \
`git show` instead of the working tree — useful for comparing a \
file across releases (requires a git repo source root). Path \
traversal attempts are rejected. Available only when source roots \
are configured.")]
async fn read_source(
&self,
Parameters(args): Parameters<ReadSourceArgs>,
) -> Result<CallToolResult, McpError> {
let roots = self.current_source_roots();
if roots.is_empty() {
return Ok(CallToolResult::success(vec![ContentBlock::text(
"Cannot read source: no active source root. Configure source_root in your manifest \
or activate one (e.g. via repo_management in workspace mode).",
)]));
}
let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
let opts = ReadOpts {
start_line: args.start_line,
end_line: args.end_line,
grep: args.grep,
grep_context: args.grep_context,
max_matches: args.max_matches,
max_chars: args.max_chars,
rev: args.rev,
};
let body = source::read_source(&args.file_path, &roots, &opts);
let body = self.finish("read_source", &args_json, body);
Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
}
#[tool(
description = "Search source files using ripgrep. `pattern` is a regex (Rust \
syntax). `glob` filters file paths (e.g. \"*.py\"). `context` adds \
N surrounding lines per match. Set `case_insensitive=true` for \
case-insensitive matching. `max_results` caps total matches \
(default 50)."
)]
async fn grep(
&self,
Parameters(args): Parameters<GrepArgs>,
) -> Result<CallToolResult, McpError> {
let roots = self.current_source_roots();
if roots.is_empty() {
return Ok(CallToolResult::success(vec![ContentBlock::text(
"Cannot grep: no active source root. Configure source_root in your manifest \
or activate one (e.g. via repo_management in workspace mode).",
)]));
}
let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
let opts = GrepOpts {
glob: args.glob,
context: args.context,
max_results: Some(args.max_results.unwrap_or(50)),
case_insensitive: args.case_insensitive,
};
let body = source::grep(&roots, &args.pattern, &opts);
let body = self.finish("grep", &args_json, body);
Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
}
#[tool(
description = "List directory contents under the configured source root. `path` \
is resolved against the first source root (\".\" lists the root \
itself). `depth` controls recursion (1 = flat ls, 2+ = tree). \
`glob` filters entry names. `dirs_only=true` shows only \
directories."
)]
async fn list_source(
&self,
Parameters(args): Parameters<ListSourceArgs>,
) -> Result<CallToolResult, McpError> {
let roots = self.current_source_roots();
if roots.is_empty() {
return Ok(CallToolResult::success(vec![ContentBlock::text(
"Cannot list source: no active source root. Configure source_root in your \
manifest or activate one (e.g. via repo_management in workspace mode).",
)]));
}
let primary = std::path::PathBuf::from(&roots[0]);
let target = match resolve_dir_under_roots(&args.path, &roots) {
Some(p) => p,
None => {
return Ok(CallToolResult::success(vec![ContentBlock::text(format!(
"Error: path '{}' resolves outside the configured source roots.",
args.path
))]));
}
};
let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
let opts = ListOpts {
depth: args.depth,
glob: args.glob,
dirs_only: args.dirs_only,
};
let body = source::list_source(&target, &primary, &opts);
let body = self.finish("list_source", &args_json, body);
Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
}
#[tool(
description = "Manage GitHub repos in the workspace. Pass `name='org/repo'` to \
clone (if missing) and activate it as the source root for \
read_source / grep / list_source. Pass `delete=true` to remove a \
repo. Pass `update=true` to fetch upstream changes for the active \
repo (rebuild auto-skipped when HEAD hasn't moved since the last \
build; set `force_rebuild=true` to bypass). Pass `revs` (an \
integer N, or a list of git revspecs) to load multiple revisions \
of the repo into one graph — N loads the newest N stable release \
tags of the dominant tag family plus HEAD (prereleases and \
unrelated tag families skipped); a revs request always rebuilds. \
Call with no \
arguments to list all known repos with their last-access counts. \
Idle repos auto-sweep on each call (default 7 days, configurable \
via --stale-after-days)."
)]
async fn repo_management(
&self,
Parameters(args): Parameters<RepoManagementArgs>,
) -> Result<CallToolResult, McpError> {
let args_json = serde_json::to_value(&args).unwrap_or(serde_json::Value::Null);
let body = match &self.options.workspace {
Some(ws) => ws.repo_management(
args.name.as_deref(),
args.delete,
args.update,
args.force_rebuild,
args.revs.as_ref(),
),
None => "repo_management requires --workspace mode.".to_string(),
};
let body = self.finish("repo_management", &args_json, body);
Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
}
}
fn resolve_repo_from(
default_repo: Option<&RepoProvider>,
override_repo: Option<String>,
) -> Result<String, String> {
if let Some(r) = override_repo {
if let Some(err) = crate::git_refs::validate_repo(&r) {
return Err(err);
}
return Ok(r);
}
if let Some(provider) = default_repo {
if let Some(r) = provider() {
if let Some(err) = crate::git_refs::validate_repo(&r) {
return Err(err);
}
return Ok(r);
}
}
if let Some(detected) = crate::github::detect_git_repo(".") {
if crate::git_refs::validate_repo(&detected).is_none() {
return Ok(detected);
}
}
Err(
"No active repository. Pass `repo_name='org/repo'`, configure a default in the \
server, or run from a directory whose git remote points at github.com."
.to_string(),
)
}
pub fn serve_prompts(registry: &ResolvedRegistry, server: &mut McpServer) {
use std::borrow::Cow;
use std::collections::HashSet;
let registered_tools: HashSet<String> = server
.tool_router
.list_all()
.iter()
.map(|t| t.name.to_string())
.collect();
let extensions = server.options.extensions.clone();
struct InjectSkill {
name: String,
description: String,
body: String,
references_tools: Vec<String>,
}
let mut auto_inject: Vec<InjectSkill> = Vec::new();
for name in registry.skill_names() {
let Some(skill) = registry.get(&name) else {
continue;
};
let activation = registry.activation_for(skill, ®istered_tools, &extensions);
if !activation.active {
let failed_clauses: Vec<&str> = activation
.clauses
.iter()
.filter(|(_, outcome)| {
*outcome != crate::server::skills::PredicateOutcome::Satisfied
})
.map(|(clause, _)| clause.as_str())
.collect();
tracing::info!(
skill = %name,
suppressed_by = ?failed_clauses,
"skill suppressed by applies_when predicates"
);
continue;
}
let prompt = Prompt::new(
skill.name().to_string(),
Some(skill.description().to_string()),
None,
);
let body = skill.body.clone();
let route = PromptRoute::new_dyn(prompt, move |_ctx| {
let body = body.clone();
Box::pin(async move {
Ok(
GetPromptResult::new(vec![PromptMessage::new_text(Role::Assistant, body)])
.into(),
)
})
});
server.prompt_router.add_route(route);
if skill.frontmatter.auto_inject_hint {
auto_inject.push(InjectSkill {
name: skill.name().to_string(),
description: skill.description().to_string(),
body: skill.body.clone(),
references_tools: skill.frontmatter.references_tools.clone(),
});
}
}
for inj in &auto_inject {
let mut targets: Vec<&str> = Vec::new();
let mut seen: HashSet<&str> = HashSet::new();
for tool in std::iter::once(inj.name.as_str())
.chain(inj.references_tools.iter().map(String::as_str))
{
if seen.insert(tool) {
targets.push(tool);
}
}
let marker = format!("<!-- mcp-skill:{} -->", inj.name);
let mut block = format!("\n\n{marker}");
let description = inj.description.trim();
if !description.is_empty() {
block.push_str("\n\n## When to use\n\n");
block.push_str(description);
}
block.push_str("\n\n## Methodology\n\n");
block.push_str(inj.body.trim());
for tool in targets {
let key = Cow::<'static, str>::Owned(tool.to_string());
let Some(route) = server.tool_router.map.get_mut(&key) else {
continue;
};
if route
.attr
.description
.as_deref()
.is_some_and(|d| d.contains(&marker))
{
continue;
}
let new_desc = match route.attr.description.take() {
Some(existing) => format!("{existing}{block}"),
None => block.trim_start().to_string(),
};
route.attr.description = Some(Cow::Owned(new_desc));
}
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for McpServer {
fn get_info(&self) -> ServerInfo {
let name = self
.options
.name
.clone()
.unwrap_or_else(|| "MCP Server".to_string());
let mut caps = ServerCapabilities::builder().enable_tools().build();
if !self.prompt_router.map.is_empty() {
caps.prompts = Some(PromptsCapability::default());
}
let mut info = ServerInfo::new(caps)
.with_server_info(Implementation::new(name, env!("CARGO_PKG_VERSION")))
.with_protocol_version(ProtocolVersion::V_2024_11_05);
if let Some(text) = &self.options.instructions {
info = info.with_instructions(text.clone());
}
info
}
async fn on_initialized(&self, context: rmcp::service::NotificationContext<rmcp::RoleServer>) {
tracing::info!("client initialized");
crate::server::roots::on_client_initialized(&self.options, &context.peer).await;
}
async fn on_roots_list_changed(
&self,
context: rmcp::service::NotificationContext<rmcp::RoleServer>,
) {
crate::server::roots::on_client_roots_changed(&self.options, &context.peer).await;
}
async fn list_prompts(
&self,
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ListPromptsResult, McpError> {
Ok(ListPromptsResult {
prompts: self.prompt_router.list_all(),
..Default::default()
})
}
async fn get_prompt(
&self,
request: GetPromptRequestParams,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<GetPromptResponse, McpError> {
let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
self,
request.name,
request.arguments,
context,
);
self.prompt_router.get_prompt(prompt_context).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn options_from_manifest_uses_name_when_set() {
let opts = ServerOptions::from_manifest(None, "Fallback");
assert_eq!(opts.name.as_deref(), Some("Fallback"));
}
#[test]
fn builtins_exposed_via_server() {
use crate::server::manifest::{BuiltinsConfig, TempCleanup};
let opts = ServerOptions {
builtins: BuiltinsConfig {
save_graph: true,
temp_cleanup: TempCleanup::OnOverview,
..Default::default()
},
..ServerOptions::default()
};
let server = McpServer::new(opts);
assert!(server.builtins().save_graph);
assert_eq!(server.builtins().temp_cleanup, TempCleanup::OnOverview);
}
#[test]
fn server_constructs() {
let _server = McpServer::new(ServerOptions::default());
}
#[test]
fn static_source_roots_provider() {
let opts = ServerOptions::default()
.with_static_source_roots(vec!["/tmp/a".to_string(), "/tmp/b".to_string()]);
let server = McpServer::new(opts);
assert_eq!(
server.current_source_roots(),
vec!["/tmp/a".to_string(), "/tmp/b".to_string()]
);
}
#[test]
fn no_provider_returns_empty_roots() {
let server = McpServer::new(ServerOptions::default());
assert!(server.current_source_roots().is_empty());
}
#[test]
fn repo_management_gated_to_workspace_mode() {
let server = McpServer::new(ServerOptions::default());
let tools = server.tool_router.list_all();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(
!names.contains(&"repo_management"),
"repo_management should be gated out without a workspace; tools were {names:?}"
);
}
fn github_tool_surface(github_opt_in: bool, token_present: bool) -> Vec<String> {
use crate::server::manifest::BuiltinsConfig;
let _g = crate::github::env_lock();
let prev_token = std::env::var("GITHUB_TOKEN").ok();
let prev_alt = std::env::var("GH_TOKEN").ok();
unsafe {
std::env::remove_var("GH_TOKEN");
if token_present {
std::env::set_var("GITHUB_TOKEN", "ghp_surface_test_not_real");
} else {
std::env::remove_var("GITHUB_TOKEN");
}
}
let opts = ServerOptions {
builtins: BuiltinsConfig {
github: github_opt_in,
..Default::default()
},
..ServerOptions::default()
};
let server = McpServer::new(opts);
let names: Vec<String> = server
.tool_router
.list_all()
.iter()
.map(|t| t.name.to_string())
.collect();
unsafe {
match prev_token {
Some(v) => std::env::set_var("GITHUB_TOKEN", v),
None => std::env::remove_var("GITHUB_TOKEN"),
}
match prev_alt {
Some(v) => std::env::set_var("GH_TOKEN", v),
None => std::env::remove_var("GH_TOKEN"),
}
}
names
}
const GITHUB_TOOLS: [&str; 3] = ["github_issues", "github_api", "screen_stargazers"];
#[test]
fn github_tools_absent_by_default_even_with_a_token() {
let names = github_tool_surface(false, true);
for tool in GITHUB_TOOLS {
assert!(
!names.iter().any(|n| n == tool),
"{tool} registered without `builtins.github: true`; tools were {names:?}"
);
}
}
#[test]
fn github_tools_register_on_opt_in_with_a_token() {
let names = github_tool_surface(true, true);
for tool in GITHUB_TOOLS {
assert!(
names.iter().any(|n| n == tool),
"{tool} missing with `builtins.github: true` and a token; tools were {names:?}"
);
}
}
#[test]
fn github_tools_absent_on_opt_in_without_a_token() {
let names = github_tool_surface(true, false);
for tool in GITHUB_TOOLS {
assert!(
!names.iter().any(|n| n == tool),
"{tool} registered with no reachable token; tools were {names:?}"
);
}
}
#[test]
fn repo_management_present_when_workspace_bound() {
use crate::server::workspace::Workspace;
let dir = tempfile::tempdir().unwrap();
let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
let opts = ServerOptions::default().with_workspace(ws);
let server = McpServer::new(opts);
let tools = server.tool_router.list_all();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(
names.contains(&"repo_management"),
"repo_management should be registered with a workspace; tools were {names:?}"
);
}
#[test]
fn result_postprocess_appends_footer_and_sees_ctx() {
use std::sync::Mutex;
type Seen = Option<(String, serde_json::Value, String, Vec<String>)>;
let seen: Arc<Mutex<Seen>> = Arc::new(Mutex::new(None));
let seen_c = seen.clone();
let hook: ResultPostprocessHook = Arc::new(move |tool, args, body, ctx| {
*seen_c.lock().unwrap() = Some((
tool.to_string(),
args.clone(),
body.to_string(),
ctx.source_roots.clone(),
));
if tool == "grep" {
Some("↳ prefer cypher_query".to_string())
} else {
None
}
});
let opts = ServerOptions::default()
.with_static_source_roots(vec!["/src".to_string()])
.with_result_postprocess(hook);
let server = McpServer::new(opts);
let args = serde_json::json!({ "pattern": "^fn " });
let out = server.finish("grep", &args, "match line".to_string());
assert_eq!(out, "match line\n\n↳ prefer cypher_query");
let rec = seen.lock().unwrap().clone().unwrap();
assert_eq!(rec.0, "grep");
assert_eq!(rec.1, args);
assert_eq!(rec.2, "match line");
assert_eq!(rec.3, vec!["/src".to_string()]);
let out2 = server.finish("read_source", &args, "file body".to_string());
assert_eq!(out2, "file body");
}
#[test]
fn no_result_postprocess_leaves_body_unchanged() {
let server = McpServer::new(ServerOptions::default());
let out = server.finish("grep", &serde_json::Value::Null, "x".to_string());
assert_eq!(out, "x");
}
#[test]
fn append_footer_ignores_empty_footers() {
assert_eq!(append_footer("a".to_string(), None), "a");
assert_eq!(append_footer("a".to_string(), Some(String::new())), "a");
assert_eq!(
append_footer("a".to_string(), Some("b".to_string())),
"a\n\nb"
);
}
#[test]
fn dynamic_provider_swaps_at_call_time() {
use std::sync::Mutex;
let state = Arc::new(Mutex::new(vec!["/initial".to_string()]));
let s2 = state.clone();
let provider: SourceRootsProvider = Arc::new(move || s2.lock().unwrap().clone());
let opts = ServerOptions::default().with_dynamic_source_roots(provider);
let server = McpServer::new(opts);
assert_eq!(server.current_source_roots(), vec!["/initial".to_string()]);
*state.lock().unwrap() = vec!["/swapped".to_string()];
assert_eq!(server.current_source_roots(), vec!["/swapped".to_string()]);
}
#[derive(Default, serde::Deserialize, schemars::JsonSchema)]
struct EchoArgs {
#[serde(default)]
text: String,
#[serde(default)]
count: u32,
}
fn result_text(result: &CallToolResult) -> String {
result
.content
.iter()
.filter_map(|c| c.as_text().map(|t| t.text.clone()))
.collect::<Vec<_>>()
.join("")
}
fn footer_hook() -> ResultPostprocessHook {
Arc::new(|_tool, _args, _body, _ctx| Some("↳ footer".to_string()))
}
fn args_map(json: serde_json::Value) -> Option<rmcp::model::JsonObject> {
json.as_object().cloned()
}
#[test]
fn fallible_ok_reports_success_with_footer() {
let hook = footer_hook();
let out = dispatch_typed_call(
"echo",
args_map(serde_json::json!({ "text": "hi", "count": 2 })),
&|args: EchoArgs| Ok(format!("{} x{}", args.text, args.count)),
Some(&hook),
None,
None,
);
assert_eq!(out.is_error, Some(false));
assert_eq!(result_text(&out), "hi x2\n\n↳ footer");
}
#[test]
fn fallible_err_sets_is_error_and_keeps_footer() {
let hook = footer_hook();
let out = dispatch_typed_call(
"echo",
args_map(serde_json::json!({ "text": "hi" })),
&|_args: EchoArgs| Err::<String, String>("no rows matched".to_string()),
Some(&hook),
None,
None,
);
assert_eq!(out.is_error, Some(true));
assert_eq!(result_text(&out), "no rows matched\n\n↳ footer");
}
#[test]
fn fallible_err_without_hook_is_error_text_verbatim() {
let out = dispatch_typed_call(
"echo",
args_map(serde_json::json!({})),
&|_args: EchoArgs| Err::<String, String>("boom".to_string()),
None,
None,
None,
);
assert_eq!(out.is_error, Some(true));
assert_eq!(result_text(&out), "boom");
}
#[test]
fn postprocess_ctx_reaches_both_arms() {
use std::sync::Mutex;
type Seen = Arc<Mutex<Vec<(String, Vec<String>)>>>;
let seen: Seen = Arc::new(Mutex::new(Vec::new()));
let seen_c = seen.clone();
let hook: ResultPostprocessHook = Arc::new(move |_tool, _args, body, ctx| {
seen_c
.lock()
.unwrap()
.push((body.to_string(), ctx.source_roots.clone()));
None
});
let roots: SourceRootsProvider = Arc::new(|| vec!["/src".to_string()]);
for handler_result in ["ok", "err"] {
let _ = dispatch_typed_call(
"echo",
args_map(serde_json::json!({})),
&|_args: EchoArgs| {
if handler_result == "ok" {
Ok("body".to_string())
} else {
Err("failed".to_string())
}
},
Some(&hook),
Some(&roots),
None,
);
}
let rec = seen.lock().unwrap().clone();
assert_eq!(rec.len(), 2, "hook must run on both arms");
assert_eq!(rec[0].0, "body");
assert_eq!(rec[1].0, "failed");
for (_, roots) in &rec {
assert_eq!(roots, &vec!["/src".to_string()]);
}
}
#[test]
fn invalid_arguments_set_is_error_on_both_registrations() {
let bad = || args_map(serde_json::json!({ "count": "not a number" }));
let fallible = dispatch_typed_call(
"echo",
bad(),
&|_args: EchoArgs| Ok("unreachable".to_string()),
None,
None,
None,
);
assert_eq!(fallible.is_error, Some(true));
assert!(
result_text(&fallible).starts_with("invalid arguments: "),
"got {:?}",
result_text(&fallible)
);
let plain_handler = |_args: EchoArgs| "unreachable".to_string();
let plain = dispatch_typed_call(
"echo",
bad(),
&move |args: EchoArgs| Ok(plain_handler(args)),
None,
None,
None,
);
assert_eq!(plain.is_error, Some(true));
assert!(result_text(&plain).starts_with("invalid arguments: "));
}
#[test]
fn invalid_arguments_still_get_the_footer() {
let hook = footer_hook();
let out = dispatch_typed_call(
"echo",
args_map(serde_json::json!({ "count": "not a number" })),
&|_args: EchoArgs| Ok("unreachable".to_string()),
Some(&hook),
None,
None,
);
assert_eq!(out.is_error, Some(true));
assert!(result_text(&out).ends_with("\n\n↳ footer"));
}
#[test]
fn plain_handler_success_unchanged() {
let hook = footer_hook();
let plain_handler = |args: EchoArgs| format!("said {}", args.text);
let out = dispatch_typed_call(
"echo",
args_map(serde_json::json!({ "text": "hello" })),
&move |args: EchoArgs| Ok(plain_handler(args)),
Some(&hook),
None,
None,
);
assert_eq!(out.is_error, Some(false));
assert_eq!(result_text(&out), "said hello\n\n↳ footer");
}
#[test]
fn missing_arguments_fall_back_to_default_args() {
let out = dispatch_typed_call(
"echo",
None,
&|args: EchoArgs| Ok(format!("[{}]", args.text)),
None,
None,
None,
);
assert_eq!(out.is_error, Some(false));
assert_eq!(result_text(&out), "[]");
}
#[test]
fn both_registrations_reach_the_router() {
let mut server = McpServer::new(ServerOptions::default());
server.register_typed_tool("echo_plain", "plain", |args: EchoArgs| args.text);
server.register_typed_tool_fallible("echo_fallible", "fallible", |args: EchoArgs| {
if args.text.is_empty() {
Err("text is required".to_string())
} else {
Ok(args.text)
}
});
let names: Vec<String> = server
.tool_router
.list_all()
.iter()
.map(|t| t.name.to_string())
.collect();
assert!(names.iter().any(|n| n == "echo_plain"), "{names:?}");
assert!(names.iter().any(|n| n == "echo_fallible"), "{names:?}");
}
fn build_test_registry(
skills: &[(&str, &str, &str, bool)],
) -> crate::server::skills::ResolvedRegistry {
use crate::server::skills::Registry;
let dir = tempfile::tempdir().unwrap();
let yaml_path = dir.path().join("manifest.yaml");
let skills_dir = dir.path().join("manifest.skills");
std::fs::create_dir_all(&skills_dir).unwrap();
for (name, description, body, auto_inject) in skills {
let auto = if *auto_inject { "true" } else { "false" };
let content = format!(
"---\nname: {name}\ndescription: {description}\nauto_inject_hint: {auto}\n---\n\n{body}\n"
);
std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
}
Registry::new()
.auto_detect_project_layer(&yaml_path)
.finalise()
.unwrap()
}
fn build_registry_with_refs(
skills: &[(&str, &str, &str, &str)],
) -> crate::server::skills::ResolvedRegistry {
use crate::server::skills::Registry;
let dir = tempfile::tempdir().unwrap();
let yaml_path = dir.path().join("manifest.yaml");
let skills_dir = dir.path().join("manifest.skills");
std::fs::create_dir_all(&skills_dir).unwrap();
for (name, description, body, references_tools) in skills {
let content = format!(
"---\nname: {name}\ndescription: {description}\n\
auto_inject_hint: true\nreferences_tools: {references_tools}\n---\n\n{body}\n"
);
std::fs::write(skills_dir.join(format!("{name}.md")), content).unwrap();
}
Registry::new()
.auto_detect_project_layer(&yaml_path)
.finalise()
.unwrap()
}
fn tool_desc(server: &McpServer, tool: &str) -> String {
server
.tool_router
.get(tool)
.and_then(|t| t.description.clone())
.map(|c| c.into_owned())
.unwrap_or_default()
}
#[test]
fn prompt_router_empty_by_default() {
let server = McpServer::new(ServerOptions::default());
assert!(server.prompt_router.map.is_empty());
}
#[test]
fn get_info_no_prompts_capability_when_empty() {
let server = McpServer::new(ServerOptions::default());
let info = server.get_info();
assert!(
info.capabilities.prompts.is_none(),
"prompts capability must be absent when no skills are registered"
);
}
#[test]
fn serve_prompts_registers_routes_with_metadata() {
let registry = build_test_registry(&[
("alpha", "First skill.", "Alpha body.", true),
("beta", "Second skill.", "Beta body.", true),
]);
let mut server = McpServer::new(ServerOptions::default());
super::serve_prompts(®istry, &mut server);
let prompts = server.prompt_router.list_all();
let names: Vec<&str> = prompts.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "beta"]);
let alpha = prompts.iter().find(|p| p.name == "alpha").unwrap();
assert_eq!(alpha.description.as_deref(), Some("First skill."));
assert!(alpha.arguments.is_none());
}
#[test]
fn serve_prompts_empty_registry_is_noop() {
let registry = crate::server::skills::ResolvedRegistry::default();
let mut server = McpServer::new(ServerOptions::default());
super::serve_prompts(®istry, &mut server);
assert!(server.prompt_router.map.is_empty());
assert!(server.get_info().capabilities.prompts.is_none());
}
#[test]
fn get_info_advertises_prompts_when_present() {
let registry = build_test_registry(&[("alpha", "First skill.", "Alpha body.", true)]);
let mut server = McpServer::new(ServerOptions::default());
super::serve_prompts(®istry, &mut server);
let info = server.get_info();
assert!(
info.capabilities.prompts.is_some(),
"prompts capability must be advertised once a skill is registered"
);
}
#[test]
fn serve_prompts_auto_injects_full_body_into_matching_tool() {
let registry =
build_test_registry(&[("ping", "Ping methodology.", "PING-BODY-SENTINEL", true)]);
let mut server = McpServer::new(ServerOptions::default());
let before = server
.tool_router
.get("ping")
.and_then(|t| t.description.clone())
.map(|c| c.into_owned())
.unwrap_or_default();
super::serve_prompts(®istry, &mut server);
let after = server
.tool_router
.get("ping")
.and_then(|t| t.description.clone())
.map(|c| c.into_owned())
.unwrap_or_default();
assert!(after.starts_with(&before), "original description preserved");
assert!(
after.contains("## Methodology"),
"inject should include a Methodology header; got: {after}"
);
assert!(
after.contains("PING-BODY-SENTINEL"),
"inject should embed the full skill body; got: {after}"
);
assert!(
!after.contains("prompts/get"),
"post-0.3.37 inject should NOT reference the prompts/get surface (agents can't reach it); got: {after}"
);
}
#[test]
fn serve_prompts_skips_injection_when_disabled() {
let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", false)]);
let mut server = McpServer::new(ServerOptions::default());
let before = server
.tool_router
.get("ping")
.and_then(|t| t.description.clone())
.map(|c| c.into_owned())
.unwrap_or_default();
super::serve_prompts(®istry, &mut server);
let after = server
.tool_router
.get("ping")
.and_then(|t| t.description.clone())
.map(|c| c.into_owned())
.unwrap_or_default();
assert_eq!(
before, after,
"auto_inject_hint=false must leave tool description untouched"
);
}
#[test]
fn serve_prompts_skips_injection_when_no_matching_tool() {
let registry = build_test_registry(&[("no_such_tool", "Methodology.", "Body.", true)]);
let mut server = McpServer::new(ServerOptions::default());
super::serve_prompts(®istry, &mut server);
assert!(server.prompt_router.map.contains_key("no_such_tool"));
let ping_desc = server
.tool_router
.get("ping")
.and_then(|t| t.description.clone())
.map(|c| c.into_owned())
.unwrap_or_default();
assert!(!ping_desc.contains("no_such_tool"));
}
#[test]
fn serve_prompts_injects_description_under_when_to_use() {
let registry = build_test_registry(&[("ping", "ROUTING-SENTINEL", "BODY-SENTINEL", true)]);
let mut server = McpServer::new(ServerOptions::default());
super::serve_prompts(®istry, &mut server);
let desc = tool_desc(&server, "ping");
assert!(
desc.contains("## When to use\n\nROUTING-SENTINEL"),
"description should be injected under `## When to use`; got: {desc}"
);
assert!(
desc.contains("<!-- mcp-skill:ping -->"),
"injection should carry the per-skill idempotency marker; got: {desc}"
);
let when = desc.find("## When to use").unwrap();
let method = desc.find("## Methodology").unwrap();
assert!(when < method, "`When to use` must precede `Methodology`");
}
#[test]
fn serve_prompts_honors_references_tools() {
let registry = build_registry_with_refs(&[(
"graph_strategy",
"Map structure first.",
"GRAPH-BODY-SENTINEL",
"[ping]",
)]);
let mut server = McpServer::new(ServerOptions::default());
super::serve_prompts(®istry, &mut server);
assert!(server.prompt_router.map.contains_key("graph_strategy"));
let desc = tool_desc(&server, "ping");
assert!(
desc.contains("<!-- mcp-skill:graph_strategy -->"),
"referenced tool should carry the skill marker; got: {desc}"
);
assert!(
desc.contains("Map structure first."),
"referenced tool should carry the skill routing; got: {desc}"
);
assert!(
desc.contains("GRAPH-BODY-SENTINEL"),
"referenced tool should carry the skill body; got: {desc}"
);
}
#[test]
fn serve_prompts_idempotent_when_skill_self_references() {
let registry = build_registry_with_refs(&[("ping", "Routing.", "Body.", "[ping]")]);
let mut server = McpServer::new(ServerOptions::default());
super::serve_prompts(®istry, &mut server);
let desc = tool_desc(&server, "ping");
let marker_count = desc.matches("<!-- mcp-skill:ping -->").count();
assert_eq!(
marker_count, 1,
"self-referencing skill must inject exactly once; got {marker_count}: {desc}"
);
}
#[test]
fn serve_prompts_idempotent_across_repeated_passes() {
let registry = build_test_registry(&[("ping", "Routing.", "Body.", true)]);
let mut server = McpServer::new(ServerOptions::default());
super::serve_prompts(®istry, &mut server);
let once = tool_desc(&server, "ping");
super::serve_prompts(®istry, &mut server);
let twice = tool_desc(&server, "ping");
assert_eq!(
once, twice,
"second pass must be a no-op for an already-injected tool"
);
}
#[test]
fn serve_prompts_multiple_skills_stack_on_one_tool() {
let registry = build_registry_with_refs(&[
("ping", "Ping routing.", "PING-BODY", "[]"),
("ping_strategy", "Strategy routing.", "STRAT-BODY", "[ping]"),
]);
let mut server = McpServer::new(ServerOptions::default());
super::serve_prompts(®istry, &mut server);
let desc = tool_desc(&server, "ping");
assert!(desc.contains("<!-- mcp-skill:ping -->"), "got: {desc}");
assert!(
desc.contains("<!-- mcp-skill:ping_strategy -->"),
"got: {desc}"
);
assert!(
desc.contains("PING-BODY") && desc.contains("STRAT-BODY"),
"got: {desc}"
);
}
fn write_gated_project_skill(applies_when_yaml: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let yaml = dir.path().join("test_mcp.yaml");
std::fs::write(&yaml, "name: t\nskills: true\n").unwrap();
let skills_dir = dir.path().join("test_mcp.skills");
std::fs::create_dir(&skills_dir).unwrap();
std::fs::write(
skills_dir.join("gated_skill.md"),
format!(
"---\n\
name: gated_skill\n\
description: A predicate-gated skill for testing.\n\
applies_when:\n\
{applies_when_yaml}\n\
---\n\n\
Body.\n",
),
)
.unwrap();
dir
}
#[test]
fn serve_prompts_suppresses_skill_with_unsatisfied_predicate() {
use crate::server::skills::Registry as SkillsBuilder;
let dir = write_gated_project_skill(" tool_registered: nonexistent_tool");
let yaml = dir.path().join("test_mcp.yaml");
let registry = SkillsBuilder::new()
.auto_detect_project_layer(&yaml)
.finalise()
.unwrap();
let mut server = McpServer::new(ServerOptions::default());
super::serve_prompts(®istry, &mut server);
assert!(
!server.prompt_router.map.contains_key("gated_skill"),
"skill with unsatisfied predicate must be suppressed"
);
}
#[test]
fn serve_prompts_keeps_skill_with_satisfied_predicate() {
use crate::server::skills::Registry as SkillsBuilder;
let dir = write_gated_project_skill(" tool_registered: ping");
let yaml = dir.path().join("test_mcp.yaml");
let registry = SkillsBuilder::new()
.auto_detect_project_layer(&yaml)
.finalise()
.unwrap();
let mut server = McpServer::new(ServerOptions::default());
super::serve_prompts(®istry, &mut server);
assert!(
server.prompt_router.map.contains_key("gated_skill"),
"skill with satisfied predicate must register"
);
}
#[test]
fn serve_prompts_evaluates_extension_enabled_from_manifest() {
use crate::server::skills::Registry as SkillsBuilder;
let dir = write_gated_project_skill(" extension_enabled: csv_http_server");
let yaml = dir.path().join("test_mcp.yaml");
let registry = SkillsBuilder::new()
.auto_detect_project_layer(&yaml)
.finalise()
.unwrap();
let mut server = McpServer::new(ServerOptions::default());
super::serve_prompts(®istry, &mut server);
assert!(!server.prompt_router.map.contains_key("gated_skill"));
let mut extensions = serde_json::Map::new();
extensions.insert("csv_http_server".to_string(), serde_json::json!(true));
let opts = ServerOptions {
extensions,
..ServerOptions::default()
};
let mut server = McpServer::new(opts);
super::serve_prompts(®istry, &mut server);
assert!(server.prompt_router.map.contains_key("gated_skill"));
}
}