mod cache;
mod github;
mod skills;
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use rmcp::{
ErrorData as McpError, ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, Content, ServerCapabilities, ServerInfo},
schemars, tool, tool_handler, tool_router,
transport::io::stdio,
};
use schemars::JsonSchema;
use serde::Deserialize;
use tracing_subscriber::EnvFilter;
use crate::github::{
build_client, fetch_repo_skills, fetch_skill_content, push_skill_content,
};
use crate::skills::{format_skill_list, search_skills};
#[derive(Clone)]
struct SkillServer {
owner: String,
repo: String,
token: Option<String>,
github: Arc<octocrab::Octocrab>,
tool_router: ToolRouter<Self>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct LoadSkillArgs {
name: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct PublishSkillArgs {
name: String,
content: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SearchSkillsArgs {
query: String,
}
#[tool_router]
impl SkillServer {
fn new(owner: String, repo: String, token: Option<String>) -> Result<Self> {
let github = Arc::new(build_client(token.as_deref())?);
Ok(Self {
owner,
repo,
token,
github,
tool_router: Self::tool_router(),
})
}
fn text(text: impl Into<String>) -> CallToolResult {
CallToolResult::success(vec![Content::text(text.into())])
}
fn error(text: impl Into<String>) -> CallToolResult {
CallToolResult::error(vec![Content::text(text.into())])
}
#[tool(description = "List all available skills from the local cache.")]
async fn list_skills(&self) -> Result<CallToolResult, McpError> {
match cache::list_cached_skills() {
Ok(skills) => Ok(Self::text(format_skill_list(skills.iter()))),
Err(e) => Ok(Self::error(format!("Failed to list skills: {e}"))),
}
}
#[tool(
description = "Load a skill by name. Returns the full SKILL.md content. \
Fetches from GitHub if not cached."
)]
async fn load_skill(
&self,
Parameters(LoadSkillArgs { name }): Parameters<LoadSkillArgs>,
) -> Result<CallToolResult, McpError> {
match cache::read_cached_skill(&name) {
Ok(Some(content)) => return Ok(Self::text(content)),
Ok(None) => {}
Err(e) => return Ok(Self::error(format!("Cache read failed: {e}"))),
}
match fetch_skill_content(&self.github, &self.owner, &self.repo, &name).await {
Ok(content) => {
if let Err(e) = cache::write_cached_skill(&name, &content) {
return Ok(Self::error(format!("Fetched but failed to cache: {e}")));
}
Ok(Self::text(content))
}
Err(_) => Ok(Self::error(format!(
"Skill \"{name}\" not found locally or in the remote repo."
))),
}
}
#[tool(description = "Sync skills from the GitHub repo to the local cache. Remote content wins.")]
async fn sync_skills(&self) -> Result<CallToolResult, McpError> {
let fetched = match fetch_repo_skills(&self.github, &self.owner, &self.repo).await {
Ok(list) => list,
Err(e) => return Ok(Self::error(format!("Sync failed: {e}"))),
};
let mut added = Vec::new();
let mut updated = Vec::new();
let total = fetched.len();
for item in fetched {
let existing = cache::read_cached_skill(&item.meta.name).ok().flatten();
match existing {
None => added.push(item.meta.name.clone()),
Some(prev) if prev != item.content => updated.push(item.meta.name.clone()),
_ => {}
}
if let Err(e) = cache::write_cached_skill(&item.meta.name, &item.content) {
return Ok(Self::error(format!("Failed to write cache: {e}")));
}
}
let mut parts = vec![format!("Synced {total} skill(s).")];
if !added.is_empty() {
parts.push(format!("Added: {}", added.join(", ")));
}
if !updated.is_empty() {
parts.push(format!("Updated: {}", updated.join(", ")));
}
if added.is_empty() && updated.is_empty() {
parts.push("Everything up to date.".to_string());
}
Ok(Self::text(parts.join("\n")))
}
#[tool(description = "Publish a skill to the shared GitHub repo. Requires GITHUB_TOKEN.")]
async fn publish_skill(
&self,
Parameters(PublishSkillArgs { name, content }): Parameters<PublishSkillArgs>,
) -> Result<CallToolResult, McpError> {
if self.token.is_none() {
return Ok(Self::error("GITHUB_TOKEN is required to publish skills."));
}
let message = format!("Add/update skill: {name}");
if let Err(e) =
push_skill_content(&self.github, &self.owner, &self.repo, &name, &content, &message)
.await
{
return Ok(Self::error(format!("Publish failed: {e}")));
}
if let Err(e) = cache::write_cached_skill(&name, &content) {
return Ok(Self::error(format!(
"Published but failed to update cache: {e}"
)));
}
Ok(Self::text(format!(
"Published \"{name}\" to {}/{}.",
self.owner, self.repo
)))
}
#[tool(description = "Search skills by name, description, or tags. Searches the local cache.")]
async fn search_skills(
&self,
Parameters(SearchSkillsArgs { query }): Parameters<SearchSkillsArgs>,
) -> Result<CallToolResult, McpError> {
match cache::list_cached_skills() {
Ok(skills) => {
let results = search_skills(&skills, &query);
Ok(Self::text(format_skill_list(results.into_iter())))
}
Err(e) => Ok(Self::error(format!("Search failed: {e}"))),
}
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for SkillServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(rmcp::model::Implementation::new(
"skill-library",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(
"Manage a GitHub-backed shared skill library. \
Use `sync_skills` to pull the latest, `list_skills`/`search_skills` \
to browse, `load_skill` to read one, and `publish_skill` to contribute.",
)
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let owner = std::env::var("SKILL_REPO_OWNER")
.map_err(|_| anyhow!("SKILL_REPO_OWNER must be set"))?;
let repo = std::env::var("SKILL_REPO_NAME")
.map_err(|_| anyhow!("SKILL_REPO_NAME must be set"))?;
let token = std::env::var("GITHUB_TOKEN").ok();
let server = SkillServer::new(owner, repo, token).context("initialize server")?;
tracing::info!("Skill Library MCP server starting on stdio");
let service = server.serve(stdio()).await.context("serve")?;
service.waiting().await.context("serve completed with error")?;
Ok(())
}