From 034cf604a6940ea082f367b286af035241f42038 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 22 Aug 2026 03:34:17 +0300 Subject: [PATCH 1/2] fix(config): handle null configuration in ModuleConfig deserialization The module's configuration slot arrives as a serde_json::Value whose default is null, not the empty object. The previous #[serde(default)] attribute only covers missing fields, not a null document, so a host that supplies no configuration would cause a deserialization error. A hand-written Deserialize implementation now wraps the wire format in Option, turning null into the default configuration and preserving the existing field-level defaults for the empty object. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymcp/src/tinybus_module/config.rs | 41 ++++++++++++++++--- crates/tinymcp/src/tinybus_module/test.rs | 45 +++++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/crates/tinymcp/src/tinybus_module/config.rs b/crates/tinymcp/src/tinybus_module/config.rs index 6d04f21..516d8fc 100644 --- a/crates/tinymcp/src/tinybus_module/config.rs +++ b/crates/tinymcp/src/tinybus_module/config.rs @@ -8,11 +8,15 @@ use tinymcp_bus::McpClientConfig; /// The module's configuration blob. /// -/// Arrives as JSON in the loader's configuration slot, and an absent one is the -/// empty object — so every field has a default and a host that supplies nothing -/// gets a working module with no servers and an in-memory store. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default)] +/// Arrives as JSON in the loader's configuration slot. A host that configures +/// nothing supplies either the empty object or `null`, so both decode to the +/// same thing: a working module with no servers and an in-memory store. +/// +/// `#[serde(default)]` covers the empty object, because it fills in absent +/// *fields*. It does not cover `null`, which is a whole document of the wrong +/// type — hence the hand-written [`Deserialize`] below. A module that refused +/// `null` would fail to load for exactly the host that asked nothing of it. +#[derive(Debug, Clone, Default, Serialize)] pub struct ModuleConfig { /// Where the store and the audit log live. /// @@ -23,3 +27,30 @@ pub struct ModuleConfig { /// The servers, credentials, identity, and proxy. pub client: McpClientConfig, } + +/// The fields as they appear on the wire. +/// +/// Separate from [`ModuleConfig`] so the hand-written deserializer below can +/// derive the field handling rather than restate it. +#[derive(Deserialize, Default)] +#[serde(default)] +struct Wire { + data_dir: Option, + client: McpClientConfig, +} + +impl<'de> Deserialize<'de> for ModuleConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // `Option` is what turns `null` into "nothing configured" rather than a + // type error. Everything else decodes through the derived impl. + let wire = Option::::deserialize(deserializer)?.unwrap_or_default(); + + Ok(Self { + data_dir: wire.data_dir, + client: wire.client, + }) + } +} diff --git a/crates/tinymcp/src/tinybus_module/test.rs b/crates/tinymcp/src/tinybus_module/test.rs index ce5eca8..a70ebdf 100644 --- a/crates/tinymcp/src/tinybus_module/test.rs +++ b/crates/tinymcp/src/tinybus_module/test.rs @@ -1021,3 +1021,48 @@ async fn a_module_that_cannot_open_its_store_fails_to_come_up() { "{error}" ); } + +#[test] +fn a_null_configuration_decodes_to_a_working_default() { + // What a host that configures nothing actually sends: the loader's + // configuration slot is a `serde_json::Value`, and its default is `null`, + // not the empty object. A module that refused it would fail to load for + // exactly the host that asked nothing of it — and the failure surfaces as + // "module initialization failed", which names nothing useful. + let config: ModuleConfig = serde_json::from_value(serde_json::Value::Null) + .expect("null decodes"); + + assert_eq!(config.data_dir, None); + assert!(config.client.servers.is_empty()); + assert!(config.client.enabled); +} + +#[test] +fn a_service_builds_from_a_null_configuration() { + // The whole path the loader takes, not just the decode. + let config: ModuleConfig = serde_json::from_value(serde_json::Value::Null).unwrap(); + + let service = McpService::new(&config).expect("the service builds"); + + assert!(service.static_servers().is_empty()); +} + +#[tokio::test] +async fn a_module_loaded_with_no_configuration_comes_up() { + // The release verifier loads the published artifact with an explicit empty + // configuration and calls it. This is that, minus the download. + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + + let serving = Connection::connect(bus.connect().await.unwrap()) + .await + .unwrap(); + let _serving = serving.clone(); + + super::setup( + serving, + serde_json::from_value(serde_json::Value::Null).expect("null decodes"), + ) + .await + .expect("the module comes up with nothing configured"); +} From e82fd87dc627ba8ac2bbd9267b82ae1ae750eea0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 22 Aug 2026 03:35:25 +0300 Subject: [PATCH 2/2] fix(test): reformat null-configuration test assertion Reformat the assertion in the null-configuration test to keep the method chain on a single line, improving readability without changing any behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymcp/src/tinybus_module/test.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinymcp/src/tinybus_module/test.rs b/crates/tinymcp/src/tinybus_module/test.rs index a70ebdf..ce1fb52 100644 --- a/crates/tinymcp/src/tinybus_module/test.rs +++ b/crates/tinymcp/src/tinybus_module/test.rs @@ -1029,8 +1029,8 @@ fn a_null_configuration_decodes_to_a_working_default() { // not the empty object. A module that refused it would fail to load for // exactly the host that asked nothing of it — and the failure surfaces as // "module initialization failed", which names nothing useful. - let config: ModuleConfig = serde_json::from_value(serde_json::Value::Null) - .expect("null decodes"); + let config: ModuleConfig = + serde_json::from_value(serde_json::Value::Null).expect("null decodes"); assert_eq!(config.data_dir, None); assert!(config.client.servers.is_empty());