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}