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::policy::ToolkitHooks;
28use crate::sql::SqlConnector;
29
30/// Composable builder extensions for config-driven `pmcp` servers.
31///
32/// Implemented for [`pmcp::ServerBuilder`] (Phase 82's public, `Arc`-aware
33/// builder) so config-driven wiring composes with the standard chained-method
34/// builder DSL.
35pub trait ServerBuilderExt: Sized {
36    /// Register every `[[tools]]` entry from `config` as a `tool_arc` handler
37    /// (TKIT-07). Panicking convenience wrapping
38    /// [`ServerBuilderExt::try_tools_from_config`].
39    ///
40    /// # Panics
41    ///
42    /// Panics with `"tools_from_config: ..."` if
43    /// [`crate::tools::synthesize_from_config`] returns `Err`. Prefer
44    /// [`ServerBuilderExt::try_tools_from_config`] for production servers
45    /// where misconfiguration must surface as a `Result`.
46    ///
47    /// # Example
48    ///
49    /// ```no_run
50    /// use pmcp::Server;
51    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
52    ///
53    /// let cfg = ServerConfig::default();
54    /// let _builder = Server::builder()
55    ///     .name("demo")
56    ///     .version("0.1.0")
57    ///     .tools_from_config(&cfg);
58    /// ```
59    fn tools_from_config(self, config: &ServerConfig) -> Self;
60
61    /// Fallible companion to [`ServerBuilderExt::tools_from_config`]
62    /// (review R7).
63    ///
64    /// # Errors
65    ///
66    /// Returns [`crate::ToolkitError`] if synthesis fails — typically
67    /// [`crate::ToolkitError::Synth`] or [`crate::ToolkitError::Validation`].
68    ///
69    /// # Example
70    ///
71    /// ```no_run
72    /// use pmcp::Server;
73    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
74    ///
75    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
76    /// let cfg = ServerConfig::default();
77    /// let _builder = Server::builder()
78    ///     .name("demo")
79    ///     .version("0.1.0")
80    ///     .try_tools_from_config(&cfg)?;
81    /// # Ok(()) }
82    /// ```
83    fn try_tools_from_config(self, config: &ServerConfig) -> Result<Self>;
84
85    /// Register every `[[tools]]` entry from `config` as a `tool_arc` handler,
86    /// threading `connector` into each handler so `tools/call` executes SQL and
87    /// emits `structuredContent` (Phase 84 CONN-01 / D-06). Panicking
88    /// convenience wrapping [`ServerBuilderExt::try_tools_from_config_with_connector`].
89    ///
90    /// This is the Shape A wiring point: production servers with a live
91    /// connector use this entry point; the connector-less
92    /// [`ServerBuilderExt::tools_from_config`] remains for callers that only
93    /// need the synthesized tool schemas (handlers error at runtime if invoked).
94    ///
95    /// # Panics
96    ///
97    /// Panics with `"tools_from_config_with_connector: ..."` if
98    /// [`crate::tools::synthesize_from_config_with_connector`] returns `Err`.
99    /// Prefer [`ServerBuilderExt::try_tools_from_config_with_connector`] for
100    /// production servers.
101    ///
102    /// # Example
103    ///
104    /// ```no_run
105    /// use std::sync::Arc;
106    /// use pmcp::Server;
107    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
108    /// use pmcp_server_toolkit::sql::SqlConnector;
109    ///
110    /// fn build(connector: Arc<dyn SqlConnector>) {
111    ///     let cfg = ServerConfig::default();
112    ///     let _builder = Server::builder()
113    ///         .name("demo")
114    ///         .version("0.1.0")
115    ///         .tools_from_config_with_connector(&cfg, connector);
116    /// }
117    /// ```
118    fn tools_from_config_with_connector(
119        self,
120        config: &ServerConfig,
121        connector: Arc<dyn SqlConnector>,
122    ) -> Self;
123
124    /// Fallible companion to
125    /// [`ServerBuilderExt::tools_from_config_with_connector`].
126    ///
127    /// # Errors
128    ///
129    /// Returns [`crate::ToolkitError`] if synthesis fails — typically
130    /// [`crate::ToolkitError::Synth`] or [`crate::ToolkitError::Validation`].
131    ///
132    /// # Example
133    ///
134    /// ```no_run
135    /// use std::sync::Arc;
136    /// use pmcp::Server;
137    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
138    /// use pmcp_server_toolkit::sql::SqlConnector;
139    ///
140    /// # fn run(connector: Arc<dyn SqlConnector>) -> Result<(), Box<dyn std::error::Error>> {
141    /// let cfg = ServerConfig::default();
142    /// let _builder = Server::builder()
143    ///     .name("demo")
144    ///     .version("0.1.0")
145    ///     .try_tools_from_config_with_connector(&cfg, connector)?;
146    /// # Ok(()) }
147    /// ```
148    fn try_tools_from_config_with_connector(
149        self,
150        config: &ServerConfig,
151        connector: Arc<dyn SqlConnector>,
152    ) -> Result<Self>;
153
154    /// Wire the `[code_mode]` block. Panicking convenience wrapping
155    /// [`ServerBuilderExt::try_code_mode_from_config`].
156    ///
157    /// When the `code-mode` feature is disabled, this is a no-op that emits
158    /// a `tracing::warn!` so operators auditing logs can spot the feature gap
159    /// (threat T-83-08-02 mitigation).
160    ///
161    /// # Panics
162    ///
163    /// Panics if [`ServerBuilderExt::try_code_mode_from_config`] errors —
164    /// commonly because `token_secret`'s referenced env var is unset, or an
165    /// inline literal `token_secret` was supplied without the dev-only escape
166    /// hatch (review R9). Prefer
167    /// [`ServerBuilderExt::try_code_mode_from_config`] for production servers.
168    ///
169    /// # Example
170    ///
171    /// ```no_run
172    /// use pmcp::Server;
173    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
174    ///
175    /// let cfg = ServerConfig::default();
176    /// let _builder = Server::builder()
177    ///     .name("demo")
178    ///     .version("0.1.0")
179    ///     .code_mode_from_config(&cfg);
180    /// ```
181    fn code_mode_from_config(self, config: &ServerConfig) -> Self;
182
183    /// Fallible companion to [`ServerBuilderExt::code_mode_from_config`]
184    /// (review R7) — the CONNECTORLESS, **validation-only / no-tool** path.
185    ///
186    /// Tolerant of `config.code_mode = None` (returns the builder unchanged).
187    /// When `[code_mode]` IS present this builds + validates the pipeline (so
188    /// R9 / secret-resolution errors fire) but registers NO tools, because no
189    /// executor is available to bind `execute_code` to. For the path that
190    /// actually registers `validate_code` + `execute_code`, use the LOCKED
191    /// connector-aware
192    /// [`ServerBuilderExt::try_code_mode_from_config_with_connector`].
193    ///
194    /// # Errors
195    ///
196    /// Returns [`crate::ToolkitError`] if code-mode wiring fails — commonly
197    /// [`crate::ToolkitError::CodeMode`] (env var missing) or
198    /// [`crate::ToolkitError::Validation`] (inline `token_secret` rejected
199    /// per review R9).
200    ///
201    /// # Example
202    ///
203    /// ```no_run
204    /// use pmcp::Server;
205    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
206    ///
207    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
208    /// let cfg = ServerConfig::default();
209    /// let _builder = Server::builder()
210    ///     .name("demo")
211    ///     .version("0.1.0")
212    ///     .try_code_mode_from_config(&cfg)?;
213    /// # Ok(()) }
214    /// ```
215    fn try_code_mode_from_config(self, config: &ServerConfig) -> Result<Self>;
216
217    /// Wire the `[code_mode]` block, registering BOTH `validate_code` and
218    /// `execute_code` over `connector` (the LOCKED connector-aware API — the
219    /// pure-config binary's path; SHAP-A-01 / SC-3).
220    ///
221    /// When `[code_mode]` is present this constructs a
222    /// [`crate::code_mode::SqlCodeExecutor`] from `connector` and delegates to
223    /// [`crate::code_mode::code_mode_tools_from_executor`], which registers the
224    /// two tools with the static `[code_mode]` policy baked into the validation
225    /// pipeline (allow_writes / allow_deletes / allow_ddl enforced; DELETE/DDL
226    /// on a read-only config are rejected). When `[code_mode]` is absent this is
227    /// a no-op (registers neither tool). Unlike the connectorless
228    /// [`ServerBuilderExt::try_code_mode_from_config`], this is the tool-
229    /// registering path because it has an executor to bind `execute_code` to.
230    ///
231    /// # Errors
232    ///
233    /// Returns [`crate::ToolkitError`] if code-mode wiring fails — commonly
234    /// [`crate::ToolkitError::CodeMode`] (env var missing / secret too short)
235    /// or [`crate::ToolkitError::Validation`] (inline `token_secret` rejected
236    /// per review R9).
237    ///
238    /// # Example
239    ///
240    /// ```no_run
241    /// use std::sync::Arc;
242    /// use pmcp::Server;
243    /// use pmcp_server_toolkit::{ServerBuilderExt, ServerConfig};
244    /// use pmcp_server_toolkit::sql::SqlConnector;
245    ///
246    /// # fn run(connector: Arc<dyn SqlConnector>) -> Result<(), Box<dyn std::error::Error>> {
247    /// let cfg = ServerConfig::default();
248    /// let _builder = Server::builder()
249    ///     .name("demo")
250    ///     .version("0.1.0")
251    ///     .try_code_mode_from_config_with_connector(&cfg, connector)?;
252    /// # Ok(()) }
253    /// ```
254    fn try_code_mode_from_config_with_connector(
255        self,
256        config: &ServerConfig,
257        connector: Arc<dyn SqlConnector>,
258    ) -> Result<Self>;
259
260    /// [`Self::try_tools_from_config`] with registered E1/E2 hooks, and the
261    /// once-at-startup enforcement log (Phase 128).
262    ///
263    /// # Why hooks are a PARAMETER rather than accumulated on the builder
264    ///
265    /// This trait is implemented for CORE's [`pmcp::ServerBuilder`], whose fields
266    /// are private. A Rust extension trait cannot add a field to a foreign type, so
267    /// a `with_request_policy(self) -> Self` on this trait would have nowhere to
268    /// store anything. [`ToolkitHooks`] carries the registrations instead and
269    /// [`Self::try_tools_from_config`] became a thin wrapper passing a default — so
270    /// no pre-existing signature changed and no existing caller broke.
271    ///
272    /// # What E1 does NOT govern on this path
273    ///
274    /// This entry point synthesizes SQL / connectorless tools and owns no HTTP
275    /// egress surface, so a [`crate::RequestPolicy`] registered here has nothing to
276    /// govern. That is reported as a startup WARNING rather than accepted in
277    /// silence: a policy the operator registered and nothing consults is exactly
278    /// the present-but-inert defect this phase exists to close. E1 reaches the two
279    /// HTTP surfaces through `pmcp-openapi-server`'s `build_server`.
280    ///
281    /// # Errors
282    ///
283    /// As [`Self::try_tools_from_config`].
284    fn try_tools_from_config_with(
285        self,
286        config: &ServerConfig,
287        hooks: &ToolkitHooks,
288    ) -> Result<Self>;
289
290    /// [`Self::try_tools_from_config_with_connector`] with registered E1/E2 hooks
291    /// (Phase 128). See [`Self::try_tools_from_config_with`] for the design and for
292    /// what E1 does and does not govern on this path.
293    ///
294    /// # Errors
295    ///
296    /// As [`Self::try_tools_from_config_with_connector`].
297    fn try_tools_from_config_with_connector_and_hooks(
298        self,
299        config: &ServerConfig,
300        connector: Arc<dyn SqlConnector>,
301        hooks: &ToolkitHooks,
302    ) -> Result<Self>;
303}
304
305/// Warn when a [`ToolkitHooks`] carries a [`crate::RequestPolicy`] on an assembly
306/// path with no HTTP egress surface to apply it to (Phase 128).
307///
308/// An E1 policy governs the two HTTP surfaces. The `ServerBuilderExt` tool paths
309/// synthesize SQL / connectorless handlers, so a policy registered here is
310/// unreachable — and an unreachable policy that stays silent is a rule the
311/// operator believes is enforced and is not.
312fn warn_if_policy_has_no_surface(hooks: &ToolkitHooks, entry_point: &str) {
313    if hooks.request_policy().is_some() {
314        tracing::warn!(
315            target: "pmcp_server_toolkit::builder_ext",
316            entry_point = %entry_point,
317            "a RequestPolicy is registered but THIS assembly path has no HTTP egress \
318             surface to apply it to, so it will never run. E1 governs the curated HTTP \
319             connector and the Code Mode executor; SQL connector traffic is not \
320             intercepted. Register the policy on the path that builds those (the \
321             OpenAPI binary's build_server)."
322        );
323    }
324}
325
326impl ServerBuilderExt for ServerBuilder {
327    fn tools_from_config(self, config: &ServerConfig) -> Self {
328        self.try_tools_from_config(config).expect(
329            "tools_from_config: synthesize_from_config returned an error — \
330             prefer try_tools_from_config to handle this as a Result",
331        )
332    }
333
334    fn try_tools_from_config(self, config: &ServerConfig) -> Result<Self> {
335        self.try_tools_from_config_with(config, &ToolkitHooks::default())
336    }
337
338    fn try_tools_from_config_with(
339        mut self,
340        config: &ServerConfig,
341        hooks: &ToolkitHooks,
342    ) -> Result<Self> {
343        crate::policy::emit_validation_report(config, hooks);
344        warn_if_policy_has_no_surface(hooks, "try_tools_from_config_with");
345        let synthesized = crate::tools::synthesize_from_config_and_hooks(config, hooks)?;
346        // T-83-08-02 mitigation: emit a visible signal when the [[tools]]
347        // block is empty so an operator notices the gap rather than seeing a
348        // silently-empty server.
349        if synthesized.is_empty() {
350            tracing::warn!(
351                target: "pmcp_server_toolkit::builder_ext",
352                "try_tools_from_config: config declared zero [[tools]] entries — \
353                 server will expose no tools (set RUST_LOG=warn to surface this)"
354            );
355        }
356        for (name, _info, handler) in synthesized {
357            self = self.tool_arc(name, handler);
358        }
359        Ok(self)
360    }
361
362    fn tools_from_config_with_connector(
363        self,
364        config: &ServerConfig,
365        connector: Arc<dyn SqlConnector>,
366    ) -> Self {
367        self.try_tools_from_config_with_connector(config, connector)
368            .expect(
369                "tools_from_config_with_connector: synthesize_from_config_with_connector \
370                 returned an error — prefer try_tools_from_config_with_connector to handle \
371                 this as a Result",
372            )
373    }
374
375    fn try_tools_from_config_with_connector(
376        self,
377        config: &ServerConfig,
378        connector: Arc<dyn SqlConnector>,
379    ) -> Result<Self> {
380        self.try_tools_from_config_with_connector_and_hooks(
381            config,
382            connector,
383            &ToolkitHooks::default(),
384        )
385    }
386
387    fn try_tools_from_config_with_connector_and_hooks(
388        mut self,
389        config: &ServerConfig,
390        connector: Arc<dyn SqlConnector>,
391        hooks: &ToolkitHooks,
392    ) -> Result<Self> {
393        crate::policy::emit_validation_report(config, hooks);
394        warn_if_policy_has_no_surface(hooks, "try_tools_from_config_with_connector_and_hooks");
395        let synthesized = crate::tools::synthesize_from_config_with_connector_and_hooks(
396            config, connector, hooks,
397        )?;
398        // T-83-08-02 mitigation: visible signal when the [[tools]] block is
399        // empty so an operator notices the gap rather than a silently-empty server.
400        if synthesized.is_empty() {
401            tracing::warn!(
402                target: "pmcp_server_toolkit::builder_ext",
403                "try_tools_from_config_with_connector: config declared zero [[tools]] entries — \
404                 server will expose no tools (set RUST_LOG=warn to surface this)"
405            );
406        }
407        for (name, _info, handler) in synthesized {
408            self = self.tool_arc(name, handler);
409        }
410        Ok(self)
411    }
412
413    fn code_mode_from_config(self, config: &ServerConfig) -> Self {
414        self.try_code_mode_from_config(config).expect(
415            "code_mode_from_config: register_code_mode_tools errored — \
416             prefer try_code_mode_from_config to handle (e.g. missing env var)",
417        )
418    }
419
420    fn try_code_mode_from_config(self, config: &ServerConfig) -> Result<Self> {
421        #[cfg(feature = "code-mode")]
422        {
423            crate::code_mode::register_code_mode_tools(self, config)
424        }
425        #[cfg(not(feature = "code-mode"))]
426        {
427            let _ = config;
428            tracing::warn!(
429                target: "pmcp_server_toolkit::builder_ext",
430                "try_code_mode_from_config called but `code-mode` feature is \
431                 disabled at compile-time — skipping (T-83-08-02 visibility)"
432            );
433            Ok(self)
434        }
435    }
436
437    fn try_code_mode_from_config_with_connector(
438        self,
439        config: &ServerConfig,
440        connector: Arc<dyn SqlConnector>,
441    ) -> Result<Self> {
442        #[cfg(feature = "code-mode")]
443        {
444            if config.code_mode.is_none() {
445                return Ok(self); // no-op when block absent (mirrors connectorless path)
446            }
447            // Coerce the SQL executor to the backend-agnostic `Arc<dyn
448            // CodeExecutor>` the generalized wiring fn takes (OAPI-10 / D-02).
449            // `CodeExecutor` is `#[async_trait]` and object-safe, so this is a
450            // plain unsize coercion. The SQL path passes `ValidationFlavor::Sql`.
451            let executor: Arc<dyn crate::code_mode::CodeExecutor> = Arc::new(
452                crate::code_mode::SqlCodeExecutor::new(connector, config.clone())?,
453            );
454            crate::code_mode::code_mode_tools_from_executor(
455                self,
456                config,
457                executor,
458                crate::code_mode::ValidationFlavor::Sql,
459            )
460        }
461        #[cfg(not(feature = "code-mode"))]
462        {
463            let _ = (config, connector);
464            tracing::warn!(
465                target: "pmcp_server_toolkit::builder_ext",
466                "try_code_mode_from_config_with_connector called but `code-mode` \
467                 feature is disabled at compile-time — skipping (T-83-08-02 visibility)"
468            );
469            Ok(self)
470        }
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use crate::config::{ServerConfig, ServerSection, ToolDecl};
478    use pmcp::Server;
479
480    fn min_cfg() -> ServerConfig {
481        ServerConfig {
482            server: ServerSection {
483                name: "test".to_string(),
484                version: "0.1.0".to_string(),
485                ..Default::default()
486            },
487            tools: vec![ToolDecl {
488                name: "ping".to_string(),
489                description: Some("ping".to_string()),
490                ..Default::default()
491            }],
492            ..Default::default()
493        }
494    }
495
496    #[test]
497    fn tools_from_config_registers_synthesized_handlers() {
498        let cfg = min_cfg();
499        let server = Server::builder()
500            .name("test")
501            .version("0.1.0")
502            .tools_from_config(&cfg)
503            .build()
504            .expect("build");
505        assert!(
506            server.get_tool("ping").is_some(),
507            "tools_from_config must wire each [[tools]] entry via tool_arc (Phase 82)"
508        );
509    }
510
511    /// Phase 128: the hooks-taking entry point registers the same handlers, and a
512    /// registered E2 validator actually reaches the tool it names.
513    #[test]
514    fn try_tools_from_config_with_registers_handlers_and_reaches_the_validator() {
515        use crate::policy::{ArgumentRefusal, ArgumentValidator, ToolkitHooks};
516        use serde_json::Value;
517        use std::sync::Arc;
518
519        struct RefuseAll;
520        impl ArgumentValidator for RefuseAll {
521            fn validate(&self, _args: &Value) -> std::result::Result<(), ArgumentRefusal> {
522                Err(ArgumentRefusal::new(
523                    "this tool is administratively disabled",
524                ))
525            }
526        }
527
528        let cfg = min_cfg();
529        let hooks = ToolkitHooks::default().with_argument_validator("ping", Arc::new(RefuseAll));
530        let server = Server::builder()
531            .name("test")
532            .version("0.1.0")
533            .try_tools_from_config_with(&cfg, &hooks)
534            .expect("ok")
535            .build()
536            .expect("build");
537        assert!(
538            server.get_tool("ping").is_some(),
539            "the hooks-taking entry point must register handlers exactly as the wrapper does"
540        );
541    }
542
543    /// The pre-existing method must behave identically to the wrapper it became —
544    /// an empty `ToolkitHooks` changes nothing.
545    #[test]
546    fn try_tools_from_config_is_a_thin_wrapper_over_the_hooks_variant() {
547        use crate::policy::ToolkitHooks;
548
549        let cfg = min_cfg();
550        let via_wrapper = Server::builder()
551            .name("t")
552            .version("0.1.0")
553            .try_tools_from_config(&cfg)
554            .expect("ok")
555            .build()
556            .expect("build");
557        let via_hooks = Server::builder()
558            .name("t")
559            .version("0.1.0")
560            .try_tools_from_config_with(&cfg, &ToolkitHooks::default())
561            .expect("ok")
562            .build()
563            .expect("build");
564        assert!(via_wrapper.get_tool("ping").is_some());
565        assert!(via_hooks.get_tool("ping").is_some());
566    }
567
568    /// A `RequestPolicy` registered on this path has no HTTP egress surface to
569    /// govern. It must not be an ERROR (a config edit may add one later) and it must
570    /// not be silent either — `warn_if_policy_has_no_surface` is the report. This
571    /// asserts the non-error half and that the helper is reached at all.
572    #[test]
573    fn a_policy_on_the_sql_path_is_reported_and_not_an_error() {
574        use crate::policy::{OutboundRequest, PolicyRefusal, RequestPolicy, ToolkitHooks};
575        use std::sync::Arc;
576
577        struct RefuseAll;
578        #[async_trait::async_trait]
579        impl RequestPolicy for RefuseAll {
580            async fn check(
581                &self,
582                _req: &OutboundRequest<'_>,
583            ) -> std::result::Result<(), PolicyRefusal> {
584                Err(PolicyRefusal::new("refused"))
585            }
586        }
587
588        let hooks = ToolkitHooks::default().with_request_policy(Arc::new(RefuseAll));
589        super::warn_if_policy_has_no_surface(&hooks, "unit-test");
590        let cfg = min_cfg();
591        let builder = Server::builder()
592            .name("t")
593            .version("0.1.0")
594            .try_tools_from_config_with(&cfg, &hooks);
595        assert!(
596            builder.is_ok(),
597            "an unreachable policy warns; it must never fail the build"
598        );
599    }
600
601    #[test]
602    fn try_tools_from_config_returns_ok_on_valid_config() {
603        let cfg = min_cfg();
604        let builder = Server::builder().name("t").version("0.1.0");
605        let result = builder.try_tools_from_config(&cfg);
606        assert!(result.is_ok(), "valid config must return Ok");
607    }
608
609    #[test]
610    fn code_mode_from_config_is_noop_when_block_absent() {
611        // Plan 06 Task 2 ensures register_code_mode_tools tolerates
612        // config.code_mode = None.
613        let cfg = min_cfg();
614        let _builder = Server::builder()
615            .name("t")
616            .version("0.1.0")
617            .code_mode_from_config(&cfg);
618        // No panic means tolerance works.
619    }
620
621    #[test]
622    fn try_code_mode_from_config_is_ok_when_block_absent() {
623        let cfg = min_cfg();
624        let builder = Server::builder().name("t").version("0.1.0");
625        let result = builder.try_code_mode_from_config(&cfg);
626        assert!(
627            result.is_ok(),
628            "code_mode = None must produce Ok (no-op) so callers can invoke unconditionally"
629        );
630    }
631}