use super::resources::*;
use super::tools::*;
use crate::session::ProtoSession;
use crate::workflows::*;
use proto_core::flow::install::Installer;
use proto_core::flow::resolve::Resolver;
use proto_core::{
PinLocation, ProtoConfigEnvOptions, ToolContext, ToolSpec, UnresolvedVersionSpec,
get_proto_version,
};
use rmcp::{
ErrorData as McpError, RoleServer, ServerHandler,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::*,
service::RequestContext,
tool, tool_handler, tool_router,
};
use semver::VersionReq;
use serde_json::json;
use std::fmt::Display;
use std::mem;
macro_rules! handle_tool_error {
($result:expr) => {
match $result {
Ok(inner) => inner,
Err(error) => {
return Ok(CallToolResult::error(vec![Annotated::new(
RawContent::text(error.to_string()),
None,
)]));
}
}
};
}
#[derive(Clone)]
pub struct ProtoMcp {
session: ProtoSession,
pub tool_router: ToolRouter<ProtoMcp>,
}
impl ProtoMcp {
pub fn list_all_resources(&self) -> ListResourcesResult {
ListResourcesResult {
resources: vec![
RawResource::new("proto://config", "Configuration".to_string()).no_annotation(),
RawResource::new("proto://env", "Environment".to_string()).no_annotation(),
RawResource::new("proto://tools", "Installed tools".to_string()).no_annotation(),
],
next_cursor: None,
meta: None,
}
}
fn parse_context(&self, value: &str) -> Result<ToolContext, McpError> {
if value.is_empty() {
return Err(McpError::invalid_params(
"Tool identifier/context required.",
Some(json!({
"param": "tool"
})),
));
}
ToolContext::parse(value).map_err(map_parse_error)
}
fn parse_spec(&self, value: &str) -> Result<ToolSpec, McpError> {
if value.is_empty() {
return Err(McpError::invalid_params(
"Tool version/specification required.",
Some(json!({
"param": "spec"
})),
));
}
ToolSpec::parse(value).map_err(map_parse_error)
}
fn resource_config(&self) -> miette::Result<ConfigResource<'_>> {
let env = &self.session.env;
Ok(ConfigResource {
working_dir: env.working_dir.clone(),
config_mode: env.config_mode,
config_files: env
.load_file_manager()?
.entries
.iter()
.map(|entry| &entry.path)
.collect(),
config: env.load_config()?,
})
}
fn resource_env(&self) -> miette::Result<EnvResource<'_>> {
let env = &self.session.env;
let config = env.load_config()?;
let options = ProtoConfigEnvOptions {
include_shared: true,
..Default::default()
};
Ok(EnvResource {
working_dir: env.working_dir.clone(),
store_dir: env.store.dir.clone(),
env_mode: env.env_mode.clone(),
env_files: config.get_env_files(&options),
env_vars: config.get_env_vars(&options)?,
proto_version: get_proto_version().to_string(),
system_arch: env.arch,
system_os: env.os,
})
}
async fn resource_tools(&self) -> miette::Result<ToolsResource> {
let mut resource = ToolsResource {
tools: Default::default(),
};
for tool in self.session.load_tools().await? {
resource.tools.insert(
tool.context.clone(),
ToolResourceEntry {
tool_dir: tool.get_inventory_dir().to_path_buf(),
installed_versions: Vec::from_iter(
tool.inventory.manifest.installed_versions.clone(),
),
},
);
}
Ok(resource)
}
}
#[tool_router]
impl ProtoMcp {
pub fn new(session: ProtoSession) -> Self {
Self {
session,
tool_router: Self::tool_router(),
}
}
#[tool(description = "Get configuration for the current working directory.")]
async fn get_config(&self) -> Result<CallToolResult, McpError> {
let config = handle_tool_error!(self.session.load_config());
Ok(CallToolResult::structured(
serde_json::to_value(config).unwrap(),
))
}
#[tool(description = "Install a tool with a specification.")]
async fn install_tool(
&self,
params: Parameters<InstallToolRequest>,
) -> Result<CallToolResult, McpError> {
let req = params.0;
let context = self.parse_context(&req.tool)?;
let mut spec = self.parse_spec(req.spec.as_deref().unwrap_or("latest"))?;
let tool = handle_tool_error!(self.session.load_tool(&context).await);
let mut workflow = InstallWorkflow::new(tool, self.session.console.clone());
let outcome = handle_tool_error!(
workflow
.install(
&mut spec,
InstallWorkflowParams {
force: req.force,
log_writer: None,
multiple: false,
passthrough_args: vec![],
pin_to: if req.pin {
Some(PinLocation::Local)
} else {
None
},
quiet: true,
skip_prompts: true,
strategy: None,
},
)
.await
);
Ok(CallToolResult::structured(
serde_json::to_value(InstallToolResponse {
installed: matches!(
outcome,
InstallOutcome::AlreadyInstalled(_) | InstallOutcome::Installed(_)
),
spec: spec.get_resolved_version().to_string(),
})
.unwrap(),
))
}
#[tool(description = "Uninstall a tool with a specification.")]
async fn uninstall_tool(
&self,
params: Parameters<UninstallToolRequest>,
) -> Result<CallToolResult, McpError> {
let req = params.0;
let context = self.parse_context(&req.tool)?;
let mut spec = self.parse_spec(&req.spec)?;
let tool = handle_tool_error!(self.session.load_tool(&context).await);
handle_tool_error!(Resolver::resolve(&tool, &mut spec, false).await);
let uninstalled = handle_tool_error!(Installer::new(&tool, &spec).uninstall().await);
Ok(CallToolResult::structured(
serde_json::to_value(UninstallToolResponse {
uninstalled,
spec: spec.get_resolved_version().to_string(),
})
.unwrap(),
))
}
#[tool(description = "List available and installed versions for a tool.")]
async fn list_tool_versions(
&self,
params: Parameters<ListToolVersionsRequest>,
) -> Result<CallToolResult, McpError> {
let req = params.0;
let context = self.parse_context(&req.tool)?;
let tool = handle_tool_error!(self.session.load_tool(&context).await);
let mut resolver = Resolver::new(&tool);
handle_tool_error!(
resolver
.load_versions(&UnresolvedVersionSpec::default())
.await
);
let mut versions = mem::take(&mut resolver.data.versions);
if let Some(filter) = req.filter {
let filter = VersionReq::parse(&filter).map_err(map_parse_error)?;
versions.retain(|item| {
item.as_version()
.is_some_and(|version| filter.matches(version))
});
}
let versions = versions
.into_iter()
.map(|v| v.to_string())
.collect::<Vec<_>>();
let versions_len = versions.len();
Ok(CallToolResult::structured(
serde_json::to_value(ListToolVersionsResponse {
aliases: resolver
.data
.aliases
.into_iter()
.map(|(k, v)| (k, v.to_string()))
.collect(),
installed_versions: tool
.inventory
.manifest
.installed_versions
.iter()
.map(|v| v.to_string())
.collect(),
versions: if req.all {
versions
} else {
versions[0..versions_len.min(25)].to_vec()
},
})
.unwrap(),
))
}
}
#[tool_handler]
impl ServerHandler for ProtoMcp {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(
ServerCapabilities::builder()
.enable_resources()
.enable_tools()
.build()
)
.with_server_info(
Implementation::from_build_env().with_website_url("https://moonrepo.dev/proto")
)
.with_instructions(
"The proto MCP server provides resources and tools for managing your toolchain, environment, and more."
)
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, McpError> {
Ok(self.list_all_resources())
}
async fn read_resource(
&self,
ReadResourceRequestParams { uri, .. }: ReadResourceRequestParams,
_: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, McpError> {
let text = match uri.as_str() {
"proto://config" => {
let resource = self
.resource_config()
.map_err(|error| map_resource_error(error, &uri))?;
serde_json::to_string_pretty(&resource).unwrap()
}
"proto://env" => {
let resource = self
.resource_env()
.map_err(|error| map_resource_error(error, &uri))?;
serde_json::to_string_pretty(&resource).unwrap()
}
"proto://tools" => {
let resource = self
.resource_tools()
.await
.map_err(|error| map_resource_error(error, &uri))?;
serde_json::to_string_pretty(&resource).unwrap()
}
_ => {
return Err(McpError::resource_not_found(
"Resource does not exist.",
Some(json!({
"uri": uri
})),
));
}
};
Ok(ReadResourceResult::new(vec![
ResourceContents::TextResourceContents {
uri,
text,
mime_type: Some("application/json".into()),
meta: None,
},
]))
}
}
fn map_parse_error(error: impl Display) -> McpError {
McpError::parse_error(error.to_string(), None)
}
fn map_resource_error(error: impl Display, uri: &str) -> McpError {
McpError::internal_error(
error.to_string(),
Some(json!({
"uri": uri
})),
)
}