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}