From 91c5d557b645f40eba850a13f81f7c6027392e36 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sat, 9 May 2026 09:23:34 -0700 Subject: [PATCH] fix(mcp): force type:object on Value-typed metadata/policy args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schemars derives an empty (no-type) JSON Schema for Option and serde_json::Value fields. Claude Code's MCP client interprets schema- without-type as 'string-encoded' and JSON-stringifies the user's {...} before sending — at which point the server-side validation `value.is_object()` returns false and the tool errors with 'CIP-25 metadata must be a JSON object' / 'CIP-68 metadata must be a JSON object'. Surfaced during Track #37 E2E test (2026-05-09): wallet_mint and wallet_mint_cip68_nft both rejected metadata-as-object args from Claude Code while the SAME object via raw stdio MCP works fine — proves the issue is client-side schema interpretation, not server. Fix: add a json_object_schema helper that emits {type: object, additionalProperties: true} and annotate every metadata + policy field with #[schemars(schema_with = "json_object_schema")]. Affected fields: - MintArgs.metadata - MintUnsignedArgs.policy + .metadata - Cip68NftArgs.metadata additionalProperties is left wide-open since these args really do accept arbitrary keys (CIP-25/CIP-68 are open-ended schemas). --- crates/aldabra-mcp/src/tools.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 11fdd8e..84d3dea 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -167,6 +167,29 @@ use rmcp::{ }; use serde::Deserialize; +/// Schema-shape helper for `serde_json::Value` arg fields that +/// expect a JSON object (CIP-25 / CIP-68 metadata, multisig +/// PolicySpec dicts). schemars's default for `Option` / +/// `Value` emits a schema with no `type`, which Claude Code's MCP +/// client interprets as "string-encoded" — it then JSON-stringifies +/// the user's `{...}` before sending, and the server-side +/// validation `value.is_object()` returns false. Force +/// `type: object` so the client passes the value through as a +/// proper JSON object on the wire. (`additionalProperties: true` +/// keeps the schema permissive — these args really do accept +/// arbitrary keys.) +fn json_object_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + use schemars::schema::{InstanceType, ObjectValidation, Schema, SchemaObject, SingleOrVec}; + Schema::Object(SchemaObject { + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Object))), + object: Some(Box::new(ObjectValidation { + additional_properties: Some(Box::new(Schema::Bool(true))), + ..Default::default() + })), + ..Default::default() + }) +} + /// MCP-facing asset spec — separate from `aldabra_core::AssetSpec` /// so the JsonSchema derive doesn't bleed schemars into the /// security-boundary crate. @@ -518,9 +541,11 @@ pub struct MintUnsignedArgs { /// single-sig policy bound to this wallet's payment key (same as /// `wallet_mint`). #[serde(default)] + #[schemars(schema_with = "json_object_schema")] pub policy: Option, /// Optional CIP-25 v2 metadata. #[serde(default)] + #[schemars(schema_with = "json_object_schema")] pub metadata: Option, /// Hex of the pkh to disclose as a required signer in the tx /// body. Defaults to this wallet's payment key hash. For @@ -665,6 +690,7 @@ pub struct Cip68NftArgs { /// CIP-68 metadata JSON object (`name`, `image`, `description`, /// `mediaType`, `files`, etc.). Encoded as Plutus Data and /// attached as the inline datum on the ref-NFT output. + #[schemars(schema_with = "json_object_schema")] pub metadata: serde_json::Value, /// Optional address where the reference NFT lives. Defaults to /// the wallet's own address — keeps the NFT *mutable* (the @@ -722,6 +748,7 @@ pub struct MintArgs { /// `files`, etc.). Wallets and explorers display this when /// rendering the asset. #[serde(default)] + #[schemars(schema_with = "json_object_schema")] pub metadata: Option, /// Bypass the configured `max_send_lovelace` hard cap on /// `dest_lovelace`. Only pass `true` for an intentional,