pmcp_server_toolkit/http/mod.rs
1// Net-new code for Phase 90 OAPI-01 (HttpConnector trait + HttpClient) +
2// OAPI-03 (HttpAuthProvider + AuthConfig six modes). SHAPE lifted from the SQL
3// connector analog (`crate::sql`); BODY lifted from the pmcp-run OpenAPI
4// reference (`mcp-openapi-server-core`): the reference HTTP client + the shared
5// `mcp-server-common` auth providers. The lift replaces the `mcp_server_common`
6// path-dependency with toolkit-owned types.
7
8//! HTTP backend primitives for config-driven OpenAPI MCP servers.
9//!
10//! This module is the backend seam the single-call synthesizer (Plan 03), the
11//! code-mode executor (Plan 04), and the binary dispatch (Plan 06) build on. It
12//! mirrors [`crate::sql`] in shape:
13//!
14//! - [`HttpConnector`] — the `#[async_trait] Send + Sync + 'static` trait that
15//! executes a REST [`Operation`] and returns JSON (analog of `SqlConnector`).
16//! - [`HttpConnectorError`] — the `#[non_exhaustive]` error enum whose `Display`
17//! reaches MCP clients and therefore MUST NOT echo credentials or URLs (analog
18//! of `ConnectorError`, mirrors its Connection Security doc-comment).
19//! - [`Operation`] / [`Parameter`] / [`ParameterLocation`] — the request model
20//! the trait signature needs. AUTHORITATIVE in [`schema`] (the `openapiv3`
21//! parser is their producer) and re-exported here so the trait signature and
22//! every later plan reference one stable type path (Plan 03 / OAPI-02).
23//! - [`join_url`] — the ONE shared `base_url` + `path` concatenation helper. Both
24//! [`client::HttpClient`] (this plan) and Plan 04's `HttpCodeExecutor` call it
25//! instead of re-inlining the trim logic — it preserves an API-Gateway stage
26//! prefix (`/v1`) where `Url::join` would silently drop it (Pitfall 2).
27//!
28//! The whole module is gated behind the opt-in `http` feature so the curated /
29//! no-`http` toolkit build stays light (RESEARCH Pitfall 4).
30
31// Why: HTTP method names ("GET", "POST") and product nouns ("OpenAPI") are
32// proper nouns / acronyms clippy::doc_markdown otherwise flags for back-ticks.
33#![allow(clippy::doc_markdown)]
34
35use async_trait::async_trait;
36use thiserror::Error;
37
38/// Authentication providers for OUTGOING HTTP requests (OAPI-03 / D-05).
39pub mod auth;
40/// reqwest-backed [`HttpConnector`] implementation (OAPI-01).
41pub mod client;
42/// OpenAPI schema parsing seam — forward stub filled by Plan 03 (OAPI-02).
43pub mod schema;
44
45#[doc(inline)]
46pub use auth::{
47 create_auth_provider, create_passthrough_auth_provider, AuthConfig, HttpAuthProvider,
48};
49#[doc(inline)]
50pub use client::{HttpClient, HttpConfig};
51
52// Operation / Parameter / ParameterLocation are AUTHORITATIVE in `schema` (the
53// parser is their producer). They are re-exported here so the
54// [`HttpConnector::execute`] trait signature and every Plan (01/03/04/05) keep
55// referencing ONE stable type path — the type never moves home again (Codex
56// MEDIUM: keep `Operation` in one place from day one).
57#[doc(inline)]
58pub use schema::{OpenApiSchema, Operation, Parameter, ParameterLocation};
59
60/// Concatenate a base URL and a request path with exactly one separating slash,
61/// PRESERVING any non-root path already on the base.
62///
63/// This is the ONE shared URL-join helper for the `http` module (de-dup: both
64/// [`client::HttpClient`] and Plan 04's `HttpCodeExecutor` call it). It is
65/// deliberately NOT `Url::join`, which follows RFC 3986 and treats an absolute
66/// request path (e.g. `/users`) as REPLACING the base path — that silently drops
67/// an API-Gateway stage prefix like `/v1` (Pitfall 2 / T-90-01-05).
68///
69/// # Examples
70///
71/// ```
72/// # // join_url is pub(crate); the behaviour is asserted in the module tests.
73/// // join_url("https://x/v1", "/users") == "https://x/v1/users"
74/// ```
75#[must_use]
76pub(crate) fn join_url(base: &str, path: &str) -> String {
77 format!(
78 "{}/{}",
79 base.trim_end_matches('/'),
80 path.trim_start_matches('/')
81 )
82}
83
84/// Errors an [`HttpConnector`] implementation may surface.
85///
86/// The enum is `#[non_exhaustive]` so later plans can add failure modes
87/// additively without a semver break (mirrors [`crate::sql::ConnectorError`]).
88///
89/// # Security
90///
91/// The inner `String` of every variant reaches MCP clients via `Display`.
92/// Implementors MUST NOT include the request URL, an `Authorization` header
93/// value, a bearer token, or an `app_key` in any inner `String` — those are
94/// credentials or capability-bearing locators. Construct error messages from
95/// non-secret context only (status code, a static reason). This mirrors the
96/// `ConnectorError::Connection` discipline in `sql/mod.rs` (T-90-01-01).
97#[derive(Debug, Error)]
98#[non_exhaustive]
99pub enum HttpConnectorError {
100 /// The outgoing request failed at the transport layer (connect / timeout /
101 /// body read). The reqwest error is deliberately NOT forwarded verbatim —
102 /// its `Display` can echo the URL — so this carries a redacted reason only.
103 #[error("http request failed: {0}")]
104 Request(String),
105
106 /// The backend returned a non-2xx HTTP status.
107 #[error("http backend returned status {status}")]
108 Status {
109 /// The HTTP status code (e.g. `401`, `503`).
110 status: u16,
111 },
112
113 /// Authentication could not be applied to the outgoing request (e.g. a
114 /// required passthrough token was absent). The reason MUST NOT echo the
115 /// token or header value.
116 #[error("authentication failed: {0}")]
117 Auth(String),
118
119 /// A header name or value could not be constructed from the configured /
120 /// supplied value. The reason MUST NOT echo a credential header's value.
121 #[error("invalid header: {0}")]
122 InvalidHeader(String),
123
124 /// A backend / configuration problem not covered by the variants above
125 /// (e.g. an unparseable base URL, an unknown HTTP method).
126 #[error("http backend error: {0}")]
127 Backend(String),
128}
129
130/// Backend-agnostic HTTP connector trait (OAPI-01).
131///
132/// The analog of [`crate::sql::SqlConnector`] for REST backends: an
133/// implementation executes an [`Operation`] against a configured base URL and
134/// returns the response body as JSON. [`base_url`](HttpConnector::base_url) is
135/// the analog of `SqlConnector::dialect()` — a cheap accessor used by the
136/// synthesizer / prompt assembly.
137///
138/// # Example
139///
140/// A minimal connector. The example defines a LOCAL dummy struct so the doctest
141/// does not depend on any downstream crate (mirrors the `SqlConnector` doctest).
142///
143/// ```no_run
144/// use pmcp_server_toolkit::http::{HttpConnector, HttpConnectorError, Operation};
145/// use async_trait::async_trait;
146/// use serde_json::Value;
147///
148/// struct Dummy;
149///
150/// #[async_trait]
151/// impl HttpConnector for Dummy {
152/// fn base_url(&self) -> &str { "https://api.example.com/v1" }
153/// async fn execute(&self, _operation: &Operation, _args: &Value)
154/// -> Result<Value, HttpConnectorError> {
155/// Ok(Value::Null)
156/// }
157/// }
158/// ```
159#[async_trait]
160pub trait HttpConnector: Send + Sync + 'static {
161 /// Execute `operation` with the caller-supplied `args` (a JSON object whose
162 /// keys map to path / query / header / body parameters) and return the
163 /// response body as a [`serde_json::Value`].
164 ///
165 /// # Errors
166 ///
167 /// Returns [`HttpConnectorError`] when the request fails at the transport
168 /// layer ([`HttpConnectorError::Request`]), the backend returns a non-2xx
169 /// status ([`HttpConnectorError::Status`]), authentication cannot be applied
170 /// ([`HttpConnectorError::Auth`]), or a header is invalid
171 /// ([`HttpConnectorError::InvalidHeader`]). Per the type-level Security note,
172 /// no error message echoes a URL or credential.
173 async fn execute(
174 &self,
175 operation: &Operation,
176 args: &serde_json::Value,
177 ) -> Result<serde_json::Value, HttpConnectorError>;
178
179 /// The configured base URL (analog of `SqlConnector::dialect()`).
180 fn base_url(&self) -> &str;
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn test_join_url_preserves_prefix() {
189 // The API-Gateway stage prefix `/v1` survives; exactly one slash joins.
190 assert_eq!(join_url("https://x/v1", "/users"), "https://x/v1/users");
191 // Trailing slash on base + leading slash on path collapse to one.
192 assert_eq!(join_url("https://x/v1/", "/users"), "https://x/v1/users");
193 // No leading slash on path still joins with exactly one slash.
194 assert_eq!(join_url("https://x/v1", "users"), "https://x/v1/users");
195 // Root base.
196 assert_eq!(join_url("https://x", "/users"), "https://x/users");
197 }
198
199 /// T-90-01-01: the rendered `Display` of every error variant MUST NOT echo a
200 /// URL, an `Authorization`/`Bearer` value, or an `app_key`. Mirrors the SQL
201 /// redaction test `test_connection_display_does_not_echo_password`.
202 #[test]
203 fn test_http_error_display_does_not_echo_secret() {
204 let variants = [
205 HttpConnectorError::Request("connect timed out".to_string()),
206 HttpConnectorError::Status { status: 401 },
207 HttpConnectorError::Auth("required token absent".to_string()),
208 HttpConnectorError::InvalidHeader("name contains illegal byte".to_string()),
209 HttpConnectorError::Backend("unknown method".to_string()),
210 ];
211 for err in &variants {
212 let rendered = format!("{err}");
213 for forbidden in ["Bearer", "Authorization", "app_key", "https://", "http://"] {
214 assert!(
215 !rendered.contains(forbidden),
216 "HttpConnectorError Display must not echo {forbidden:?}; got {rendered:?}"
217 );
218 }
219 }
220 }
221
222 /// display_no_secret: the named `verify` automated check — Status{401}
223 /// renders the code but never a credential token.
224 #[test]
225 fn display_no_secret_status_shows_code() {
226 let rendered = HttpConnectorError::Status { status: 401 }.to_string();
227 assert!(
228 rendered.contains("401"),
229 "status code must be visible: {rendered:?}"
230 );
231 for forbidden in ["Bearer", "Authorization", "app_key", "https://"] {
232 assert!(!rendered.contains(forbidden), "must not echo {forbidden:?}");
233 }
234 }
235
236 #[test]
237 fn connector_trait_object_is_send_sync_static() {
238 fn assert_send_sync<T: Send + Sync + 'static>() {}
239 assert_send_sync::<Box<dyn HttpConnector>>();
240 }
241}