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 std::sync::Arc;
37use thiserror::Error;
38
39/// Authentication providers for OUTGOING HTTP requests (OAPI-03 / D-05).
40pub mod auth;
41/// reqwest-backed [`HttpConnector`] implementation (OAPI-01).
42pub mod client;
43/// OpenAPI schema parsing seam — forward stub filled by Plan 03 (OAPI-02).
44pub mod schema;
45
46#[doc(inline)]
47pub use auth::{
48 create_auth_provider, create_passthrough_auth_provider, AuthConfig, HttpAuthProvider,
49};
50#[doc(inline)]
51pub use client::{HttpClient, HttpConfig};
52
53// Operation / Parameter / ParameterLocation are AUTHORITATIVE in `schema` (the
54// parser is their producer). They are re-exported here so the
55// [`HttpConnector::execute`] trait signature and every Plan (01/03/04/05) keep
56// referencing ONE stable type path — the type never moves home again (Codex
57// MEDIUM: keep `Operation` in one place from day one).
58#[doc(inline)]
59pub use schema::{OpenApiSchema, Operation, Parameter, ParameterLocation};
60
61/// Concatenate a base URL and a request path with exactly one separating slash,
62/// PRESERVING any non-root path already on the base.
63///
64/// This is the ONE shared URL-join helper for the `http` module (de-dup: both
65/// [`client::HttpClient`] and Plan 04's `HttpCodeExecutor` call it). It is
66/// deliberately NOT `Url::join`, which follows RFC 3986 and treats an absolute
67/// request path (e.g. `/users`) as REPLACING the base path — that silently drops
68/// an API-Gateway stage prefix like `/v1` (Pitfall 2 / T-90-01-05).
69///
70/// # Examples
71///
72/// ```
73/// # // join_url is pub(crate); the behaviour is asserted in the module tests.
74/// // join_url("https://x/v1", "/users") == "https://x/v1/users"
75/// ```
76#[must_use]
77pub(crate) fn join_url(base: &str, path: &str) -> String {
78 format!(
79 "{}/{}",
80 base.trim_end_matches('/'),
81 path.trim_start_matches('/')
82 )
83}
84
85/// Errors an [`HttpConnector`] implementation may surface.
86///
87/// The enum is `#[non_exhaustive]` so later plans can add failure modes
88/// additively without a semver break (mirrors [`crate::sql::ConnectorError`]).
89///
90/// # Security
91///
92/// The inner `String` of every variant reaches MCP clients via `Display`.
93/// Implementors MUST NOT include the request URL, an `Authorization` header
94/// value, a bearer token, or an `app_key` in any inner `String` — those are
95/// credentials or capability-bearing locators. Construct error messages from
96/// non-secret context only (status code, a static reason). This mirrors the
97/// `ConnectorError::Connection` discipline in `sql/mod.rs` (T-90-01-01).
98#[derive(Debug, Error)]
99#[non_exhaustive]
100pub enum HttpConnectorError {
101 /// The outgoing request failed at the transport layer (connect / timeout /
102 /// body read). The reqwest error is deliberately NOT forwarded verbatim —
103 /// its `Display` can echo the URL — so this carries a redacted reason only.
104 #[error("http request failed: {0}")]
105 Request(String),
106
107 /// The backend returned a non-2xx HTTP status.
108 #[error("http backend returned status {status}")]
109 Status {
110 /// The HTTP status code (e.g. `401`, `503`).
111 status: u16,
112 },
113
114 /// Authentication could not be applied to the outgoing request (e.g. a
115 /// required passthrough token was absent). The reason MUST NOT echo the
116 /// token or header value.
117 #[error("authentication failed: {0}")]
118 Auth(String),
119
120 /// A header name or value could not be constructed from the configured /
121 /// supplied value. The reason MUST NOT echo a credential header's value.
122 #[error("invalid header: {0}")]
123 InvalidHeader(String),
124
125 /// A backend / configuration problem not covered by the variants above
126 /// (e.g. an unparseable base URL, an unknown HTTP method).
127 #[error("http backend error: {0}")]
128 Backend(String),
129
130 /// A registered E1 [`crate::policy::RequestPolicy`] refused the outbound
131 /// request (Phase 128). Nothing was authenticated and nothing was sent.
132 ///
133 /// Its OWN variant rather than folding into [`Self::Backend`], because a
134 /// refusal by a security control and a broken backend are different facts and
135 /// an operator tracing a rule that started refusing calls needs to tell them
136 /// apart.
137 ///
138 /// # Security
139 ///
140 /// The inner `String` is the POLICY's own message, authored outside this
141 /// crate — so unlike every other variant here, the toolkit cannot guarantee it
142 /// is value-free. That residual is documented on
143 /// [`crate::policy::PolicyRefusal`], which is where an implementor reads it.
144 #[error("outbound request refused by policy: {0}")]
145 PolicyRefused(String),
146}
147
148/// Backend-agnostic HTTP connector trait (OAPI-01).
149///
150/// The analog of [`crate::sql::SqlConnector`] for REST backends: an
151/// implementation executes an [`Operation`] against a configured base URL and
152/// returns the response body as JSON. [`base_url`](HttpConnector::base_url) is
153/// the analog of `SqlConnector::dialect()` — a cheap accessor used by the
154/// synthesizer / prompt assembly.
155///
156/// # Example
157///
158/// A minimal connector. The example defines a LOCAL dummy struct so the doctest
159/// does not depend on any downstream crate (mirrors the `SqlConnector` doctest).
160///
161/// ```no_run
162/// use pmcp_server_toolkit::http::{HttpConnector, HttpConnectorError, Operation};
163/// use async_trait::async_trait;
164/// use serde_json::Value;
165///
166/// struct Dummy;
167///
168/// #[async_trait]
169/// impl HttpConnector for Dummy {
170/// fn base_url(&self) -> &str { "https://api.example.com/v1" }
171/// async fn execute(&self, _operation: &Operation, _args: &Value)
172/// -> Result<Value, HttpConnectorError> {
173/// Ok(Value::Null)
174/// }
175/// }
176/// ```
177#[async_trait]
178pub trait HttpConnector: Send + Sync + 'static {
179 /// Execute `operation` with the caller-supplied `args` (a JSON object whose
180 /// keys map to path / query / header / body parameters) and return the
181 /// response body as a [`serde_json::Value`].
182 ///
183 /// # Errors
184 ///
185 /// Returns [`HttpConnectorError`] when the request fails at the transport
186 /// layer ([`HttpConnectorError::Request`]), the backend returns a non-2xx
187 /// status ([`HttpConnectorError::Status`]), authentication cannot be applied
188 /// ([`HttpConnectorError::Auth`]), or a header is invalid
189 /// ([`HttpConnectorError::InvalidHeader`]). Per the type-level Security note,
190 /// no error message echoes a URL or credential.
191 async fn execute(
192 &self,
193 operation: &Operation,
194 args: &serde_json::Value,
195 ) -> Result<serde_json::Value, HttpConnectorError>;
196
197 /// The configured base URL (analog of `SqlConnector::dialect()`).
198 fn base_url(&self) -> &str;
199
200 /// Execute `operation` on behalf of the MCP tool named `tool` (Phase 128 E1).
201 ///
202 /// The tool name is the one thing an `Operation` cannot carry — it describes an
203 /// endpoint, not a tool — and an E1 [`crate::policy::RequestPolicy`] that keys
204 /// on the tool needs it. The synthesized single-call handler calls THIS method;
205 /// the default body delegates to [`execute`](HttpConnector::execute), so an
206 /// out-of-repo implementation compiles unchanged and simply reports no tool.
207 ///
208 /// # Errors
209 ///
210 /// As [`execute`](HttpConnector::execute).
211 async fn execute_for_tool(
212 &self,
213 tool: &str,
214 operation: &Operation,
215 args: &serde_json::Value,
216 ) -> Result<serde_json::Value, HttpConnectorError> {
217 let _ = tool;
218 self.execute(operation, args).await
219 }
220
221 /// Whether this connector consults an E1 policy before sending (Phase 128).
222 ///
223 /// Default `false`. Overridden by [`crate::http::HttpClient`]. It exists so a
224 /// registered-but-unreached policy cannot look registered: the wiring that
225 /// attaches one lives in a different crate
226 /// (`pmcp-openapi-server`'s `build_server`) and the connector is an
227 /// `Arc<dyn HttpConnector>` by then, so the only way to PROVE the attachment
228 /// took is to ask through the trait.
229 fn has_request_policy(&self) -> bool {
230 false
231 }
232
233 /// A clone of this connector that consults `policy` before every outbound
234 /// request (Phase 128 E1), or `None` when the implementation cannot host one.
235 ///
236 /// `None` is the default, and a caller that holds a policy MUST treat `None`
237 /// as a hard problem rather than a silent no-op — a policy the operator
238 /// registered and the connector never consults is precisely the
239 /// present-but-inert defect this phase exists to close.
240 fn governed(
241 &self,
242 policy: Arc<dyn crate::policy::RequestPolicy>,
243 ) -> Option<Arc<dyn HttpConnector>> {
244 let _ = policy;
245 None
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn test_join_url_preserves_prefix() {
255 // The API-Gateway stage prefix `/v1` survives; exactly one slash joins.
256 assert_eq!(join_url("https://x/v1", "/users"), "https://x/v1/users");
257 // Trailing slash on base + leading slash on path collapse to one.
258 assert_eq!(join_url("https://x/v1/", "/users"), "https://x/v1/users");
259 // No leading slash on path still joins with exactly one slash.
260 assert_eq!(join_url("https://x/v1", "users"), "https://x/v1/users");
261 // Root base.
262 assert_eq!(join_url("https://x", "/users"), "https://x/users");
263 }
264
265 /// T-90-01-01: the rendered `Display` of every error variant MUST NOT echo a
266 /// URL, an `Authorization`/`Bearer` value, or an `app_key`. Mirrors the SQL
267 /// redaction test `test_connection_display_does_not_echo_password`.
268 #[test]
269 fn test_http_error_display_does_not_echo_secret() {
270 let variants = [
271 HttpConnectorError::Request("connect timed out".to_string()),
272 HttpConnectorError::Status { status: 401 },
273 HttpConnectorError::Auth("required token absent".to_string()),
274 HttpConnectorError::InvalidHeader("name contains illegal byte".to_string()),
275 HttpConnectorError::Backend("unknown method".to_string()),
276 ];
277 for err in &variants {
278 let rendered = format!("{err}");
279 for forbidden in ["Bearer", "Authorization", "app_key", "https://", "http://"] {
280 assert!(
281 !rendered.contains(forbidden),
282 "HttpConnectorError Display must not echo {forbidden:?}; got {rendered:?}"
283 );
284 }
285 }
286 }
287
288 /// display_no_secret: the named `verify` automated check — Status{401}
289 /// renders the code but never a credential token.
290 #[test]
291 fn display_no_secret_status_shows_code() {
292 let rendered = HttpConnectorError::Status { status: 401 }.to_string();
293 assert!(
294 rendered.contains("401"),
295 "status code must be visible: {rendered:?}"
296 );
297 for forbidden in ["Bearer", "Authorization", "app_key", "https://"] {
298 assert!(!rendered.contains(forbidden), "must not echo {forbidden:?}");
299 }
300 }
301
302 #[test]
303 fn connector_trait_object_is_send_sync_static() {
304 fn assert_send_sync<T: Send + Sync + 'static>() {}
305 assert_send_sync::<Box<dyn HttpConnector>>();
306 }
307}