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 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}