use std::{path::PathBuf, sync::Arc, time::UNIX_EPOCH};
use rmcp::{
model::*,
service::RequestContext,
transport::stdio,
ErrorData, RoleServer, ServerHandler, ServiceExt,
};
use serde_json::{json, Map, Value};
use crate::{
automation::{self, RunRequest},
error::{HenError, HenErrorKind},
request::{ExecutionOptions, RequestFailure},
};
const SYNTAX_REFERENCE: &str = include_str!("../syntax-reference.md");
const README_GUIDE: &str = include_str!("../README.md");
const AUTHORING_GUIDE_URI: &str = "hen://authoring-guide";
const README_URI: &str = "hen://readme";
#[derive(Clone)]
pub struct HenMcpServer {
root: PathBuf,
}
impl HenMcpServer {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
}
impl ServerHandler for HenMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build(),
server_info: Implementation {
name: "hen-mcp".to_string(),
title: Some("Hen MCP Server".to_string()),
version: env!("CARGO_PKG_VERSION").to_string(),
description: Some(
"Run, verify, and author .hen collections without using the interactive CLI."
.to_string(),
),
icons: None,
website_url: None,
},
instructions: Some(
"Run, verify, and author .hen collections without using the interactive CLI."
.to_string(),
),
..Default::default()
}
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, ErrorData> {
Ok(ListToolsResult {
tools: vec![
Tool::new(
"run_hen",
"Run a .hen collection or a single request non-interactively.",
Arc::new(
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to a .hen file, or a directory containing exactly one .hen file. Relative paths resolve from the server working directory."
},
"selector": {
"type": "string",
"description": "Request selector. Use an integer index or 'all'. Required when the collection has multiple requests."
},
"inputs": {
"type": "object",
"description": "Values for [[ prompt ]] placeholders.",
"additionalProperties": { "type": "string" }
},
"parallel": {
"type": "boolean",
"description": "Run independent requests concurrently."
},
"maxConcurrency": {
"type": "integer",
"minimum": 1,
"description": "Maximum number of concurrent requests."
},
"continueOnError": {
"type": "boolean",
"description": "Continue running unaffected branches after a failure."
},
"includeBody": {
"type": "boolean",
"description": "Include response bodies in the returned execution records. Defaults to true."
},
"maxBodyChars": {
"type": "integer",
"minimum": 1,
"description": "Trim response bodies to at most this many characters."
}
},
"required": ["path"]
})
.as_object()
.unwrap()
.clone(),
),
),
Tool::new(
"verify_hen_syntax",
"Parse and verify .hen syntax without executing shell variables or network requests.",
Arc::new(
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the .hen file to verify."
},
"source": {
"type": "string",
"description": "Inline .hen source to verify. Provide this instead of path when validating editor content."
},
"workingDirectory": {
"type": "string",
"description": "Working directory used to resolve << fragment imports when source is provided."
}
}
})
.as_object()
.unwrap()
.clone(),
),
),
Tool::new(
"get_hen_authoring_guide",
"Return the built-in Hen syntax and usage guide.",
Arc::new(
json!({
"type": "object",
"properties": {
"topic": {
"type": "string",
"description": "Optional guide topic. Use 'syntax' or 'usage'."
}
}
})
.as_object()
.unwrap()
.clone(),
),
),
],
meta: None,
next_cursor: None,
})
}
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResult, ErrorData> {
let empty = Map::new();
let args = request.arguments.as_ref().unwrap_or(&empty);
match request.name.as_ref() {
"run_hen" => self.run_hen(args).await,
"verify_hen_syntax" => self.verify_hen_syntax(args),
"get_hen_authoring_guide" => self.get_hen_authoring_guide(args),
_ => Err(ErrorData::new(
ErrorCode::METHOD_NOT_FOUND,
"Unknown tool",
None,
)),
}
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, ErrorData> {
Ok(ListResourcesResult {
resources: vec![
RawResource::new(AUTHORING_GUIDE_URI, "Hen authoring guide").no_annotation(),
RawResource::new(README_URI, "Hen README").no_annotation(),
],
next_cursor: None,
meta: None,
})
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, ErrorData> {
match request.uri.as_str() {
AUTHORING_GUIDE_URI => Ok(ReadResourceResult {
contents: vec![ResourceContents::text(SYNTAX_REFERENCE, &request.uri)],
}),
README_URI => Ok(ReadResourceResult {
contents: vec![ResourceContents::text(README_GUIDE, &request.uri)],
}),
_ => Err(ErrorData::resource_not_found(
"Resource not found",
Some(json!({ "uri": request.uri })),
)),
}
}
async fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourceTemplatesResult, ErrorData> {
Ok(ListResourceTemplatesResult {
resource_templates: vec![],
next_cursor: None,
meta: None,
})
}
}
impl HenMcpServer {
async fn run_hen(&self, args: &Map<String, Value>) -> Result<CallToolResult, ErrorData> {
let path = required_string(args, "path")?;
let selector = optional_selector(args.get("selector"))?;
let inputs = optional_string_map(args.get("inputs"))?;
let parallel = optional_bool(args.get("parallel")).unwrap_or(false);
let max_concurrency = optional_usize(args.get("maxConcurrency"))?;
let continue_on_error = optional_bool(args.get("continueOnError")).unwrap_or(false);
let include_body = optional_bool(args.get("includeBody")).unwrap_or(true);
let max_body_chars = optional_usize(args.get("maxBodyChars"))?;
let outcome = automation::run_path(RunRequest {
path: self.resolve_path(&path),
selector,
inputs,
execution_options: ExecutionOptions {
parallel: parallel || max_concurrency.is_some(),
max_concurrency,
continue_on_error,
},
})
.await
.map_err(hen_error_to_mcp)?;
let value = json!({
"collection": {
"path": outcome.collection.path.display().to_string(),
"name": outcome.collection.name,
"description": outcome.collection.description,
"requiredInputs": outcome.collection.required_inputs.iter().map(prompt_requirement_json).collect::<Vec<_>>(),
"requests": outcome.collection.requests.iter().map(|request| {
json!({
"index": request.index,
"description": request.description,
"method": request.method,
"url": request.url,
"dependencies": request.dependencies,
})
}).collect::<Vec<_>>()
},
"plan": outcome.plan,
"selectedRequests": outcome.selected_requests,
"primaryTarget": outcome.primary_target,
"executionFailed": outcome.execution_failed,
"records": outcome.records.iter().map(|record| {
let body = if include_body {
Some(truncate_text(record.execution.output.as_str(), max_body_chars))
} else {
None
};
json!({
"index": record.index,
"description": record.description,
"method": record.method.as_str(),
"url": record.url,
"status": record.execution.snapshot.status.as_u16(),
"statusText": record.execution.snapshot.status.canonical_reason(),
"startedAtUnixMs": record.started_at.duration_since(UNIX_EPOCH).ok().map(|value| value.as_millis() as u64),
"durationMs": record.duration.as_millis() as u64,
"body": body.as_ref().map(|value| value.0.clone()),
"bodyTruncated": body.as_ref().map(|value| value.1).unwrap_or(false),
})
}).collect::<Vec<_>>(),
"failures": outcome.failures.iter().map(request_failure_json).collect::<Vec<_>>(),
});
json_result(value)
}
fn verify_hen_syntax(&self, args: &Map<String, Value>) -> Result<CallToolResult, ErrorData> {
let path = optional_string(args.get("path"))?;
let source = optional_string(args.get("source"))?;
let working_directory = optional_string(args.get("workingDirectory"))?;
let result = match (path, source) {
(Some(path), None) => automation::verify_path(self.resolve_path(&path)),
(None, Some(source)) => {
let working_directory = working_directory
.map(|value| self.resolve_path(&value))
.unwrap_or_else(|| self.root.clone());
automation::verify_source(source, working_directory)
}
(Some(_), Some(_)) => {
return Err(invalid_params(
"Provide either 'path' or 'source', but not both.",
));
}
(None, None) => {
return Err(invalid_params(
"Provide either 'path' or 'source' to verify Hen syntax.",
));
}
}
.map_err(hen_error_to_mcp)?;
let value = json!({
"path": result.path.as_ref().map(|path| path.display().to_string()),
"name": result.summary.name,
"description": result.summary.description,
"requiredInputs": result.required_inputs.iter().map(prompt_requirement_json).collect::<Vec<_>>(),
"requests": result.summary.requests.iter().map(|request| {
json!({
"index": request.index,
"description": request.description,
"method": request.method,
"url": request.url,
})
}).collect::<Vec<_>>(),
});
json_result(value)
}
fn get_hen_authoring_guide(
&self,
args: &Map<String, Value>,
) -> Result<CallToolResult, ErrorData> {
let topic = optional_string(args.get("topic"))?;
let guide = match topic.as_deref() {
None => format!("{}\n\n---\n\n{}", SYNTAX_REFERENCE.trim(), README_GUIDE.trim()),
Some("syntax") => SYNTAX_REFERENCE.to_string(),
Some("usage") => README_GUIDE.to_string(),
Some(other) => {
return Err(invalid_params(format!(
"Unknown topic '{}'. Use 'syntax' or 'usage'.",
other
)));
}
};
Ok(CallToolResult::success(vec![Content::text(guide)]))
}
fn resolve_path(&self, raw: &str) -> PathBuf {
let path = PathBuf::from(raw);
if path.is_absolute() {
path
} else {
self.root.join(path)
}
}
}
pub async fn run_stdio_server(root: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let service = HenMcpServer::new(root).serve(stdio()).await?;
service.waiting().await?;
Ok(())
}
fn required_string(args: &Map<String, Value>, field: &str) -> Result<String, ErrorData> {
match args.get(field).and_then(Value::as_str) {
Some(value) if !value.trim().is_empty() => Ok(value.to_string()),
_ => Err(invalid_params(format!("Field '{}' is required.", field))),
}
}
fn optional_string(value: Option<&Value>) -> Result<Option<String>, ErrorData> {
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::String(text)) => Ok(Some(text.clone())),
Some(_) => Err(invalid_params("Expected a string value.")),
}
}
fn optional_selector(value: Option<&Value>) -> Result<Option<String>, ErrorData> {
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::String(text)) => Ok(Some(text.clone())),
Some(Value::Number(number)) => Ok(Some(number.to_string())),
Some(_) => Err(invalid_params("Selector must be a string or integer.")),
}
}
fn optional_bool(value: Option<&Value>) -> Option<bool> {
value.and_then(Value::as_bool)
}
fn optional_usize(value: Option<&Value>) -> Result<Option<usize>, ErrorData> {
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(number)) => number
.as_u64()
.map(|value| value as usize)
.map(Some)
.ok_or_else(|| invalid_params("Expected a positive integer value.")),
Some(_) => Err(invalid_params("Expected a positive integer value.")),
}
}
fn optional_string_map(
value: Option<&Value>,
) -> Result<std::collections::HashMap<String, String>, ErrorData> {
match value {
None | Some(Value::Null) => Ok(std::collections::HashMap::new()),
Some(Value::Object(map)) => {
let mut values = std::collections::HashMap::new();
for (key, value) in map {
let value = value.as_str().ok_or_else(|| {
invalid_params(format!("inputs.{} must be a string value.", key))
})?;
values.insert(key.clone(), value.to_string());
}
Ok(values)
}
Some(_) => Err(invalid_params("Expected an object with string values.")),
}
}
fn truncate_text(value: &str, limit: Option<usize>) -> (String, bool) {
match limit {
Some(limit) if value.chars().count() > limit => {
let truncated = value.chars().take(limit).collect::<String>();
(truncated, true)
}
_ => (value.to_string(), false),
}
}
fn request_failure_json(failure: &RequestFailure) -> Value {
json!({
"index": failure.index(),
"request": failure.request(),
"kind": format!("{:?}", failure.kind()),
"message": failure.to_string(),
})
}
fn prompt_requirement_json(prompt: &automation::PromptRequirement) -> Value {
json!({
"name": prompt.name,
"default": prompt.default,
})
}
fn json_result(value: Value) -> Result<CallToolResult, ErrorData> {
Ok(CallToolResult::structured(value))
}
fn invalid_params(message: impl Into<String>) -> ErrorData {
ErrorData::new(ErrorCode::INVALID_PARAMS, message.into(), None)
}
fn hen_error_to_mcp(error: HenError) -> ErrorData {
let code = match error.kind() {
HenErrorKind::Input | HenErrorKind::Prompt | HenErrorKind::Parse => {
ErrorCode::INVALID_PARAMS
}
_ => ErrorCode::INTERNAL_ERROR,
};
ErrorData::new(code, error.to_string(), None)
}