Skip to main content

pmcp_server_toolkit/
builder_ext.rs

1// Net-new code for Phase 83 PATTERNS §13 (builder extension surface).
2// Hosts the `ServerBuilderExt` trait + `try_*` fallible variants per review R7.
3
4//! Builder extension trait for [`pmcp::ServerBuilder`] — connects config-driven
5//! synthesis (Plans 04, 05, 06) to the public Phase 82 builder API.
6//!
7//! Per CONTEXT.md D-10 + D-11, this is the "common path" surface — power users
8//! call [`crate::tools::synthesize_from_config`] +
9//! [`crate::code_mode::register_code_mode_tools`] directly. Shape C ≤15-line
10//! `main.rs` users compose this trait.
11//!
12//! Per review R7, each method has a panicking convenience form
13//! ([`ServerBuilderExt::tools_from_config`],
14//! [`ServerBuilderExt::code_mode_from_config`]) AND a fallible companion
15//! ([`ServerBuilderExt::try_tools_from_config`],
16//! [`ServerBuilderExt::try_code_mode_from_config`]). The panicking forms
17//! delegate to the `try_*` variants with documented panic messages — production
18//! servers should prefer the `try_*` shape so misconfiguration surfaces as a
19//! `Result`, not a crash.
20
21use std::sync::Arc;
22
23use pmcp::ServerBuilder;
24
25use crate::config::ServerConfig;
26use crate::error::Result;
27use crate::sql::SqlConnector;
28
29/// Composable builder extensions for config-driven `pmcp` servers.
30///
31/// Implemented for [`pmcp::ServerBuilder`] (Phase 82's public, `Arc`-aware
32/// builder) so config-driven wiring composes with the standard chained-method
33/// builder DSL.
34pub trait ServerBuilderExt: Sized {
35    /// Register every `[[tools]]` entry from `config` as a `tool_arc` handler
36    /// (TKIT-07). Panicking convenience wrapping
37    /// [`ServerBuilderExt::try_tools_from_config`].
38    ///
39    /// # Panics
40    ///
41    /// Panics with `"tools_from_config: ..."` if
42    /// [`crate::tools::synthesize_from_config`] returns `Err`. Prefer
43    /// [`ServerBuilderExt::try_tools_from_config`] for production servers
44    /// where misconfiguration must surface as a `Result`.
45    ///
46    /// # Example
47    ///
48    /// ```no_run
49    /// use pmcp::Server;
50    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
51    ///
52    /// let cfg = ServerConfig::default();
53    /// let _builder = Server::builder()
54    ///     .name("demo")
55    ///     .version("0.1.0")
56    ///     .tools_from_config(&cfg);
57    /// ```
58    fn tools_from_config(self, config: &ServerConfig) -> Self;
59
60    /// Fallible companion to [`ServerBuilderExt::tools_from_config`]
61    /// (review R7).
62    ///
63    /// # Errors
64    ///
65    /// Returns [`crate::ToolkitError`] if synthesis fails — typically
66    /// [`crate::ToolkitError::Synth`] or [`crate::ToolkitError::Validation`].
67    ///
68    /// # Example
69    ///
70    /// ```no_run
71    /// use pmcp::Server;
72    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
73    ///
74    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
75    /// let cfg = ServerConfig::default();
76    /// let _builder = Server::builder()
77    ///     .name("demo")
78    ///     .version("0.1.0")
79    ///     .try_tools_from_config(&cfg)?;
80    /// # Ok(()) }
81    /// ```
82    fn try_tools_from_config(self, config: &ServerConfig) -> Result<Self>;
83
84    /// Register every `[[tools]]` entry from `config` as a `tool_arc` handler,
85    /// threading `connector` into each handler so `tools/call` executes SQL and
86    /// emits `structuredContent` (Phase 84 CONN-01 / D-06). Panicking
87    /// convenience wrapping [`ServerBuilderExt::try_tools_from_config_with_connector`].
88    ///
89    /// This is the Shape A wiring point: production servers with a live
90    /// connector use this entry point; the connector-less
91    /// [`ServerBuilderExt::tools_from_config`] remains for callers that only
92    /// need the synthesized tool schemas (handlers error at runtime if invoked).
93    ///
94    /// # Panics
95    ///
96    /// Panics with `"tools_from_config_with_connector: ..."` if
97    /// [`crate::tools::synthesize_from_config_with_connector`] returns `Err`.
98    /// Prefer [`ServerBuilderExt::try_tools_from_config_with_connector`] for
99    /// production servers.
100    ///
101    /// # Example
102    ///
103    /// ```no_run
104    /// use std::sync::Arc;
105    /// use pmcp::Server;
106    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
107    /// use pmcp_server_toolkit::sql::SqlConnector;
108    ///
109    /// fn build(connector: Arc<dyn SqlConnector>) {
110    ///     let cfg = ServerConfig::default();
111    ///     let _builder = Server::builder()
112    ///         .name("demo")
113    ///         .version("0.1.0")
114    ///         .tools_from_config_with_connector(&cfg, connector);
115    /// }
116    /// ```
117    fn tools_from_config_with_connector(
118        self,
119        config: &ServerConfig,
120        connector: Arc<dyn SqlConnector>,
121    ) -> Self;
122
123    /// Fallible companion to
124    /// [`ServerBuilderExt::tools_from_config_with_connector`].
125    ///
126    /// # Errors
127    ///
128    /// Returns [`crate::ToolkitError`] if synthesis fails — typically
129    /// [`crate::ToolkitError::Synth`] or [`crate::ToolkitError::Validation`].
130    ///
131    /// # Example
132    ///
133    /// ```no_run
134    /// use std::sync::Arc;
135    /// use pmcp::Server;
136    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
137    /// use pmcp_server_toolkit::sql::SqlConnector;
138    ///
139    /// # fn run(connector: Arc<dyn SqlConnector>) -> Result<(), Box<dyn std::error::Error>> {
140    /// let cfg = ServerConfig::default();
141    /// let _builder = Server::builder()
142    ///     .name("demo")
143    ///     .version("0.1.0")
144    ///     .try_tools_from_config_with_connector(&cfg, connector)?;
145    /// # Ok(()) }
146    /// ```
147    fn try_tools_from_config_with_connector(
148        self,
149        config: &ServerConfig,
150        connector: Arc<dyn SqlConnector>,
151    ) -> Result<Self>;
152
153    /// Wire the `[code_mode]` block. Panicking convenience wrapping
154    /// [`ServerBuilderExt::try_code_mode_from_config`].
155    ///
156    /// When the `code-mode` feature is disabled, this is a no-op that emits
157    /// a `tracing::warn!` so operators auditing logs can spot the feature gap
158    /// (threat T-83-08-02 mitigation).
159    ///
160    /// # Panics
161    ///
162    /// Panics if [`ServerBuilderExt::try_code_mode_from_config`] errors —
163    /// commonly because `token_secret`'s referenced env var is unset, or an
164    /// inline literal `token_secret` was supplied without the dev-only escape
165    /// hatch (review R9). Prefer
166    /// [`ServerBuilderExt::try_code_mode_from_config`] for production servers.
167    ///
168    /// # Example
169    ///
170    /// ```no_run
171    /// use pmcp::Server;
172    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
173    ///
174    /// let cfg = ServerConfig::default();
175    /// let _builder = Server::builder()
176    ///     .name("demo")
177    ///     .version("0.1.0")
178    ///     .code_mode_from_config(&cfg);
179    /// ```
180    fn code_mode_from_config(self, config: &ServerConfig) -> Self;
181
182    /// Fallible companion to [`ServerBuilderExt::code_mode_from_config`]
183    /// (review R7) — the CONNECTORLESS, **validation-only / no-tool** path.
184    ///
185    /// Tolerant of `config.code_mode = None` (returns the builder unchanged).
186    /// When `[code_mode]` IS present this builds + validates the pipeline (so
187    /// R9 / secret-resolution errors fire) but registers NO tools, because no
188    /// executor is available to bind `execute_code` to. For the path that
189    /// actually registers `validate_code` + `execute_code`, use the LOCKED
190    /// connector-aware
191    /// [`ServerBuilderExt::try_code_mode_from_config_with_connector`].
192    ///
193    /// # Errors
194    ///
195    /// Returns [`crate::ToolkitError`] if code-mode wiring fails — commonly
196    /// [`crate::ToolkitError::CodeMode`] (env var missing) or
197    /// [`crate::ToolkitError::Validation`] (inline `token_secret` rejected
198    /// per review R9).
199    ///
200    /// # Example
201    ///
202    /// ```no_run
203    /// use pmcp::Server;
204    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
205    ///
206    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
207    /// let cfg = ServerConfig::default();
208    /// let _builder = Server::builder()
209    ///     .name("demo")
210    ///     .version("0.1.0")
211    ///     .try_code_mode_from_config(&cfg)?;
212    /// # Ok(()) }
213    /// ```
214    fn try_code_mode_from_config(self, config: &ServerConfig) -> Result<Self>;
215
216    /// Wire the `[code_mode]` block, registering BOTH `validate_code` and
217    /// `execute_code` over `connector` (the LOCKED connector-aware API — the
218    /// pure-config binary's path; SHAP-A-01 / SC-3).
219    ///
220    /// When `[code_mode]` is present this constructs a
221    /// [`crate::code_mode::SqlCodeExecutor`] from `connector` and delegates to
222    /// [`crate::code_mode::code_mode_tools_from_executor`], which registers the
223    /// two tools with the static `[code_mode]` policy baked into the validation
224    /// pipeline (allow_writes / allow_deletes / allow_ddl enforced; DELETE/DDL
225    /// on a read-only config are rejected). When `[code_mode]` is absent this is
226    /// a no-op (registers neither tool). Unlike the connectorless
227    /// [`ServerBuilderExt::try_code_mode_from_config`], this is the tool-
228    /// registering path because it has an executor to bind `execute_code` to.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`crate::ToolkitError`] if code-mode wiring fails — commonly
233    /// [`crate::ToolkitError::CodeMode`] (env var missing / secret too short)
234    /// or [`crate::ToolkitError::Validation`] (inline `token_secret` rejected
235    /// per review R9).
236    ///
237    /// # Example
238    ///
239    /// ```no_run
240    /// use std::sync::Arc;
241    /// use pmcp::Server;
242    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
243    /// use pmcp_server_toolkit::sql::SqlConnector;
244    ///
245    /// # fn run(connector: Arc<dyn SqlConnector>) -> Result<(), Box<dyn std::error::Error>> {
246    /// let cfg = ServerConfig::default();
247    /// let _builder = Server::builder()
248    ///     .name("demo")
249    ///     .version("0.1.0")
250    ///     .try_code_mode_from_config_with_connector(&cfg, connector)?;
251    /// # Ok(()) }
252    /// ```
253    fn try_code_mode_from_config_with_connector(
254        self,
255        config: &ServerConfig,
256        connector: Arc<dyn SqlConnector>,
257    ) -> Result<Self>;
258}
259
260impl ServerBuilderExt for ServerBuilder {
261    fn tools_from_config(self, config: &ServerConfig) -> Self {
262        self.try_tools_from_config(config).expect(
263            "tools_from_config: synthesize_from_config returned an error — \
264             prefer try_tools_from_config to handle this as a Result",
265        )
266    }
267
268    fn try_tools_from_config(mut self, config: &ServerConfig) -> Result<Self> {
269        let synthesized = crate::tools::synthesize_from_config(config)?;
270        // T-83-08-02 mitigation: emit a visible signal when the [[tools]]
271        // block is empty so an operator notices the gap rather than seeing a
272        // silently-empty server.
273        if synthesized.is_empty() {
274            tracing::warn!(
275                target: "pmcp_server_toolkit::builder_ext",
276                "try_tools_from_config: config declared zero [[tools]] entries — \
277                 server will expose no tools (set RUST_LOG=warn to surface this)"
278            );
279        }
280        for (name, _info, handler) in synthesized {
281            self = self.tool_arc(name, handler);
282        }
283        Ok(self)
284    }
285
286    fn tools_from_config_with_connector(
287        self,
288        config: &ServerConfig,
289        connector: Arc<dyn SqlConnector>,
290    ) -> Self {
291        self.try_tools_from_config_with_connector(config, connector)
292            .expect(
293                "tools_from_config_with_connector: synthesize_from_config_with_connector \
294                 returned an error — prefer try_tools_from_config_with_connector to handle \
295                 this as a Result",
296            )
297    }
298
299    fn try_tools_from_config_with_connector(
300        mut self,
301        config: &ServerConfig,
302        connector: Arc<dyn SqlConnector>,
303    ) -> Result<Self> {
304        let synthesized = crate::tools::synthesize_from_config_with_connector(config, connector)?;
305        // T-83-08-02 mitigation: visible signal when the [[tools]] block is
306        // empty so an operator notices the gap rather than a silently-empty server.
307        if synthesized.is_empty() {
308            tracing::warn!(
309                target: "pmcp_server_toolkit::builder_ext",
310                "try_tools_from_config_with_connector: config declared zero [[tools]] entries — \
311                 server will expose no tools (set RUST_LOG=warn to surface this)"
312            );
313        }
314        for (name, _info, handler) in synthesized {
315            self = self.tool_arc(name, handler);
316        }
317        Ok(self)
318    }
319
320    fn code_mode_from_config(self, config: &ServerConfig) -> Self {
321        self.try_code_mode_from_config(config).expect(
322            "code_mode_from_config: register_code_mode_tools errored — \
323             prefer try_code_mode_from_config to handle (e.g. missing env var)",
324        )
325    }
326
327    fn try_code_mode_from_config(self, config: &ServerConfig) -> Result<Self> {
328        #[cfg(feature = "code-mode")]
329        {
330            crate::code_mode::register_code_mode_tools(self, config)
331        }
332        #[cfg(not(feature = "code-mode"))]
333        {
334            let _ = config;
335            tracing::warn!(
336                target: "pmcp_server_toolkit::builder_ext",
337                "try_code_mode_from_config called but `code-mode` feature is \
338                 disabled at compile-time — skipping (T-83-08-02 visibility)"
339            );
340            Ok(self)
341        }
342    }
343
344    fn try_code_mode_from_config_with_connector(
345        self,
346        config: &ServerConfig,
347        connector: Arc<dyn SqlConnector>,
348    ) -> Result<Self> {
349        #[cfg(feature = "code-mode")]
350        {
351            if config.code_mode.is_none() {
352                return Ok(self); // no-op when block absent (mirrors connectorless path)
353            }
354            // Coerce the SQL executor to the backend-agnostic `Arc<dyn
355            // CodeExecutor>` the generalized wiring fn takes (OAPI-10 / D-02).
356            // `CodeExecutor` is `#[async_trait]` and object-safe, so this is a
357            // plain unsize coercion. The SQL path passes `ValidationFlavor::Sql`.
358            let executor: Arc<dyn crate::code_mode::CodeExecutor> = Arc::new(
359                crate::code_mode::SqlCodeExecutor::new(connector, config.clone())?,
360            );
361            crate::code_mode::code_mode_tools_from_executor(
362                self,
363                config,
364                executor,
365                crate::code_mode::ValidationFlavor::Sql,
366            )
367        }
368        #[cfg(not(feature = "code-mode"))]
369        {
370            let _ = (config, connector);
371            tracing::warn!(
372                target: "pmcp_server_toolkit::builder_ext",
373                "try_code_mode_from_config_with_connector called but `code-mode` \
374                 feature is disabled at compile-time — skipping (T-83-08-02 visibility)"
375            );
376            Ok(self)
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::config::{ServerConfig, ServerSection, ToolDecl};
385    use pmcp::Server;
386
387    fn min_cfg() -> ServerConfig {
388        ServerConfig {
389            server: ServerSection {
390                name: "test".to_string(),
391                version: "0.1.0".to_string(),
392                ..Default::default()
393            },
394            tools: vec![ToolDecl {
395                name: "ping".to_string(),
396                description: Some("ping".to_string()),
397                ..Default::default()
398            }],
399            ..Default::default()
400        }
401    }
402
403    #[test]
404    fn tools_from_config_registers_synthesized_handlers() {
405        let cfg = min_cfg();
406        let server = Server::builder()
407            .name("test")
408            .version("0.1.0")
409            .tools_from_config(&cfg)
410            .build()
411            .expect("build");
412        assert!(
413            server.get_tool("ping").is_some(),
414            "tools_from_config must wire each [[tools]] entry via tool_arc (Phase 82)"
415        );
416    }
417
418    #[test]
419    fn try_tools_from_config_returns_ok_on_valid_config() {
420        let cfg = min_cfg();
421        let builder = Server::builder().name("t").version("0.1.0");
422        let result = builder.try_tools_from_config(&cfg);
423        assert!(result.is_ok(), "valid config must return Ok");
424    }
425
426    #[test]
427    fn code_mode_from_config_is_noop_when_block_absent() {
428        // Plan 06 Task 2 ensures register_code_mode_tools tolerates
429        // config.code_mode = None.
430        let cfg = min_cfg();
431        let _builder = Server::builder()
432            .name("t")
433            .version("0.1.0")
434            .code_mode_from_config(&cfg);
435        // No panic means tolerance works.
436    }
437
438    #[test]
439    fn try_code_mode_from_config_is_ok_when_block_absent() {
440        let cfg = min_cfg();
441        let builder = Server::builder().name("t").version("0.1.0");
442        let result = builder.try_code_mode_from_config(&cfg);
443        assert!(
444            result.is_ok(),
445            "code_mode = None must produce Ok (no-op) so callers can invoke unconditionally"
446        );
447    }
448}