Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions crates/tinymcp/src/tinybus_module/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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<PathBuf>,
client: McpClientConfig,
}

impl<'de> Deserialize<'de> for ModuleConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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::<Wire>::deserialize(deserializer)?.unwrap_or_default();

Ok(Self {
data_dir: wire.data_dir,
client: wire.client,
})
}
}
45 changes: 45 additions & 0 deletions crates/tinymcp/src/tinybus_module/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}