use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rmcp::model::*;
use rmcp::service::RequestContext;
use rmcp::{ErrorData as McpError, RoleServer, ServerHandler};
use crate::executor;
use crate::skills::models::Skill;
use crate::skills::parser::parse_argument_hint;
const TOOL_GET_AVAILABLE_SKILLS: &str = "get_available_skills";
const TOOL_GET_SKILL_DETAILS: &str = "get_skill_details";
const TOOL_GET_SKILL_RELATED_FILE: &str = "get_skill_related_file";
#[derive(Clone)]
pub struct SkillsServer {
skills: Vec<Skill>,
skill_index: HashMap<String, usize>,
tool_index: HashMap<String, (usize, usize)>,
resource_index: HashMap<String, (usize, usize)>,
}
impl SkillsServer {
pub fn new(skills: Vec<Skill>) -> Self {
let mut skill_index = HashMap::new();
let mut tool_index = HashMap::new();
let mut resource_index = HashMap::new();
for (si, skill) in skills.iter().enumerate() {
skill_index.insert(skill.name.clone(), si);
for (sci, script) in skill.scripts.iter().enumerate() {
let tool_name = Self::make_tool_name(&skill.name, &script.name);
tool_index.insert(tool_name, (si, sci));
}
for (ri, reference) in skill.references.iter().enumerate() {
resource_index.insert(reference.uri.clone(), (si, ri));
}
}
Self {
skills,
skill_index,
tool_index,
resource_index,
}
}
pub fn skills(&self) -> &[Skill] {
&self.skills
}
fn make_tool_name(skill_name: &str, script_name: &str) -> String {
let stem = script_name
.rsplit_once('.')
.map(|(s, _)| s)
.unwrap_or(script_name);
format!(
"{}__{}",
skill_name.replace('-', "_"),
stem.replace('-', "_"),
)
}
fn build_script_input_schema() -> Arc<JsonObject> {
let mut props = serde_json::Map::new();
let mut args_obj = serde_json::Map::new();
args_obj.insert("type".into(), "string".into());
args_obj.insert(
"description".into(),
"Arguments to pass to the script".into(),
);
props.insert("args".into(), serde_json::Value::Object(args_obj));
let mut schema = serde_json::Map::new();
schema.insert("type".into(), "object".into());
schema.insert("properties".into(), serde_json::Value::Object(props));
Arc::new(schema)
}
fn build_available_skills_schema() -> Arc<JsonObject> {
let mut schema = serde_json::Map::new();
schema.insert("type".into(), "object".into());
schema.insert(
"properties".into(),
serde_json::Value::Object(serde_json::Map::new()),
);
Arc::new(schema)
}
fn build_skill_details_schema() -> Arc<JsonObject> {
let mut props = serde_json::Map::new();
props.insert("name".into(), string_property("Skill name to inspect"));
props.insert(
"return_type".into(),
string_property("One of: content, file_path, both"),
);
object_schema(props, &["name"])
}
fn build_skill_related_file_schema() -> Arc<JsonObject> {
let mut props = serde_json::Map::new();
props.insert(
"skill_name".into(),
string_property("Skill name containing the file"),
);
props.insert(
"relative_path".into(),
string_property("Path relative to the skill directory"),
);
props.insert(
"return_type".into(),
string_property("One of: content, file_path, both"),
);
object_schema(props, &["skill_name", "relative_path"])
}
fn hybrid_tools() -> Vec<Tool> {
vec![
Tool::new(
TOOL_GET_AVAILABLE_SKILLS.to_string(),
"List available skills with their prompt, tool, and resource metadata".to_string(),
Self::build_available_skills_schema(),
),
Tool::new(
TOOL_GET_SKILL_DETAILS.to_string(),
"Get detailed information for a skill, including its prompt body and file listing"
.to_string(),
Self::build_skill_details_schema(),
),
Tool::new(
TOOL_GET_SKILL_RELATED_FILE.to_string(),
"Read a file from within a skill directory using a safe relative path".to_string(),
Self::build_skill_related_file_schema(),
),
]
}
fn list_available_skills(&self) -> serde_json::Value {
serde_json::Value::Array(
self.skills
.iter()
.map(|skill| {
serde_json::json!({
"name": skill.name,
"description": skill.frontmatter.description,
"prompt_name": skill.name,
"tools": skill
.scripts
.iter()
.map(|script| Self::make_tool_name(&skill.name, &script.name))
.collect::<Vec<_>>(),
"resources": skill
.references
.iter()
.map(|reference| reference.uri.clone())
.collect::<Vec<_>>(),
})
})
.collect(),
)
}
fn get_skill_details(
&self,
args: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Result<CallToolResult, McpError> {
let skill_name = required_string_arg(args, "name")?;
let return_type = return_type_arg(args)?;
let skill = self
.skill_index
.get(skill_name)
.map(|idx| &self.skills[*idx])
.ok_or_else(|| {
McpError::invalid_params(format!("Unknown skill: {}", skill_name), None)
})?;
let skill_md = skill.base_dir.join("SKILL.md");
let file_path = skill_md.display().to_string();
let content = std::fs::read_to_string(&skill_md).map_err(|e| {
McpError::internal_error(
format!("Failed to read {}: {}", skill_md.display(), e),
None,
)
})?;
match return_type {
ReturnType::Content => Ok(CallToolResult::success(vec![Content::text(content)])),
ReturnType::FilePath => Ok(CallToolResult::success(vec![Content::text(file_path)])),
ReturnType::Both => json_success(serde_json::json!({
"name": skill.name,
"description": skill.frontmatter.description,
"prompt_name": skill.name,
"skill_path": file_path,
"skill_content": content,
"tools": skill
.scripts
.iter()
.map(|script| Self::make_tool_name(&skill.name, &script.name))
.collect::<Vec<_>>(),
"resources": skill
.references
.iter()
.map(|reference| reference.uri.clone())
.collect::<Vec<_>>(),
"files": list_skill_files(&skill.base_dir)?,
})),
}
}
fn get_skill_related_file(
&self,
args: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Result<CallToolResult, McpError> {
let skill_name = required_string_arg(args, "skill_name")?;
let relative_path = required_string_arg(args, "relative_path")?;
let return_type = return_type_arg(args)?;
let skill = self
.skill_index
.get(skill_name)
.map(|idx| &self.skills[*idx])
.ok_or_else(|| {
McpError::invalid_params(format!("Unknown skill: {}", skill_name), None)
})?;
let resolved_path = resolve_skill_file_path(skill, relative_path)?;
let file_path = resolved_path.display().to_string();
match return_type {
ReturnType::Content => {
let content = read_text_file(&resolved_path)?;
Ok(CallToolResult::success(vec![Content::text(content)]))
}
ReturnType::FilePath => Ok(CallToolResult::success(vec![Content::text(file_path)])),
ReturnType::Both => {
let content = read_text_file(&resolved_path)?;
json_success(serde_json::json!({
"skill_name": skill.name,
"relative_path": relative_path,
"file_path": file_path,
"mime_type": mime_from_name(relative_path),
"content": content,
}))
}
}
}
}
impl ServerHandler for SkillsServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_tools()
.enable_prompts()
.enable_resources()
.build(),
)
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
let schema = Self::build_script_input_schema();
let mut tools = Self::hybrid_tools();
for skill in &self.skills {
for script in &skill.scripts {
let tool_name = Self::make_tool_name(&skill.name, &script.name);
let tool = Tool::new(
tool_name,
format!("Run {} from skill '{}'", script.name, skill.name),
schema.clone(),
);
tools.push(tool);
}
}
Ok(ListToolsResult {
tools,
next_cursor: None,
meta: None,
})
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let tool_name: &str = request.name.as_ref();
match tool_name {
TOOL_GET_AVAILABLE_SKILLS => return json_success(self.list_available_skills()),
TOOL_GET_SKILL_DETAILS => return self.get_skill_details(request.arguments.as_ref()),
TOOL_GET_SKILL_RELATED_FILE => {
return self.get_skill_related_file(request.arguments.as_ref())
}
_ => {}
}
let (si, sci) = self.tool_index.get(tool_name).ok_or_else(|| {
McpError::invalid_params(format!("Unknown tool: {}", tool_name), None)
})?;
let skill = &self.skills[*si];
let script = &skill.scripts[*sci];
let args_str = request
.arguments
.as_ref()
.and_then(|args| args.get("args"))
.and_then(|v| v.as_str())
.unwrap_or("");
let args: Vec<&str> = if args_str.is_empty() {
vec![]
} else {
args_str.split_whitespace().collect()
};
match executor::execute_script(&script.path, &args, &skill.base_dir, 30).await {
Ok(result) => {
let mut output = result.stdout;
if result.exit_code != 0 {
output.push_str(&format!(
"\nSTDERR: {}\nExit code: {}",
result.stderr, result.exit_code
));
return Ok(CallToolResult::error(vec![Content::text(output)]));
}
Ok(CallToolResult::success(vec![Content::text(output)]))
}
Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
}
}
async fn list_prompts(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListPromptsResult, McpError> {
let prompts: Vec<Prompt> = self
.skills
.iter()
.map(|skill| {
let args = skill
.frontmatter
.argument_hint
.as_deref()
.map(parse_argument_hint)
.unwrap_or_default();
let prompt_args: Vec<PromptArgument> = args
.iter()
.map(|a| {
PromptArgument::new(a.name.clone())
.with_description(a.description.clone())
.with_required(a.required)
})
.collect();
Prompt::new(
skill.name.clone(),
Some(skill.frontmatter.description.clone()),
Some(prompt_args),
)
})
.collect();
Ok(ListPromptsResult {
prompts,
next_cursor: None,
meta: None,
})
}
async fn get_prompt(
&self,
request: GetPromptRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<GetPromptResult, McpError> {
let skill_idx = self.skill_index.get(&request.name).ok_or_else(|| {
McpError::invalid_params(format!("Unknown prompt: {}", request.name), None)
})?;
let skill = &self.skills[*skill_idx];
let mut body = skill.body.clone();
if let Some(ref args) = request.arguments {
if let Some(full_args) = args.get("arguments").and_then(|v| v.as_str()) {
body = body.replace("$ARGUMENTS", full_args);
}
for (key, value) in args {
if let Some(val_str) = value.as_str() {
let placeholder = format!("${}", key.to_uppercase().replace('-', "_"));
body = body.replace(&placeholder, val_str);
}
}
}
body = body.replace("$ARGUMENTS", "");
let messages = vec![PromptMessage::new_text(PromptMessageRole::User, body)];
Ok(GetPromptResult::new(messages).with_description(skill.frontmatter.description.clone()))
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, McpError> {
let resources: Vec<Resource> = self
.skills
.iter()
.flat_map(|skill| {
skill.references.iter().map(|r| {
let raw = RawResource::new(r.uri.clone(), r.name.clone())
.with_description(format!("Reference from skill '{}'", skill.name))
.with_mime_type(mime_from_name(&r.name));
Annotated::new(raw, None)
})
})
.collect();
Ok(ListResourcesResult {
resources,
next_cursor: None,
meta: None,
})
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, McpError> {
let uri_str = request.uri.as_str();
let (si, ri) = self.resource_index.get(uri_str).ok_or_else(|| {
McpError::invalid_params(format!("Unknown resource: {}", uri_str), None)
})?;
let reference = &self.skills[*si].references[*ri];
let content = std::fs::read_to_string(&reference.path).map_err(|e| {
McpError::internal_error(
format!("Failed to read {}: {}", reference.path.display(), e),
None,
)
})?;
Ok(ReadResourceResult::new(vec![ResourceContents::text(
content, uri_str,
)]))
}
}
fn mime_from_name(name: &str) -> String {
match name.rsplit('.').next() {
Some("md") => "text/markdown".to_string(),
Some("json") => "application/json".to_string(),
Some("yaml" | "yml") => "text/yaml".to_string(),
Some("txt") => "text/plain".to_string(),
Some("sh") => "text/x-shellscript".to_string(),
Some("py") => "text/x-python".to_string(),
_ => "text/plain".to_string(),
}
}
#[derive(Clone, Copy)]
enum ReturnType {
Content,
FilePath,
Both,
}
fn string_property(description: &str) -> serde_json::Value {
let mut obj = serde_json::Map::new();
obj.insert("type".into(), "string".into());
obj.insert("description".into(), description.into());
serde_json::Value::Object(obj)
}
fn object_schema(
props: serde_json::Map<String, serde_json::Value>,
required: &[&str],
) -> Arc<JsonObject> {
let mut schema = serde_json::Map::new();
schema.insert("type".into(), "object".into());
schema.insert("properties".into(), serde_json::Value::Object(props));
if !required.is_empty() {
schema.insert(
"required".into(),
serde_json::Value::Array(
required
.iter()
.map(|name| serde_json::Value::String((*name).to_string()))
.collect(),
),
);
}
Arc::new(schema)
}
fn required_string_arg<'a>(
args: Option<&'a serde_json::Map<String, serde_json::Value>>,
key: &str,
) -> Result<&'a str, McpError> {
args.and_then(|args| args.get(key))
.and_then(|value| value.as_str())
.ok_or_else(|| {
McpError::invalid_params(format!("Missing required argument: {}", key), None)
})
}
fn return_type_arg(
args: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Result<ReturnType, McpError> {
match args
.and_then(|args| args.get("return_type"))
.and_then(|value| value.as_str())
.unwrap_or("both")
{
"content" => Ok(ReturnType::Content),
"file_path" => Ok(ReturnType::FilePath),
"both" => Ok(ReturnType::Both),
other => Err(McpError::invalid_params(
format!(
"Invalid return_type '{}'. Use one of: content, file_path, both",
other
),
None,
)),
}
}
fn json_success(value: serde_json::Value) -> Result<CallToolResult, McpError> {
let text = serde_json::to_string_pretty(&value).map_err(|e| {
McpError::internal_error(format!("Failed to serialize tool response: {}", e), None)
})?;
Ok(CallToolResult::success(vec![Content::text(text)]))
}
fn read_text_file(path: &Path) -> Result<String, McpError> {
std::fs::read_to_string(path).map_err(|e| {
McpError::internal_error(format!("Failed to read {}: {}", path.display(), e), None)
})
}
fn resolve_skill_file_path(skill: &Skill, relative_path: &str) -> Result<PathBuf, McpError> {
let relative = Path::new(relative_path);
if relative.as_os_str().is_empty() || relative.is_absolute() {
return Err(McpError::invalid_params(
"relative_path must be a non-empty relative path".to_string(),
None,
));
}
let base = skill.base_dir.canonicalize().map_err(|e| {
McpError::internal_error(
format!(
"Failed to resolve skill directory {}: {}",
skill.base_dir.display(),
e
),
None,
)
})?;
let candidate = base.join(relative);
let resolved = candidate.canonicalize().map_err(|e| {
McpError::invalid_params(format!("File '{}' not found: {}", relative_path, e), None)
})?;
if !resolved.starts_with(&base) {
return Err(McpError::invalid_params(
format!("Path '{}' escapes the skill directory", relative_path),
None,
));
}
if !resolved.is_file() {
return Err(McpError::invalid_params(
format!("Path '{}' is not a file", relative_path),
None,
));
}
Ok(resolved)
}
fn list_skill_files(base_dir: &Path) -> Result<Vec<String>, McpError> {
let canonical_base = base_dir.canonicalize().map_err(|e| {
McpError::internal_error(
format!(
"Failed to resolve skill directory {}: {}",
base_dir.display(),
e
),
None,
)
})?;
let mut files = Vec::new();
collect_skill_files(&canonical_base, &canonical_base, &mut files)?;
files.sort();
Ok(files)
}
fn collect_skill_files(
current: &Path,
base: &Path,
files: &mut Vec<String>,
) -> Result<(), McpError> {
for entry in std::fs::read_dir(current).map_err(|e| {
McpError::internal_error(format!("Failed to read {}: {}", current.display(), e), None)
})? {
let entry = entry.map_err(|e| {
McpError::internal_error(
format!(
"Failed to read directory entry in {}: {}",
current.display(),
e
),
None,
)
})?;
let path = entry.path();
if path.is_dir() {
collect_skill_files(&path, base, files)?;
} else if path.is_file() {
let relative = path.strip_prefix(base).map_err(|e| {
McpError::internal_error(
format!(
"Failed to compute relative path for {}: {}",
path.display(),
e
),
None,
)
})?;
files.push(relative.to_string_lossy().replace('\\', "/"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skills::models::{Skill, SkillFrontmatter};
fn make_skill(base_dir: PathBuf) -> Skill {
Skill {
name: "test-skill".to_string(),
base_dir,
frontmatter: SkillFrontmatter {
name: "test-skill".to_string(),
description: "Test skill".to_string(),
argument_hint: None,
allowed_tools: None,
user_invocable: true,
model: None,
disable_model_invocation: false,
context: None,
agent: None,
},
body: "Body".to_string(),
scripts: vec![],
references: vec![],
source: "test".to_string(),
}
}
#[test]
fn test_resolve_skill_file_path_rejects_escape() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("skill");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "content").unwrap();
let skill = make_skill(skill_dir);
let err = resolve_skill_file_path(&skill, "../outside.txt").unwrap_err();
assert!(
err.message.contains("escapes the skill directory")
|| err.message.contains("not found")
);
}
#[test]
fn test_list_skill_files_recursive() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("skill");
std::fs::create_dir_all(skill_dir.join("references")).unwrap();
std::fs::create_dir_all(skill_dir.join("scripts")).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "content").unwrap();
std::fs::write(skill_dir.join("references/guide.md"), "# Guide").unwrap();
std::fs::write(skill_dir.join("scripts/run.sh"), "#!/bin/bash").unwrap();
let files = list_skill_files(&skill_dir).unwrap();
assert_eq!(
files,
vec![
"SKILL.md".to_string(),
"references/guide.md".to_string(),
"scripts/run.sh".to_string(),
]
);
}
}