use rmcp::handler::server::tool::{schema_for_type, ToolRouter};
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::{tool, tool_handler, tool_router, ServerHandler};
use crate::config::RuntimeConfig;
use crate::delivery::{VentRequest, VentService};
use crate::types::{ListChannelsOutput, VentDefaultChannelInput, VentInput, VentOutput};
#[derive(Clone)]
pub struct VentMcpServer {
service: VentService,
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl VentMcpServer {
#[must_use]
pub fn new(service: VentService) -> Self {
let tool_router = configured_tool_router(service.config());
Self {
service,
tool_router,
}
}
#[tool(
name = "list_channels",
description = "List the configured vent channels with short names and descriptions.",
annotations(read_only_hint = true, idempotent_hint = true)
)]
async fn list_channels(&self) -> Json<ListChannelsOutput> {
Json(self.service.list_channels())
}
#[tool(
name = "vent",
description = "Send a non-destructive complaint or feedback message to a configured vent channel.",
annotations(destructive_hint = false)
)]
async fn vent(&self, input: Parameters<VentInput>) -> Json<VentOutput> {
let input = input.0;
Json(
self.service
.send(VentRequest {
message: input.message,
channel: input.channel,
})
.await,
)
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for VentMcpServer {
fn get_info(&self) -> rmcp::model::ServerInfo {
let instructions = if self.tool_router.has_route("list_channels") {
"Use list_channels to inspect available feedback channels. Use vent to send concise, non-destructive complaints or process feedback. The server records only the project directory name, not the full working directory path."
} else {
"Use vent to send concise, non-destructive complaints or process feedback. The server records only the project directory name, not the full working directory path."
};
rmcp::model::ServerInfo::new(
rmcp::model::ServerCapabilities::builder()
.enable_tools()
.build(),
)
.with_server_info(rmcp::model::Implementation::new(
"vent-mcp",
env!("CARGO_PKG_VERSION"),
))
.with_instructions(instructions)
}
}
fn configured_tool_router(config: &RuntimeConfig) -> ToolRouter<VentMcpServer> {
let mut router = VentMcpServer::tool_router();
if config.has_only_default_channel() {
router.remove_route("list_channels");
if let Some(route) = router.map.get_mut("vent") {
route.attr.input_schema = schema_for_type::<Parameters<VentDefaultChannelInput>>();
}
}
router
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use tempfile::tempdir;
use crate::config::{AppConfig, ChannelConfig, LoggingConfig, RuntimeConfig};
use crate::delivery::VentService;
use super::VentMcpServer;
fn test_server(config: AppConfig) -> VentMcpServer {
let dir = tempdir().expect("temp dir");
let config = RuntimeConfig::from_app_config(config, dir.path().to_path_buf())
.expect("runtime config");
let service = VentService::new(config, "vent-mcp".to_string());
VentMcpServer::new(service)
}
fn vent_schema_has_property(server: &VentMcpServer, property: &str) -> bool {
server
.tool_router
.get("vent")
.expect("vent tool")
.input_schema
.get("properties")
.and_then(Value::as_object)
.is_some_and(|properties| properties.contains_key(property))
}
#[test]
fn single_default_channel_hides_channel_metadata() {
let server = test_server(AppConfig::default());
assert!(server.tool_router.has_route("vent"));
assert!(!server.tool_router.has_route("list_channels"));
assert_eq!(server.tool_router.list_all().len(), 1);
assert!(vent_schema_has_property(&server, "message"));
assert!(!vent_schema_has_property(&server, "channel"));
let instructions = rmcp::ServerHandler::get_info(&server)
.instructions
.expect("instructions");
assert!(!instructions.contains("list_channels"));
}
#[test]
fn multiple_channels_expose_channel_metadata() {
let server = test_server(AppConfig {
default_channel: "general".to_string(),
channels: vec![
ChannelConfig {
name: "general".to_string(),
description: "General feedback.".to_string(),
},
ChannelConfig {
name: "ux".to_string(),
description: "Workflow friction.".to_string(),
},
],
..AppConfig::default()
});
assert!(server.tool_router.has_route("vent"));
assert!(server.tool_router.has_route("list_channels"));
assert_eq!(server.tool_router.list_all().len(), 2);
assert!(vent_schema_has_property(&server, "message"));
assert!(vent_schema_has_property(&server, "channel"));
let instructions = rmcp::ServerHandler::get_info(&server)
.instructions
.expect("instructions");
assert!(instructions.contains("list_channels"));
}
#[tokio::test]
async fn list_channels_returns_configured_channels() {
let server = test_server(AppConfig {
default_channel: "general".to_string(),
channels: vec![
ChannelConfig {
name: "general".to_string(),
description: "General feedback.".to_string(),
},
ChannelConfig {
name: "ux".to_string(),
description: "Workflow friction.".to_string(),
},
],
..AppConfig::default()
});
let output = server.list_channels().await.0;
assert_eq!(output.default_channel, "general");
assert_eq!(output.channels.len(), 2);
assert_eq!(output.channels[1].name, "ux");
}
#[tokio::test]
async fn vent_uses_default_channel_when_omitted() {
let dir = tempdir().expect("temp dir");
let config = AppConfig {
logging: LoggingConfig {
jsonl_dir: Some(dir.path().to_string_lossy().to_string()),
},
..AppConfig::default()
};
let server = test_server(config);
let output = server
.vent(rmcp::handler::server::wrapper::Parameters(
crate::types::VentInput {
message: "The task queue kept changing mid-run.".to_string(),
channel: None,
},
))
.await
.0;
assert!(output.ok);
assert_eq!(output.channel, "general");
assert_eq!(output.event_id.len(), 8);
assert!(output
.event_id
.chars()
.all(|character| character.is_ascii_alphanumeric()));
assert_eq!(output.error, None);
}
#[tokio::test]
async fn vent_rejects_unknown_channel() {
let server = test_server(AppConfig::default());
let output = server
.vent(rmcp::handler::server::wrapper::Parameters(
crate::types::VentInput {
message: "Something useful.".to_string(),
channel: Some("missing".to_string()),
},
))
.await
.0;
assert!(!output.ok);
assert_eq!(output.channel, "missing");
assert_eq!(output.error.as_deref(), Some("unknown channel: missing"));
}
}