use std::collections::HashMap;
use std::fmt;
use std::time::Duration;
use asupersync::Cx;
use fastmcp_core::{CanonicalHttpUrl, McpErrorCode, McpRequestCancellation};
use fastmcp_protocol::CoreResult;
use fastmcp_protocol::http_headers::ParameterHeaderBinding;
use serde_json::Value;
use super::{ManagedOAuthSession, OAuthSessionError, deadline_after};
use crate::http_executor::parameter_headers::ReviewedToolHeaders;
use crate::{ClientBuilder, HttpClient, HttpClientError, ProtocolPolicy};
mod cursors;
use cursors::{CatalogKind, CursorLedger};
pub enum ManagedHttpClientError {
InvalidPolicy,
Closed,
Session(OAuthSessionError),
Connection,
Request {
code: Option<McpErrorCode>,
},
CatalogGenerationChanged,
InvalidCatalogCursor,
CatalogCursorUnavailable,
}
impl fmt::Debug for ManagedHttpClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidPolicy => f.write_str("ManagedHttpClientError::InvalidPolicy"),
Self::Closed => f.write_str("ManagedHttpClientError::Closed"),
Self::Session(_) => f.write_str("ManagedHttpClientError::Session(..)"),
Self::Connection => f.write_str("ManagedHttpClientError::Connection"),
Self::Request { code } => f
.debug_struct("ManagedHttpClientError::Request")
.field("code", code)
.finish(),
Self::CatalogGenerationChanged => {
f.write_str("ManagedHttpClientError::CatalogGenerationChanged")
}
Self::InvalidCatalogCursor => {
f.write_str("ManagedHttpClientError::InvalidCatalogCursor")
}
Self::CatalogCursorUnavailable => {
f.write_str("ManagedHttpClientError::CatalogCursorUnavailable")
}
}
}
}
impl fmt::Display for ManagedHttpClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::InvalidPolicy => "invalid managed HTTP client policy",
Self::Closed => "managed HTTP client is closed",
Self::Session(_) => "managed OAuth operation failed",
Self::Connection => "authenticated MCP discovery failed",
Self::Request { .. } => "authenticated MCP request failed; not replayed",
Self::CatalogGenerationChanged => {
"catalog credential generation changed; restart listing without a cursor"
}
Self::InvalidCatalogCursor => {
"catalog cursor is stale, unissued, or belongs to another catalog"
}
Self::CatalogCursorUnavailable => {
"catalog continuation could not be retained within local bounds"
}
})
}
}
impl std::error::Error for ManagedHttpClientError {}
impl From<OAuthSessionError> for ManagedHttpClientError {
fn from(error: OAuthSessionError) -> Self {
Self::Session(error)
}
}
fn request_error(error: HttpClientError) -> ManagedHttpClientError {
let code = match error {
HttpClientError::CoreResult(error) => Some(error.code),
_ => None,
};
ManagedHttpClientError::Request { code }
}
struct Connection {
client: HttpClient,
generation: u64,
cursors: CursorLedger,
}
pub struct ManagedHttpClient {
session: ManagedOAuthSession,
builder: ClientBuilder,
operation_timeout: Duration,
connection: Option<Connection>,
closed: bool,
}
impl fmt::Debug for ManagedHttpClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ManagedHttpClient")
.field("closed", &self.closed)
.field("cached_generation", &self.cached_credential_generation())
.field("operation_timeout", &self.operation_timeout)
.finish_non_exhaustive()
}
}
fn admit_builder(
builder: &ClientBuilder,
resource: &CanonicalHttpUrl,
timeout: Duration,
) -> Result<(), ManagedHttpClientError> {
let plan = builder.selected_protocol_plan();
if timeout.is_zero()
|| timeout > Duration::from_mins(15)
|| resource.scheme() != "https"
|| plan.policy() != ProtocolPolicy::ModernOnly
|| plan.modern_post_target() != Some(resource.as_str())
{
return Err(ManagedHttpClientError::InvalidPolicy);
}
builder
.http_negotiation()
.map_err(|_| ManagedHttpClientError::InvalidPolicy)?;
Ok(())
}
impl ManagedHttpClient {
pub fn new(
session: ManagedOAuthSession,
builder: ClientBuilder,
operation_timeout: Duration,
) -> Result<Self, ManagedHttpClientError> {
admit_builder(&builder, session.resource(), operation_timeout)?;
Ok(Self {
session,
builder,
operation_timeout,
connection: None,
closed: false,
})
}
pub fn resource(&self) -> &CanonicalHttpUrl {
self.session.resource()
}
pub fn cached_credential_generation(&self) -> Option<u64> {
self.connection
.as_ref()
.map(|connection| connection.generation)
}
pub fn close(&mut self) {
self.closed = true;
self.connection = None;
}
pub async fn list_tools(
&mut self,
cx: &Cx,
cursor: Option<&str>,
) -> Result<CoreResult, ManagedHttpClientError> {
self.list_tools_with_cancellation(cx, &McpRequestCancellation::new(), cursor)
.await
}
pub async fn list_tools_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> Result<CoreResult, ManagedHttpClientError> {
self.perform(cx, cancellation, Call::Tools(cursor)).await
}
pub async fn list_resources(
&mut self,
cx: &Cx,
cursor: Option<&str>,
) -> Result<CoreResult, ManagedHttpClientError> {
self.list_resources_with_cancellation(cx, &McpRequestCancellation::new(), cursor)
.await
}
pub async fn list_resources_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> Result<CoreResult, ManagedHttpClientError> {
self.perform(cx, cancellation, Call::Resources(cursor))
.await
}
pub async fn list_resource_templates(
&mut self,
cx: &Cx,
cursor: Option<&str>,
) -> Result<CoreResult, ManagedHttpClientError> {
self.list_resource_templates_with_cancellation(cx, &McpRequestCancellation::new(), cursor)
.await
}
pub async fn list_resource_templates_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> Result<CoreResult, ManagedHttpClientError> {
self.perform(cx, cancellation, Call::Templates(cursor))
.await
}
pub async fn list_prompts(
&mut self,
cx: &Cx,
cursor: Option<&str>,
) -> Result<CoreResult, ManagedHttpClientError> {
self.list_prompts_with_cancellation(cx, &McpRequestCancellation::new(), cursor)
.await
}
pub async fn list_prompts_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> Result<CoreResult, ManagedHttpClientError> {
self.perform(cx, cancellation, Call::Prompts(cursor)).await
}
pub async fn call_tool(
&mut self,
cx: &Cx,
name: &str,
arguments: Value,
) -> Result<CoreResult, ManagedHttpClientError> {
self.call_tool_with_cancellation(cx, &McpRequestCancellation::new(), name, arguments)
.await
}
pub async fn call_tool_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
name: &str,
arguments: Value,
) -> Result<CoreResult, ManagedHttpClientError> {
self.perform(cx, cancellation, Call::Tool { name, arguments })
.await
}
pub async fn call_tool_with_reviewed_headers(
&mut self,
cx: &Cx,
arguments: Value,
reviewed: &ReviewedToolHeaders,
review: &(dyn Fn(&ParameterHeaderBinding) -> bool + Send + Sync),
) -> Result<CoreResult, ManagedHttpClientError> {
self.call_tool_with_reviewed_headers_and_cancellation(
cx,
&McpRequestCancellation::new(),
arguments,
reviewed,
review,
)
.await
}
pub async fn call_tool_with_reviewed_headers_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
arguments: Value,
reviewed: &ReviewedToolHeaders,
review: &(dyn Fn(&ParameterHeaderBinding) -> bool + Send + Sync),
) -> Result<CoreResult, ManagedHttpClientError> {
if reviewed.resource() != self.session.resource() {
return Err(ManagedHttpClientError::InvalidPolicy);
}
self.perform(
cx,
cancellation,
Call::Reviewed {
arguments,
reviewed,
review,
},
)
.await
}
pub async fn read_resource(
&mut self,
cx: &Cx,
uri: &str,
) -> Result<CoreResult, ManagedHttpClientError> {
self.read_resource_with_cancellation(cx, &McpRequestCancellation::new(), uri)
.await
}
pub async fn read_resource_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
uri: &str,
) -> Result<CoreResult, ManagedHttpClientError> {
self.perform(cx, cancellation, Call::Read(uri)).await
}
pub async fn get_prompt(
&mut self,
cx: &Cx,
name: &str,
arguments: HashMap<String, String>,
) -> Result<CoreResult, ManagedHttpClientError> {
self.get_prompt_with_cancellation(cx, &McpRequestCancellation::new(), name, arguments)
.await
}
pub async fn get_prompt_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
name: &str,
arguments: HashMap<String, String>,
) -> Result<CoreResult, ManagedHttpClientError> {
self.perform(cx, cancellation, Call::Prompt { name, arguments })
.await
}
async fn perform(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
call: Call<'_>,
) -> Result<CoreResult, ManagedHttpClientError> {
if self.closed {
return Err(ManagedHttpClientError::Closed);
}
self.session.check(cx, cancellation)?;
let deadline = deadline_after(cx, self.operation_timeout)?;
let catalog = call.catalog();
let continuation = catalog.is_some_and(|(_, cursor)| cursor.is_some());
if let Some((kind, Some(cursor))) = catalog {
let previous = self
.connection
.as_ref()
.ok_or(ManagedHttpClientError::CatalogGenerationChanged)?;
if !previous.cursors.admits(kind, cursor) {
return Err(ManagedHttpClientError::InvalidCatalogCursor);
}
}
let previous = self.connection.take();
let session = self.session.clone();
let builder = self.builder.clone();
let guarded = session.await_active(
cx,
cancellation,
deadline,
None,
Box::pin(async {
let snapshot = session
.credential_with_cancellation(cx, cancellation)
.await?;
let generation = snapshot.generation();
if continuation
&& previous
.as_ref()
.is_none_or(|old| old.generation != generation)
{
return Ok(Err(ManagedHttpClientError::CatalogGenerationChanged));
}
let reusable = previous.filter(|old| old.generation == generation);
session
.await_credential(
cx,
cancellation,
deadline,
snapshot.expires_at(),
&snapshot.credential.revoked,
Box::pin(async {
let (mut client, mut cursors) = match reusable {
Some(connection) => (connection.client, connection.cursors),
None => match builder
.http_bearer_credential(snapshot.credential().clone())
.connect_http_client_with_cx(cx)
.await
{
Ok(client) => (client, CursorLedger::default()),
Err(_) => return Ok(Err(ManagedHttpClientError::Connection)),
},
};
let wire_cursor = match catalog {
Some((kind, cursor)) => match cursors.take(kind, cursor) {
Ok(cursor) => cursor,
Err(error) => return Ok(Err(error)),
},
None => None,
};
let result = call
.dispatch(cx, cancellation, &mut client, wire_cursor.as_deref())
.await;
let mut value = match result {
Ok(value) => value,
Err(error) => return Ok(Err(request_error(error))),
};
if let Some((kind, _)) = catalog {
if let Err(error) = cursors.publish(kind, &mut value) {
return Ok(Err(error));
}
}
Ok(Ok((
value,
Connection {
client,
generation,
cursors,
},
)))
}),
)
.await
}),
);
let guarded = Box::pin(guarded).await?;
let (value, connection) = guarded?;
self.connection = Some(connection);
Ok(value)
}
}
enum Call<'a> {
Tools(Option<&'a str>),
Resources(Option<&'a str>),
Templates(Option<&'a str>),
Prompts(Option<&'a str>),
Tool {
name: &'a str,
arguments: Value,
},
Reviewed {
arguments: Value,
reviewed: &'a ReviewedToolHeaders,
review: &'a (dyn Fn(&ParameterHeaderBinding) -> bool + Send + Sync),
},
Read(&'a str),
Prompt {
name: &'a str,
arguments: HashMap<String, String>,
},
}
impl<'a> Call<'a> {
fn catalog(&self) -> Option<(CatalogKind, Option<&'a str>)> {
match self {
Self::Tools(cursor) => Some((CatalogKind::Tools, *cursor)),
Self::Resources(cursor) => Some((CatalogKind::Resources, *cursor)),
Self::Templates(cursor) => Some((CatalogKind::Templates, *cursor)),
Self::Prompts(cursor) => Some((CatalogKind::Prompts, *cursor)),
_ => None,
}
}
async fn dispatch(
self,
cx: &Cx,
cancellation: &McpRequestCancellation,
client: &mut HttpClient,
wire_cursor: Option<&str>,
) -> Result<CoreResult, HttpClientError> {
match self {
Self::Tools(_) => client.list_tools(cx, wire_cursor).await,
Self::Resources(_) => client.list_resources(cx, wire_cursor).await,
Self::Templates(_) => client.list_resource_templates(cx, wire_cursor).await,
Self::Prompts(_) => client.list_prompts(cx, wire_cursor).await,
Self::Tool { name, arguments } => {
client
.call_tool_with_cancellation(cx, cancellation, name, arguments)
.await
}
Self::Reviewed {
arguments,
reviewed,
review,
} => {
client
.call_tool_with_reviewed_headers_and_cancellation(
cx,
cancellation,
arguments,
reviewed,
review,
)
.await
}
Self::Read(uri) => client.read_resource(cx, uri).await,
Self::Prompt { name, arguments } => client.get_prompt(cx, name, arguments).await,
}
}
}
#[cfg(test)]
mod tests;