#![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>;
#[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,
}
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(),
}
}
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.active_repo_name()));
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>,
}
#[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,
}
#[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,
}
#[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(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. \
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)
},
);
}
fn register_github_tools_if_authorized(&mut self) {
if !crate::github::has_git_token() {
tracing::info!(
"GITHUB_TOKEN not set — github_issues / github_api tools hidden from the agent. \
Set the env var 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;
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 an absolute resource (\"search/issues?q=...\", \
\"users/octocat\") which passes through. 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,
},
);
}
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,
{
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);
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::CallToolResult, rmcp::ErrorData>> {
let handler = handler.clone();
let arguments = ctx.arguments.clone();
Box::pin(async move {
let args: T = match arguments {
Some(map) => {
match serde_json::from_value(serde_json::Value::Object(map)) {
Ok(a) => a,
Err(e) => {
return Ok(rmcp::model::CallToolResult::success(vec![
rmcp::model::Content::text(format!(
"invalid arguments: {e}"
)),
]));
}
}
}
None => T::default(),
};
let body = handler(args);
Ok(rmcp::model::CallToolResult::success(vec![
rmcp::model::Content::text(body),
]))
})
},
));
}
fn current_source_roots(&self) -> Vec<String> {
match &self.options.source_roots {
Some(provider) => provider(),
None => Vec::new(),
}
}
#[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 body = args.message.unwrap_or_else(|| "pong".to_string());
Ok(CallToolResult::success(vec![Content::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. 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![Content::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 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,
};
let body = source::read_source(&args.file_path, &roots, &opts);
Ok(CallToolResult::success(vec![Content::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![Content::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 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);
Ok(CallToolResult::success(vec![Content::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![Content::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![Content::text(format!(
"Error: path '{}' resolves outside the configured source roots.",
args.path
))]));
}
};
let opts = ListOpts {
depth: args.depth,
glob: args.glob,
dirs_only: args.dirs_only,
};
let body = source::list_source(&target, &primary, &opts);
Ok(CallToolResult::success(vec![Content::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). 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 body = match &self.options.workspace {
Some(ws) => ws.repo_management(
args.name.as_deref(),
args.delete,
args.update,
args.force_rebuild,
),
None => "repo_management requires --workspace mode.".to_string(),
};
Ok(CallToolResult::success(vec![Content::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;
let mut auto_inject: Vec<(String, String)> = Vec::new();
for name in registry.skill_names() {
let Some(skill) = registry.get(&name) else {
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(
PromptMessageRole::Assistant,
body,
)]))
})
});
server.prompt_router.add_route(route);
if skill.frontmatter.auto_inject_hint {
auto_inject.push((skill.name().to_string(), skill.description().to_string()));
}
}
for (skill_name, _desc) in &auto_inject {
let key = Cow::<'static, str>::Owned(skill_name.clone());
if let Some(route) = server.tool_router.map.get_mut(&key) {
let hint = format!("\n\nSee `prompts/get` `{skill_name}` for the full methodology.");
let new_desc = match route.attr.description.take() {
Some(existing) => format!("{existing}{hint}"),
None => hint.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 list_prompts(
&self,
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ListPromptsResult, McpError> {
Ok(ListPromptsResult {
meta: None,
next_cursor: None,
prompts: self.prompt_router.list_all(),
})
}
async fn get_prompt(
&self,
request: GetPromptRequestParams,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<GetPromptResult, 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,
},
..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:?}"
);
}
#[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 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()]);
}
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()
}
#[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_hint_into_matching_tool() {
let registry = build_test_registry(&[("ping", "Ping methodology.", "Ping body.", 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("`prompts/get`") && after.contains("`ping`"),
"hint should reference prompts/get and the skill name; 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"));
}
}