Skip to main content

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}