use crate::request_context::{CLIENT_CANNOT_ELICIT, parse_response, supports_elicitation};
use crate::review::html_review::{Artifact, PendingReviews, ReviewServer};
use crate::workspace_paths::resolve_path;
use reqwest::Url;
use rmcp::{
ErrorData as McpError, RoleServer,
handler::server::{
tool::{InputResponses, IntoCallToolResult, RequestState},
wrapper::Json,
},
model::{
CallToolResponse, CallToolResult, ContentBlock, ElicitRequest, ElicitRequestParams, ElicitResult,
ElicitationAction, ElicitationSchema, EnumSchema, InputRequest, InputRequests, InputRequiredResult,
RequestMetaObject,
},
service::{ElicitationMode, RequestContext},
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use utils::artifact_review::{ArtifactReviewDecision, ArtifactReviewElicitationMeta, ArtifactReviewSubmission};
const REVIEW: &str = "review";
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReviewArtifactInput {
pub format: ArtifactFormat,
pub source: ArtifactSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
#[schemars(inline)]
pub enum ArtifactFormat {
Markdown,
Html,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
#[schemars(inline)]
pub enum ArtifactSource {
File { path: PathBuf },
Content { content: String },
Url { url: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "lowercase")]
pub enum ReviewArtifactOutput {
Approved,
Feedback {
feedback: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
annotations: Vec<HtmlAnnotation>,
},
Cancelled,
Declined,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct HtmlAnnotation {
pub element: String,
pub excerpt: String,
pub comment: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
pub struct ReviewArtifactTool {
root_dir: PathBuf,
pending: PendingReviews,
}
impl ReviewArtifactTool {
pub fn new(root_dir: PathBuf) -> Self {
Self { root_dir, pending: PendingReviews::default() }
}
pub async fn execute(
&self,
input: ReviewArtifactInput,
responses: InputResponses,
request_state: RequestState,
context: &RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
match responses.0 {
Some(responses) => self.respond(&responses, request_state, context).await,
None => Ok(self.request(input, context).await.unwrap_or_else(tool_error)),
}
}
async fn request(
&self,
input: ReviewArtifactInput,
context: &RequestContext<RoleServer>,
) -> Result<CallToolResponse, String> {
let ReviewArtifactInput { format, source, title } = input;
let mode = match format {
ArtifactFormat::Markdown => ElicitationMode::Form,
ArtifactFormat::Html => ElicitationMode::Url,
};
if !supports_elicitation(context, mode) {
return Err(format!("Reviewing an artifact requires user input, but {CLIENT_CANNOT_ELICIT}."));
}
let (params, review_token) = match format {
ArtifactFormat::Markdown => {
let (markdown, path) = self.load_document(source).await?;
let label = path.as_ref().map(|path| path.display().to_string());
let meta = ArtifactReviewElicitationMeta::new(
path,
review_title(title.as_deref(), label.as_deref()),
markdown,
);
(build_elicitation_form(&meta, &review_subject(title.as_deref(), label.as_deref()))?, None)
}
ArtifactFormat::Html => {
let (artifact, label) = self.load_html(source).await?;
let subject = review_subject(title.as_deref(), label.as_deref());
let server = ReviewServer::start(artifact)
.map_err(|error| format!("Failed to start the review server: {error}"))?;
let token = server.token().to_string();
let params = ElicitRequestParams::UrlElicitationParams {
meta: None,
message: format!("Review {subject} in your browser, then submit or cancel there."),
url: server.url().to_string(),
elicitation_id: token.clone(),
};
self.pending.insert(server);
(params, Some(token))
}
};
let requests =
InputRequests::from([(REVIEW.to_string(), InputRequest::Elicitation(ElicitRequest::new(params)))]);
Ok(InputRequiredResult::new(Some(requests), review_token).into())
}
async fn respond(
&self,
responses: &rmcp::model::InputResponses,
request_state: RequestState,
context: &RequestContext<RoleServer>,
) -> Result<CallToolResponse, McpError> {
let result: ElicitResult = parse_response(responses, REVIEW)?;
let output = match request_state.0 {
None => ReviewArtifactOutput::try_from(result)?,
Some(token) => {
let review = self.pending.take(&token);
match result.action {
ElicitationAction::Accept => {
let review = review.ok_or_else(|| {
McpError::invalid_params("the HTML review expired before it was submitted", None)
})?;
review.wait(context.ct.clone()).await
}
ElicitationAction::Cancel => ReviewArtifactOutput::Cancelled,
ElicitationAction::Decline => ReviewArtifactOutput::Declined,
_ => return Err(McpError::invalid_params("unknown elicitation action", None)),
}
}
};
Json(output).into_call_tool_result()
}
async fn load_document(&self, source: ArtifactSource) -> Result<(String, Option<PathBuf>), String> {
match source {
ArtifactSource::File { path } => {
let path = resolve_path(&self.root_dir, path);
Ok((read_file(&path).await?, Some(path)))
}
ArtifactSource::Content { content } => Ok((content, None)),
ArtifactSource::Url { url } => {
Err(format!("Markdown artifacts cannot be reviewed from a URL ({url}); pass a file or content."))
}
}
}
async fn load_html(&self, source: ArtifactSource) -> Result<(Artifact, Option<String>), String> {
match source {
ArtifactSource::File { path } => {
let path = resolve_path(&self.root_dir, path);
let html = read_file(&path).await?;
let assets = path.parent().map(Path::to_path_buf);
Ok((Artifact::Document { html, assets }, Some(path.display().to_string())))
}
ArtifactSource::Content { content } => Ok((Artifact::Document { html: content, assets: None }, None)),
ArtifactSource::Url { url } => {
let parsed = Url::parse(&url).map_err(|error| format!("Invalid artifact URL {url}: {error}"))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(format!("Artifact URL must use http or https: {url}"));
}
Ok((Artifact::App(parsed), Some(url)))
}
}
}
}
impl TryFrom<ElicitResult> for ReviewArtifactOutput {
type Error = McpError;
fn try_from(result: ElicitResult) -> Result<Self, Self::Error> {
match result.action {
ElicitationAction::Cancel => Ok(Self::Cancelled),
ElicitationAction::Decline => Ok(Self::Declined),
ElicitationAction::Accept => {
let content = result
.content
.ok_or_else(|| McpError::invalid_params("accepted review response must include content", None))?;
let submission: ArtifactReviewSubmission = serde_json::from_value(content).map_err(|error| {
McpError::invalid_params(format!("invalid accepted review response: {error}"), None)
})?;
Ok(match submission {
ArtifactReviewSubmission::Approved => Self::Approved,
ArtifactReviewSubmission::Feedback { feedback } => {
Self::Feedback { feedback, annotations: Vec::new() }
}
})
}
_ => Err(McpError::invalid_params("unknown elicitation action", None)),
}
}
}
fn review_title(title: Option<&str>, label: Option<&str>) -> String {
match (title, label) {
(Some(title), _) => title.to_string(),
(None, Some(label)) => format!("Review {label}"),
(None, None) => "Review".to_string(),
}
}
fn review_subject(title: Option<&str>, label: Option<&str>) -> String {
title.or(label).map_or_else(|| "this content".to_string(), str::to_string)
}
fn tool_error(message: String) -> CallToolResponse {
CallToolResult::error(vec![ContentBlock::text(message)]).into()
}
async fn read_file(path: &Path) -> Result<String, String> {
let metadata = tokio::fs::metadata(path)
.await
.map_err(|error| format!("Failed to read artifact {}: {error}", path.display()))?;
if !metadata.is_file() {
return Err(format!("Artifact is not a regular file: {}", path.display()));
}
tokio::fs::read_to_string(path)
.await
.map_err(|error| format!("Failed to read UTF-8 artifact {}: {error}", path.display()))
}
fn build_elicitation_form(
artifact: &ArtifactReviewElicitationMeta,
subject: &str,
) -> Result<ElicitRequestParams, String> {
let requested_schema = ElicitationSchema::builder()
.required_enum_schema(
"decision",
EnumSchema::builder(ArtifactReviewDecision::ALL.map(|decision| decision.to_string()).to_vec()).build(),
)
.optional_string("feedback")
.build()
.map_err(|error| format!("Failed to build the review form schema: {error}"))?;
Ok(ElicitRequestParams::FormElicitationParams {
meta: Some(RequestMetaObject::from(artifact.to_meta())),
message: format!("Review {subject} and approve or submit feedback."),
requested_schema,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_schema_is_a_self_contained_object() {
let schema = serde_json::to_value(schemars::schema_for!(ReviewArtifactInput)).expect("schema serializes");
let text = schema.to_string();
assert!(
!text.contains("$ref"),
"refs must be inlined so providers that ignore $defs still see the shape: {text}"
);
assert_eq!(schema["type"], "object", "providers require an object at the root: {text}");
for combinator in ["oneOf", "anyOf", "allOf"] {
assert!(schema.get(combinator).is_none(), "Anthropic rejects a top-level {combinator}: {text}");
}
}
}