diff --git a/crates/pakx-registry-client/src/official_mcp.rs b/crates/pakx-registry-client/src/official_mcp.rs index 1cd5512..430fc23 100644 --- a/crates/pakx-registry-client/src/official_mcp.rs +++ b/crates/pakx-registry-client/src/official_mcp.rs @@ -164,7 +164,7 @@ struct ServerListResponse { } #[derive(Debug, Clone, Deserialize)] -struct ServerRaw { +struct ServerCore { /// Canonical id. The MCP Registry sends this as `name` (e.g. /// `io.github.modelcontextprotocol/server-filesystem`); older or /// alternate deployments may send `id`. @@ -181,6 +181,29 @@ struct ServerRaw { extra: serde_json::Map, } +/// Wire format for a single server entry. The 2025-12-11 schema wraps +/// every entry in `{ "server": , "_meta": {...} }`; older +/// deployments still send the flat core directly. Accept both. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum ServerRaw { + Wrapped { + server: ServerCore, + #[serde(rename = "_meta", default)] + meta: Option, + }, + Flat(ServerCore), +} + +impl ServerRaw { + fn into_parts(self) -> (ServerCore, Option) { + match self { + Self::Wrapped { server, meta } => (server, meta), + Self::Flat(core) => (core, None), + } + } +} + #[derive(Debug, Clone, Deserialize)] struct VersionDetail { #[serde(default)] @@ -188,18 +211,22 @@ struct VersionDetail { } fn into_package(raw: ServerRaw) -> Package { - let version = raw + let (core, meta) = raw.into_parts(); + let version = core .version - .or_else(|| raw.version_detail.and_then(|v| v.version)) + .or_else(|| core.version_detail.and_then(|v| v.version)) .unwrap_or_else(|| "0.0.0".to_string()); - let install_hints = Value::Object(raw.extra); + let mut extra = core.extra; + if let Some(m) = meta { + extra.insert("_meta".to_owned(), m); + } Package { - id: raw.name.clone(), + id: core.name.clone(), source: RegistrySource::OfficialMcp, - name: raw.name, + name: core.name, version, - description: raw.description, - install_hints, + description: core.description, + install_hints: Value::Object(extra), } } diff --git a/crates/pakx-registry-client/tests/official_mcp.rs b/crates/pakx-registry-client/tests/official_mcp.rs index 354cb35..bf4679e 100644 --- a/crates/pakx-registry-client/tests/official_mcp.rs +++ b/crates/pakx-registry-client/tests/official_mcp.rs @@ -165,6 +165,79 @@ async fn second_search_is_served_from_cache() { // wiremock verifies the expect(1) on drop. } +#[tokio::test] +async fn search_decodes_wrapped_schema() { + // 2025-12-11 schema wraps each list entry as `{server, _meta}`. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/servers")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "servers": [ + { + "server": { + "name": "io.github.modelcontextprotocol/server-filesystem", + "description": "Local fs", + "version": "1.2.3", + "remotes": [{ "type": "streamable-http", "url": "https://x" }] + }, + "_meta": { + "io.modelcontextprotocol.registry/official": { + "status": "active", + "isLatest": true + } + } + }, + { + "server": { "name": "io.github.acme/playwright", "version": "0.5.0" }, + "_meta": {} + } + ], + "next": null + }))) + .mount(&server) + .await; + + let temp = TempDir::new().unwrap(); + let source = source_with_mock(&server.uri(), temp.path()); + + let results = source.search("").await.unwrap(); + assert_eq!(results.len(), 2); + assert_eq!( + results[0].id, + "io.github.modelcontextprotocol/server-filesystem" + ); + assert_eq!(results[0].version, "1.2.3"); + assert_eq!(results[0].description.as_deref(), Some("Local fs")); + // `_meta` is preserved on `install_hints` so the resolver can inspect it. + assert!(results[0].install_hints.get("_meta").is_some()); + // `remotes` from the inner `server` is also preserved on `install_hints`. + assert!(results[0].install_hints.get("remotes").is_some()); + assert_eq!(results[1].id, "io.github.acme/playwright"); +} + +#[tokio::test] +async fn fetch_decodes_wrapped_schema() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v0/servers/io.github.foo/bar")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "server": { + "name": "io.github.foo/bar", + "version_detail": { "version": "2.0.0" } + }, + "_meta": { "io.modelcontextprotocol.registry/official": { "status": "active" } } + }))) + .mount(&server) + .await; + + let temp = TempDir::new().unwrap(); + let source = source_with_mock(&server.uri(), temp.path()); + let pkg = source.fetch("io.github.foo/bar").await.unwrap(); + assert_eq!(pkg.id, "io.github.foo/bar"); + assert_eq!(pkg.version, "2.0.0"); + assert!(pkg.install_hints.get("_meta").is_some()); +} + #[tokio::test] async fn cache_ttl_expiry_refetches() { let server = MockServer::start().await;