use elicitation::contracts::{And, Established, Prop};
use elicitation::{ElicitPlugin, elicit_tool};
use rmcp::{
ErrorData,
model::{CallToolResult, Content},
};
use schemars::JsonSchema;
use serde::Deserialize;
use std::collections::HashMap;
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 {
pub inner: url::Url,
}
pub struct SecureUrlState {
pub 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,
}
#[derive(Debug, ElicitPlugin)]
#[plugin(name = "url_workflow")]
pub struct UrlWorkflowPlugin;
#[elicit_tool(
plugin = "url_workflow",
name = "parse_url",
description = "Parse a raw URL string and validate its syntax. \
Establishes: UrlParsed. \
Returns scheme, host, port, path, query, and fragment."
)]
#[instrument(skip_all)]
async fn parse_url(p: ParseUrlParams) -> Result<CallToolResult, ErrorData> {
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(|port| port.to_string())
.unwrap_or_default(),
inner.path(),
inner.query().unwrap_or(""),
inner.fragment().unwrap_or(""),
);
Ok(CallToolResult::success(vec![Content::text(summary)]))
}
#[elicit_tool(
plugin = "url_workflow",
name = "assert_https",
description = "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."
)]
#[instrument(skip_all)]
async fn assert_https(p: AssertHttpsParams) -> Result<CallToolResult, ErrorData> {
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()
))]))
}
#[elicit_tool(
plugin = "url_workflow",
name = "validate_scheme",
description = "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."
)]
#[instrument(skip_all)]
async fn validate_scheme(p: ValidateSchemeParams) -> Result<CallToolResult, ErrorData> {
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()
))]))
}
#[elicit_tool(
plugin = "url_workflow",
name = "build_url",
description = "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."
)]
#[instrument(skip_all)]
async fn build_url(p: BuildUrlParams) -> Result<CallToolResult, ErrorData> {
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()
))]))
}
#[elicit_tool(
plugin = "url_workflow",
name = "join_url",
description = "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_all)]
async fn join_url(p: JoinUrlParams) -> Result<CallToolResult, ErrorData> {
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()
))]))
}