use elicitation::ElicitPlugin;
use elicitation::contracts::{And, Established, Prop};
use futures::future::BoxFuture;
use rmcp::{
ErrorData,
model::{CallToolRequestParams, CallToolResult, Content, Tool},
service::RequestContext,
};
use schemars::JsonSchema;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::instrument;
pub struct UrlParsed;
impl Prop for UrlParsed {}
pub struct HttpsRequired;
impl Prop for HttpsRequired {}
pub struct SchemeAllowed;
impl Prop for SchemeAllowed {}
pub type SecureUrl = And<UrlParsed, HttpsRequired>;
pub struct UnvalidatedUrl {
src: String,
}
pub struct ParsedUrl {
inner: url::Url,
}
pub struct SecureUrlState {
inner: url::Url,
}
impl UnvalidatedUrl {
pub fn new(src: impl Into<String>) -> Self {
Self { src: src.into() }
}
pub fn parse(self) -> Result<(ParsedUrl, Established<UrlParsed>), String> {
url::Url::parse(&self.src)
.map(|inner| (ParsedUrl { inner }, Established::assert()))
.map_err(|e| format!("UrlParsed not established: {e}"))
}
}
impl ParsedUrl {
pub fn as_str(&self) -> &str {
self.inner.as_str()
}
pub fn assert_https(
self,
parsed: Established<UrlParsed>,
) -> Result<(SecureUrlState, Established<SecureUrl>), String> {
if self.inner.scheme() == "https" {
let secure_proof =
elicitation::contracts::both(parsed, Established::<HttpsRequired>::assert());
Ok((SecureUrlState { inner: self.inner }, secure_proof))
} else {
Err(format!(
"HttpsRequired not established: scheme is '{}', expected 'https'",
self.inner.scheme()
))
}
}
pub fn validate_scheme(
self,
allowed: &[&str],
_parsed: Established<UrlParsed>,
) -> Result<(ParsedUrl, Established<And<UrlParsed, SchemeAllowed>>), String> {
if allowed.contains(&self.inner.scheme()) {
let proof = elicitation::contracts::both(
Established::<UrlParsed>::assert(),
Established::<SchemeAllowed>::assert(),
);
Ok((self, proof))
} else {
Err(format!(
"SchemeAllowed not established: scheme '{}' not in {:?}",
self.inner.scheme(),
allowed,
))
}
}
}
impl SecureUrlState {
pub fn as_str(&self) -> &str {
self.inner.as_str()
}
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct ParseUrlParams {
pub url: String,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct AssertHttpsParams {
pub url: String,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct ValidateSchemeParams {
pub url: String,
pub allowed_schemes: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct BuildUrlParams {
pub base: String,
pub path: Option<String>,
pub params: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct JoinUrlParams {
pub base: String,
pub relative: String,
}
fn typed_tool<T: JsonSchema + 'static>(name: &'static str, description: &'static str) -> Tool {
Tool::new(name, description, Arc::new(Default::default())).with_input_schema::<T>()
}
fn parse_args<T: serde::de::DeserializeOwned>(
params: &CallToolRequestParams,
) -> Result<T, ErrorData> {
let value = serde_json::Value::Object(params.arguments.clone().unwrap_or_default());
serde_json::from_value(value).map_err(|e| ErrorData::invalid_params(e.to_string(), None))
}
#[derive(Debug)]
pub struct UrlWorkflowPlugin;
impl ElicitPlugin for UrlWorkflowPlugin {
fn name(&self) -> &'static str {
"url_workflow"
}
fn list_tools(&self) -> Vec<Tool> {
vec![
typed_tool::<ParseUrlParams>(
"parse_url",
"Parse a raw URL string and validate its syntax. \
Establishes: UrlParsed. \
Returns scheme, host, port, path, query, and fragment.",
),
typed_tool::<AssertHttpsParams>(
"assert_https",
"Parse a URL and assert that its scheme is 'https'. \
Establishes: UrlParsed ∧ HttpsRequired. \
Fails if the URL is invalid OR the scheme is not https.",
),
typed_tool::<ValidateSchemeParams>(
"validate_scheme",
"Parse a URL and assert that its scheme is in the supplied allow-list. \
Establishes: UrlParsed ∧ SchemeAllowed. \
Useful for restricting to ['https', 'wss'] or similar safe sets.",
),
typed_tool::<BuildUrlParams>(
"build_url",
"Build a canonical URL from a base, optional path, and optional query params. \
Establishes: UrlParsed on the resulting URL. \
The result is percent-encoded and normalized.",
),
typed_tool::<JoinUrlParams>(
"join_url",
"Resolve a relative URL or path against a base URL (RFC 3986). \
Establishes: UrlParsed(base) ∧ UrlParsed(result). \
Handles `../`, query strings, and fragment identifiers correctly.",
),
]
}
#[instrument(skip(self, _ctx), fields(tool = %params.name))]
fn call_tool<'a>(
&'a self,
params: CallToolRequestParams,
_ctx: RequestContext<rmcp::RoleServer>,
) -> BoxFuture<'a, Result<CallToolResult, ErrorData>> {
Box::pin(async move {
let bare = params.name.trim_start_matches("url_workflow__");
match bare {
"parse_url" => {
let p: ParseUrlParams = parse_args(¶ms)?;
let (parsed, _proof) = match UnvalidatedUrl::new(p.url).parse() {
Ok(r) => r,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
let inner = &parsed.inner;
let summary = format!(
"UrlParsed established.\n\
url: {}\n\
scheme: {}\n\
host: {}\n\
port: {}\n\
path: {}\n\
query: {}\n\
fragment: {}",
inner.as_str(),
inner.scheme(),
inner.host_str().unwrap_or(""),
inner.port().map(|p| p.to_string()).unwrap_or_default(),
inner.path(),
inner.query().unwrap_or(""),
inner.fragment().unwrap_or(""),
);
Ok(CallToolResult::success(vec![Content::text(summary)]))
}
"assert_https" => {
let p: AssertHttpsParams = parse_args(¶ms)?;
let (parsed, parsed_proof) = match UnvalidatedUrl::new(p.url).parse() {
Ok(r) => r,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
let (secure, _proof) = match parsed.assert_https(parsed_proof) {
Ok(r) => r,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
Ok(CallToolResult::success(vec![Content::text(format!(
"UrlParsed ∧ HttpsRequired established.\nurl: {}",
secure.as_str()
))]))
}
"validate_scheme" => {
let p: ValidateSchemeParams = parse_args(¶ms)?;
let allowed: Vec<&str> = p.allowed_schemes.iter().map(String::as_str).collect();
let (parsed, parsed_proof) = match UnvalidatedUrl::new(p.url).parse() {
Ok(r) => r,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
let (validated, _proof) = match parsed.validate_scheme(&allowed, parsed_proof) {
Ok(r) => r,
Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
};
Ok(CallToolResult::success(vec![Content::text(format!(
"UrlParsed ∧ SchemeAllowed established.\nscheme: {}\nurl: {}",
validated.inner.scheme(),
validated.inner.as_str()
))]))
}
"build_url" => {
let p: BuildUrlParams = parse_args(¶ms)?;
let mut url = match url::Url::parse(&p.base) {
Ok(u) => u,
Err(e) => {
return Ok(CallToolResult::error(vec![Content::text(format!(
"UrlParsed not established for base: {e}"
))]));
}
};
if let Some(path) = p.path {
let base_str = url.as_str().trim_end_matches('/').to_string() + "/";
let base = url::Url::parse(&base_str).unwrap_or(url.clone());
let path_clean = path.trim_start_matches('/');
url = match base.join(path_clean) {
Ok(u) => u,
Err(e) => {
return Ok(CallToolResult::error(vec![Content::text(format!(
"Path join failed: {e}"
))]));
}
};
}
if let Some(qp) = p.params {
let mut pairs: Vec<(String, String)> = url
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
for (k, v) in qp {
pairs.push((k, v));
}
let mut new_url = url.clone();
new_url.set_query(None);
{
let mut serializer = new_url.query_pairs_mut();
for (k, v) in &pairs {
serializer.append_pair(k, v);
}
}
url = new_url;
}
Ok(CallToolResult::success(vec![Content::text(format!(
"UrlParsed established.\nurl: {}",
url.as_str()
))]))
}
"join_url" => {
let p: JoinUrlParams = parse_args(¶ms)?;
let base = match url::Url::parse(&p.base) {
Ok(u) => u,
Err(e) => {
return Ok(CallToolResult::error(vec![Content::text(format!(
"UrlParsed not established for base: {e}"
))]));
}
};
let result = match base.join(&p.relative) {
Ok(u) => u,
Err(e) => {
return Ok(CallToolResult::error(vec![Content::text(format!(
"Join failed: {e}"
))]));
}
};
Ok(CallToolResult::success(vec![Content::text(format!(
"UrlParsed(base) ∧ UrlParsed(result) established.\nresult: {}",
result.as_str()
))]))
}
other => Ok(CallToolResult::error(vec![Content::text(format!(
"Unknown tool: {other}"
))])),
}
})
}
}
#[cfg(feature = "emit")]
use elicitation::emit_code::{CrateDep, EmitCode};
#[cfg(feature = "emit")]
use proc_macro2::TokenStream;
#[cfg(feature = "emit")]
const ELICIT_URL_DEP: CrateDep = CrateDep::new("elicit_url", "0.8");
#[cfg(feature = "emit")]
const ELICITATION_DEP: CrateDep = CrateDep::new("elicitation", "0.8");
#[cfg(feature = "emit")]
impl EmitCode for ParseUrlParams {
fn emit_code(&self) -> TokenStream {
let url = &self.url;
quote::quote! {
let (_parsed, _url_proof) = elicit_url::UnvalidatedUrl::new(#url.to_string())
.parse()
.map_err(|e| format!("URL parse failed: {}", e))?;
println!("UrlParsed: {}", _parsed.as_str());
}
}
fn crate_deps(&self) -> Vec<CrateDep> {
vec![ELICITATION_DEP, ELICIT_URL_DEP]
}
}
#[cfg(feature = "emit")]
impl EmitCode for AssertHttpsParams {
fn emit_code(&self) -> TokenStream {
let url = &self.url;
quote::quote! {
let (_parsed, _parsed_proof) = elicit_url::UnvalidatedUrl::new(#url.to_string())
.parse()
.map_err(|e| format!("URL parse failed: {}", e))?;
let (_secure, _proof) = _parsed.assert_https(_parsed_proof)
.map_err(|e| format!("HttpsRequired not established: {}", e))?;
println!("UrlParsed \u{2227} HttpsRequired: {}", _secure.as_str());
}
}
fn crate_deps(&self) -> Vec<CrateDep> {
vec![ELICITATION_DEP, ELICIT_URL_DEP]
}
}
#[cfg(feature = "emit")]
impl EmitCode for ValidateSchemeParams {
fn emit_code(&self) -> TokenStream {
let url = &self.url;
let schemes: Vec<&str> = self.allowed_schemes.iter().map(String::as_str).collect();
quote::quote! {
let (_parsed, _parsed_proof) = elicit_url::UnvalidatedUrl::new(#url.to_string())
.parse()
.map_err(|e| format!("URL parse failed: {}", e))?;
let allowed: &[&str] = &[ #( #schemes ),* ];
let (_validated, _proof) = _parsed.validate_scheme(allowed, _parsed_proof)
.map_err(|e| format!("SchemeAllowed not established: {}", e))?;
println!("UrlParsed \u{2227} SchemeAllowed: {}", _validated.as_str());
}
}
fn crate_deps(&self) -> Vec<CrateDep> {
vec![ELICITATION_DEP, ELICIT_URL_DEP]
}
}
#[cfg(feature = "emit")]
impl EmitCode for BuildUrlParams {
fn emit_code(&self) -> TokenStream {
let base = &self.base;
let path_opt = &self.path;
let has_path = path_opt.is_some();
let path_val = path_opt.as_deref().unwrap_or("");
let (qk, qv): (Vec<_>, Vec<_>) = self
.params
.as_ref()
.map(|m| m.iter().map(|(k, v)| (k.as_str(), v.as_str())).unzip())
.unwrap_or_default();
let has_params = !qk.is_empty();
quote::quote! {
let mut _url = ::url::Url::parse(#base)
.map_err(|e| format!("URL parse failed: {}", e))?;
if #has_path {
let _base_str = _url.as_str().trim_end_matches('/').to_string() + "/";
let _base2 = ::url::Url::parse(&_base_str).unwrap_or(_url.clone());
_url = _base2.join(#path_val.trim_start_matches('/'))
.map_err(|e| format!("Path join failed: {}", e))?;
}
if #has_params {
let mut _pairs: Vec<(String, String)> = _url.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
#( _pairs.push((#qk.to_string(), #qv.to_string())); )*
let mut _new_url = _url.clone();
_new_url.set_query(None);
{ let mut _s = _new_url.query_pairs_mut(); for (k, v) in &_pairs { _s.append_pair(k, v); } }
_url = _new_url;
}
println!("UrlParsed (built): {}", _url.as_str());
}
}
fn crate_deps(&self) -> Vec<CrateDep> {
vec![ELICITATION_DEP, ELICIT_URL_DEP, CrateDep::new("url", "2")]
}
}
#[cfg(feature = "emit")]
impl EmitCode for JoinUrlParams {
fn emit_code(&self) -> TokenStream {
let base = &self.base;
let relative = &self.relative;
quote::quote! {
let _base = ::url::Url::parse(#base)
.map_err(|e| format!("URL parse failed: {}", e))?;
let _result = _base.join(#relative)
.map_err(|e| format!("Join failed: {}", e))?;
println!("UrlParsed(base) \u{2227} UrlParsed(result): {}", _result.as_str());
}
}
fn crate_deps(&self) -> Vec<CrateDep> {
vec![ELICITATION_DEP, ELICIT_URL_DEP, CrateDep::new("url", "2")]
}
}
#[cfg(feature = "emit")]
pub fn dispatch_emit(
tool_name: &str,
params: serde_json::Value,
) -> Result<Box<dyn EmitCode>, String> {
match tool_name {
"parse_url" => serde_json::from_value::<ParseUrlParams>(params)
.map(|p| Box::new(p) as Box<dyn EmitCode>)
.map_err(|e| format!("{e}")),
"assert_https" => serde_json::from_value::<AssertHttpsParams>(params)
.map(|p| Box::new(p) as Box<dyn EmitCode>)
.map_err(|e| format!("{e}")),
"validate_scheme" => serde_json::from_value::<ValidateSchemeParams>(params)
.map(|p| Box::new(p) as Box<dyn EmitCode>)
.map_err(|e| format!("{e}")),
"build_url" => serde_json::from_value::<BuildUrlParams>(params)
.map(|p| Box::new(p) as Box<dyn EmitCode>)
.map_err(|e| format!("{e}")),
"join_url" => serde_json::from_value::<JoinUrlParams>(params)
.map(|p| Box::new(p) as Box<dyn EmitCode>)
.map_err(|e| format!("{e}")),
other => Err(format!("Unknown url_workflow tool: '{other}'")),
}
}