creamtop 0.1.0

MCP server that manages a GitHub-backed shared skill library for Claude Code.
//! creamtop: an MCP server for a GitHub-backed skill library.

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 of the skill to load.
    name: String,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct PublishSkillArgs {
    /// Name of the skill (used as directory name).
    name: String,
    /// Full `SKILL.md` content to publish.
    content: String,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct SearchSkillsArgs {
    /// Search query.
    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> {
        // Try cache first.
        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}"))),
        }

        // Fall back to remote fetch + cache.
        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<()> {
    // Logs go to stderr so they don't corrupt the stdio JSON-RPC stream.
    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(())
}