fix(mcp): force type:object on Value-typed metadata/policy args

schemars derives an empty (no-type) JSON Schema for Option<serde_json::Value>
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).
This commit is contained in:
Kayos 2026-05-09 09:23:34 -07:00
parent 72bb8fa38e
commit 91c5d557b6

View file

@ -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>` /
/// `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<serde_json::Value>,
/// Optional CIP-25 v2 metadata.
#[serde(default)]
#[schemars(schema_with = "json_object_schema")]
pub metadata: Option<serde_json::Value>,
/// 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<serde_json::Value>,
/// Bypass the configured `max_send_lovelace` hard cap on
/// `dest_lovelace`. Only pass `true` for an intentional,