From 3bc826eafb104676613a39d211f05a1b1b509200 Mon Sep 17 00:00:00 2001 From: kaluuba-org Date: Sun, 30 Aug 2026 16:17:03 +0100 Subject: [PATCH 1/4] fix(mcp): repair merge damage that left the package uncompilable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mcp package does not build or test on main. Three separate bad merges left duplicated declarations and a stale snapshot file behind: - auditLog.ts had two `emit` implementations — one writing to the file sink, the other stamping the correlation ID — so `tsc` rejected the module and every suite importing it collected zero tests. Merged into one exit point that does both, and imported the missing `currentCorrelationId`. - index.ts came out of "Merge branch 'main' into feat/mcp-545-catalog-resources" with both sides of eight conflicting hunks concatenated: two `Mutex` imports (one from `async-mutex`, which is not a dependency), two `safeErrorMessage` imports, two `browse` definitions, two `toolMetrics`, and a `ToolOutcome`/`string` return-type clash in the dispatcher. Redoing that three-way merge yields the pre-merge main file plus exactly the four additions #545 contributed (the resources capability, the two ListResources/ReadResource handlers, and their imports), which is what this commit applies. - preview() lost its #556 offline-snapshot fallback when #582's preview limits landed on top of it, leaving `index.test.ts` asserting a cache path the function no longer had. Restored as `previewData`, composed with `applyPreviewLimits` so both behaviours hold. - toolSchemaSnapshots snapshots were written before #553 added output schemas to TOOL_DEFINITIONS and never regenerated, so 25 cases expected `null` where a schema is now declared. Regenerated. mcp: 67 files / 2112 tests pass, `tsc --noEmit` is clean, and `smoke:install` drives the built server over stdio. --- .../toolSchemaSnapshots.test.ts.snap | 1341 ++++++++++++++++- mcp/src/auditLog.ts | 26 +- mcp/src/index.ts | 169 +-- 3 files changed, 1372 insertions(+), 164 deletions(-) diff --git a/mcp/src/__snapshots__/toolSchemaSnapshots.test.ts.snap b/mcp/src/__snapshots__/toolSchemaSnapshots.test.ts.snap index 4eff62b7..98c9d7a3 100644 --- a/mcp/src/__snapshots__/toolSchemaSnapshots.test.ts.snap +++ b/mcp/src/__snapshots__/toolSchemaSnapshots.test.ts.snap @@ -17,7 +17,12 @@ exports[`tool schema snapshots > mindvault_agent_status > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_agent_status > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_agent_status > output schema 1`] = ` +{ + "additionalProperties": true, + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_backup_state > annotations 1`] = ` { @@ -174,7 +179,73 @@ exports[`tool schema snapshots > mindvault_browse > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_browse > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_browse > output schema 1`] = ` +{ + "properties": { + "items": { + "items": { + "properties": { + "accessUrl": { + "type": [ + "string", + "null", + ], + }, + "description": { + "type": [ + "string", + "null", + ], + }, + "id": { + "type": [ + "string", + "null", + ], + }, + "price": { + "type": [ + "string", + "number", + "null", + ], + }, + "title": { + "type": [ + "string", + "null", + ], + }, + }, + "required": [ + "id", + "title", + "price", + "description", + "accessUrl", + ], + "type": "object", + }, + "type": "array", + }, + "notice": { + "type": [ + "string", + "null", + ], + }, + "truncated": { + "type": "boolean", + }, + }, + "required": [ + "items", + "notice", + "truncated", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_buy > annotations 1`] = ` { @@ -219,7 +290,91 @@ exports[`tool schema snapshots > mindvault_buy > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_buy > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_buy > output schema 1`] = ` +{ + "oneOf": [ + { + "properties": { + "after": {}, + "before": {}, + "changedFields": { + "items": { + "type": "string", + }, + "type": "array", + }, + "failureGuidance": { + "type": [ + "array", + "null", + ], + }, + "txHash": { + "type": [ + "string", + "null", + ], + }, + }, + "required": [ + "before", + "after", + "changedFields", + "txHash", + ], + "type": "object", + }, + { + "properties": { + "intentions": { + "type": "object", + }, + "mode": { + "const": "dry-run", + "type": "string", + }, + "operation": { + "type": "string", + }, + "steps": { + "items": { + "type": "string", + }, + "type": "array", + }, + "validation": { + "type": "object", + }, + }, + "required": [ + "mode", + "operation", + "validation", + "intentions", + "steps", + ], + "type": "object", + }, + { + "properties": { + "message": { + "type": "string", + }, + "status": { + "const": "text", + "type": "string", + }, + }, + "required": [ + "status", + "message", + ], + "type": "object", + }, + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_check_bindings > annotations 1`] = ` { @@ -272,7 +427,54 @@ exports[`tool schema snapshots > mindvault_check_consistency > input schema 1`] } `; -exports[`tool schema snapshots > mindvault_check_consistency > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_check_consistency > output schema 1`] = ` +{ + "properties": { + "apiFound": { + "type": "boolean", + }, + "matches": { + "type": "object", + }, + "mismatches": { + "type": "object", + }, + "missingInApi": { + "type": "array", + }, + "missingInOnchain": { + "type": "array", + }, + "onchainError": { + "type": [ + "string", + "null", + ], + }, + "onchainFound": { + "type": "boolean", + }, + "resourceId": { + "type": "string", + }, + "summary": { + "type": "string", + }, + }, + "required": [ + "resourceId", + "apiFound", + "onchainFound", + "onchainError", + "matches", + "mismatches", + "missingInApi", + "missingInOnchain", + "summary", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_check_state_permissions > annotations 1`] = ` { @@ -561,7 +763,27 @@ exports[`tool schema snapshots > mindvault_import_wallet > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_import_wallet > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_import_wallet > output schema 1`] = ` +{ + "properties": { + "address": { + "type": "string", + }, + "persisted": { + "type": "boolean", + }, + "profile": { + "type": "string", + }, + }, + "required": [ + "profile", + "address", + "persisted", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_list_profiles > annotations 1`] = ` { @@ -580,7 +802,49 @@ exports[`tool schema snapshots > mindvault_list_profiles > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_list_profiles > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_list_profiles > output schema 1`] = ` +{ + "properties": { + "active": { + "type": "string", + }, + "profiles": { + "items": { + "properties": { + "active": { + "type": "boolean", + }, + "address": { + "type": [ + "string", + "null", + ], + }, + "name": { + "type": "string", + }, + "publisherRegistered": { + "type": "boolean", + }, + }, + "required": [ + "name", + "address", + "publisherRegistered", + "active", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "active", + "profiles", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_metrics > annotations 1`] = ` { @@ -608,7 +872,43 @@ exports[`tool schema snapshots > mindvault_metrics > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_metrics > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_metrics > output schema 1`] = ` +{ + "properties": { + "enabled": { + "type": "boolean", + }, + "message": { + "type": "string", + }, + "payments": { + "type": "object", + }, + "since": { + "type": [ + "string", + "null", + ], + }, + "toolDurationBudgetMs": { + "type": [ + "integer", + "null", + ], + }, + "tools": { + "type": "object", + }, + "totals": { + "type": "object", + }, + }, + "required": [ + "enabled", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_network_profile > annotations 1`] = ` { @@ -627,7 +927,46 @@ exports[`tool schema snapshots > mindvault_network_profile > input schema 1`] = } `; -exports[`tool schema snapshots > mindvault_network_profile > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_network_profile > output schema 1`] = ` +{ + "properties": { + "horizonUrl": { + "type": "string", + }, + "registryContractId": { + "type": "string", + }, + "retries": {}, + "sorobanRpcUrl": { + "type": "string", + }, + "stellarNetwork": { + "type": "string", + }, + "timeouts": {}, + "usdcContractId": {}, + "warnings": { + "items": { + "type": "string", + }, + "type": "array", + }, + "x402Network": { + "type": "string", + }, + }, + "required": [ + "stellarNetwork", + "x402Network", + "sorobanRpcUrl", + "horizonUrl", + "registryContractId", + "usdcContractId", + "warnings", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_preview > annotations 1`] = ` { @@ -658,7 +997,32 @@ exports[`tool schema snapshots > mindvault_preview > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_preview > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_preview > output schema 1`] = ` +{ + "properties": { + "accessUrl": {}, + "description": {}, + "id": {}, + "offlineCache": { + "type": "string", + }, + "price": {}, + "title": {}, + "type": {}, + "verificationStatus": {}, + }, + "required": [ + "id", + "title", + "description", + "price", + "type", + "verificationStatus", + "accessUrl", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_publish > annotations 1`] = ` { @@ -722,41 +1086,152 @@ exports[`tool schema snapshots > mindvault_publish > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_publish > output schema 1`] = `null`; - -exports[`tool schema snapshots > mindvault_recover_catalog_cache > annotations 1`] = `undefined`; - -exports[`tool schema snapshots > mindvault_recover_catalog_cache > input schema 1`] = ` -{ - "properties": {}, - "required": [], - "type": "object", -} -`; - -exports[`tool schema snapshots > mindvault_recover_catalog_cache > output schema 1`] = `null`; - -exports[`tool schema snapshots > mindvault_register > annotations 1`] = ` -{ - "destructiveHint": false, - "idempotentHint": false, - "readOnlyHint": false, - "title": "Register Publisher", -} -`; - -exports[`tool schema snapshots > mindvault_register > input schema 1`] = ` +exports[`tool schema snapshots > mindvault_publish > output schema 1`] = ` { - "properties": { - "confirmMainnet": { - "description": "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", - "type": "boolean", - }, - "email": { - "description": "Contact email for the publisher record. Must be a valid address (max 254 chars).", - "examples": [ - "agent-a@example.com", - ], + "oneOf": [ + { + "properties": { + "after": {}, + "before": {}, + "changedFields": { + "items": { + "type": "string", + }, + "type": "array", + }, + "failureGuidance": { + "type": [ + "array", + "null", + ], + }, + "txHash": { + "type": [ + "string", + "null", + ], + }, + }, + "required": [ + "before", + "after", + "changedFields", + "txHash", + ], + "type": "object", + }, + { + "properties": { + "intentions": { + "type": "object", + }, + "mode": { + "const": "dry-run", + "type": "string", + }, + "operation": { + "type": "string", + }, + "steps": { + "items": { + "type": "string", + }, + "type": "array", + }, + "validation": { + "type": "object", + }, + }, + "required": [ + "mode", + "operation", + "validation", + "intentions", + "steps", + ], + "type": "object", + }, + { + "properties": { + "message": { + "type": "string", + }, + "status": { + "const": "text", + "type": "string", + }, + }, + "required": [ + "status", + "message", + ], + "type": "object", + }, + ], + "type": "object", +} +`; + +exports[`tool schema snapshots > mindvault_recover_catalog_cache > annotations 1`] = ` +{ + "destructiveHint": false, + "idempotentHint": true, + "readOnlyHint": false, + "title": "Recover Catalog Cache", +} +`; + +exports[`tool schema snapshots > mindvault_recover_catalog_cache > input schema 1`] = ` +{ + "properties": {}, + "required": [], + "type": "object", +} +`; + +exports[`tool schema snapshots > mindvault_recover_catalog_cache > output schema 1`] = ` +{ + "properties": { + "action": { + "type": "string", + }, + "message": { + "type": "string", + }, + "source": { + "type": "string", + }, + }, + "required": [ + "source", + "action", + "message", + ], + "type": "object", +} +`; + +exports[`tool schema snapshots > mindvault_register > annotations 1`] = ` +{ + "destructiveHint": false, + "idempotentHint": false, + "readOnlyHint": false, + "title": "Register Publisher", +} +`; + +exports[`tool schema snapshots > mindvault_register > input schema 1`] = ` +{ + "properties": { + "confirmMainnet": { + "description": "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", + "type": "boolean", + }, + "email": { + "description": "Contact email for the publisher record. Must be a valid address (max 254 chars).", + "examples": [ + "agent-a@example.com", + ], "type": "string", }, "name": { @@ -818,7 +1293,91 @@ exports[`tool schema snapshots > mindvault_register_onchain > input schema 1`] = } `; -exports[`tool schema snapshots > mindvault_register_onchain > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_register_onchain > output schema 1`] = ` +{ + "oneOf": [ + { + "properties": { + "after": {}, + "before": {}, + "changedFields": { + "items": { + "type": "string", + }, + "type": "array", + }, + "failureGuidance": { + "type": [ + "array", + "null", + ], + }, + "txHash": { + "type": [ + "string", + "null", + ], + }, + }, + "required": [ + "before", + "after", + "changedFields", + "txHash", + ], + "type": "object", + }, + { + "properties": { + "intentions": { + "type": "object", + }, + "mode": { + "const": "dry-run", + "type": "string", + }, + "operation": { + "type": "string", + }, + "steps": { + "items": { + "type": "string", + }, + "type": "array", + }, + "validation": { + "type": "object", + }, + }, + "required": [ + "mode", + "operation", + "validation", + "intentions", + "steps", + ], + "type": "object", + }, + { + "properties": { + "message": { + "type": "string", + }, + "status": { + "const": "text", + "type": "string", + }, + }, + "required": [ + "status", + "message", + ], + "type": "object", + }, + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_registry_health > annotations 1`] = ` { @@ -856,7 +1415,46 @@ exports[`tool schema snapshots > mindvault_registry_info > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_registry_info > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_registry_info > output schema 1`] = ` +{ + "properties": { + "contractId": { + "type": "string", + }, + "mainnetDiagnostics": { + "type": "string", + }, + "network": { + "type": "string", + }, + "networkPassphrase": { + "type": "string", + }, + "resourceFields": { + "items": { + "type": "string", + }, + "type": "array", + }, + "rpcUrl": { + "type": "string", + }, + "x402Network": { + "type": "string", + }, + }, + "required": [ + "contractId", + "networkPassphrase", + "rpcUrl", + "network", + "x402Network", + "resourceFields", + "mainnetDiagnostics", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_registry_list > annotations 1`] = ` { @@ -895,7 +1493,42 @@ exports[`tool schema snapshots > mindvault_registry_list > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_registry_list > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_registry_list > output schema 1`] = ` +{ + "properties": { + "contract": {}, + "count": { + "type": "integer", + }, + "limit": { + "type": "integer", + }, + "message": { + "type": "string", + }, + "network": {}, + "resources": { + "type": "array", + }, + "rpc": {}, + "source": { + "type": "string", + }, + "start": { + "type": "integer", + }, + }, + "required": [ + "source", + "start", + "limit", + "count", + "resources", + "contract", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_registry_lookup > annotations 1`] = ` { @@ -926,7 +1559,42 @@ exports[`tool schema snapshots > mindvault_registry_lookup > input schema 1`] = } `; -exports[`tool schema snapshots > mindvault_registry_lookup > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_registry_lookup > output schema 1`] = ` +{ + "properties": { + "contract": {}, + "creator": {}, + "found": { + "type": "boolean", + }, + "id": {}, + "listed": {}, + "message": { + "type": "string", + }, + "metadata": {}, + "network": {}, + "next": { + "type": "string", + }, + "price": {}, + "resourceId": { + "type": "string", + }, + "rpc": {}, + "source": { + "type": "string", + }, + "tags": {}, + }, + "required": [ + "source", + "found", + "contract", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_reset > annotations 1`] = ` { @@ -1152,7 +1820,73 @@ exports[`tool schema snapshots > mindvault_search > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_search > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_search > output schema 1`] = ` +{ + "properties": { + "items": { + "items": { + "properties": { + "accessUrl": { + "type": [ + "string", + "null", + ], + }, + "description": { + "type": [ + "string", + "null", + ], + }, + "id": { + "type": [ + "string", + "null", + ], + }, + "price": { + "type": [ + "string", + "number", + "null", + ], + }, + "title": { + "type": [ + "string", + "null", + ], + }, + }, + "required": [ + "id", + "title", + "price", + "description", + "accessUrl", + ], + "type": "object", + }, + "type": "array", + }, + "notice": { + "type": [ + "string", + "null", + ], + }, + "truncated": { + "type": "boolean", + }, + }, + "required": [ + "items", + "notice", + "truncated", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_set_listed > annotations 1`] = ` { @@ -1195,7 +1929,94 @@ exports[`tool schema snapshots > mindvault_set_listed > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_set_listed > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_set_listed > output schema 1`] = ` +{ + "oneOf": [ + { + "properties": { + "listed": { + "type": "boolean", + }, + "metadata": { + "type": "string", + }, + "newCreator": { + "type": "string", + }, + "price": { + "type": "string", + }, + "resourceId": { + "type": "string", + }, + "status": { + "type": "string", + }, + "txHash": { + "type": [ + "string", + "null", + ], + }, + }, + "required": [ + "status", + "resourceId", + "txHash", + ], + "type": "object", + }, + { + "properties": { + "intentions": { + "type": "object", + }, + "mode": { + "const": "dry-run", + "type": "string", + }, + "operation": { + "type": "string", + }, + "steps": { + "items": { + "type": "string", + }, + "type": "array", + }, + "validation": { + "type": "object", + }, + }, + "required": [ + "mode", + "operation", + "validation", + "intentions", + "steps", + ], + "type": "object", + }, + { + "properties": { + "message": { + "type": "string", + }, + "status": { + "const": "text", + "type": "string", + }, + }, + "required": [ + "status", + "message", + ], + "type": "object", + }, + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_set_price > annotations 1`] = ` { @@ -1239,7 +2060,94 @@ exports[`tool schema snapshots > mindvault_set_price > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_set_price > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_set_price > output schema 1`] = ` +{ + "oneOf": [ + { + "properties": { + "listed": { + "type": "boolean", + }, + "metadata": { + "type": "string", + }, + "newCreator": { + "type": "string", + }, + "price": { + "type": "string", + }, + "resourceId": { + "type": "string", + }, + "status": { + "type": "string", + }, + "txHash": { + "type": [ + "string", + "null", + ], + }, + }, + "required": [ + "status", + "resourceId", + "txHash", + ], + "type": "object", + }, + { + "properties": { + "intentions": { + "type": "object", + }, + "mode": { + "const": "dry-run", + "type": "string", + }, + "operation": { + "type": "string", + }, + "steps": { + "items": { + "type": "string", + }, + "type": "array", + }, + "validation": { + "type": "object", + }, + }, + "required": [ + "mode", + "operation", + "validation", + "intentions", + "steps", + ], + "type": "object", + }, + { + "properties": { + "message": { + "type": "string", + }, + "status": { + "const": "text", + "type": "string", + }, + }, + "required": [ + "status", + "message", + ], + "type": "object", + }, + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_set_tags > annotations 1`] = ` { @@ -1325,7 +2233,27 @@ exports[`tool schema snapshots > mindvault_setup_wallet > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_setup_wallet > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_setup_wallet > output schema 1`] = ` +{ + "properties": { + "address": { + "type": "string", + }, + "persisted": { + "type": "boolean", + }, + "profile": { + "type": "string", + }, + }, + "required": [ + "profile", + "address", + "persisted", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_transfer_ownership > annotations 1`] = ` { @@ -1367,7 +2295,94 @@ exports[`tool schema snapshots > mindvault_transfer_ownership > input schema 1`] } `; -exports[`tool schema snapshots > mindvault_transfer_ownership > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_transfer_ownership > output schema 1`] = ` +{ + "oneOf": [ + { + "properties": { + "listed": { + "type": "boolean", + }, + "metadata": { + "type": "string", + }, + "newCreator": { + "type": "string", + }, + "price": { + "type": "string", + }, + "resourceId": { + "type": "string", + }, + "status": { + "type": "string", + }, + "txHash": { + "type": [ + "string", + "null", + ], + }, + }, + "required": [ + "status", + "resourceId", + "txHash", + ], + "type": "object", + }, + { + "properties": { + "intentions": { + "type": "object", + }, + "mode": { + "const": "dry-run", + "type": "string", + }, + "operation": { + "type": "string", + }, + "steps": { + "items": { + "type": "string", + }, + "type": "array", + }, + "validation": { + "type": "object", + }, + }, + "required": [ + "mode", + "operation", + "validation", + "intentions", + "steps", + ], + "type": "object", + }, + { + "properties": { + "message": { + "type": "string", + }, + "status": { + "const": "text", + "type": "string", + }, + }, + "required": [ + "status", + "message", + ], + "type": "object", + }, + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_tx_status > annotations 1`] = ` { @@ -1397,7 +2412,61 @@ exports[`tool schema snapshots > mindvault_tx_status > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_tx_status > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_tx_status > output schema 1`] = ` +{ + "oneOf": [ + { + "properties": { + "applicationOrder": {}, + "envelopeXdr": {}, + "feeBump": {}, + "hash": { + "type": "string", + }, + "latestLedger": {}, + "ledger": {}, + "ledgerCloseTime": { + "type": [ + "string", + "null", + ], + }, + "message": { + "type": "string", + }, + "oldestLedger": {}, + "resultMetaXdr": {}, + "resultXdr": {}, + "status": { + "type": "string", + }, + }, + "required": [ + "status", + "hash", + ], + "type": "object", + }, + { + "properties": { + "message": { + "type": "string", + }, + "status": { + "const": "text", + "type": "string", + }, + }, + "required": [ + "status", + "message", + ], + "type": "object", + }, + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_update_metadata > annotations 1`] = ` { @@ -1440,7 +2509,94 @@ exports[`tool schema snapshots > mindvault_update_metadata > input schema 1`] = } `; -exports[`tool schema snapshots > mindvault_update_metadata > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_update_metadata > output schema 1`] = ` +{ + "oneOf": [ + { + "properties": { + "listed": { + "type": "boolean", + }, + "metadata": { + "type": "string", + }, + "newCreator": { + "type": "string", + }, + "price": { + "type": "string", + }, + "resourceId": { + "type": "string", + }, + "status": { + "type": "string", + }, + "txHash": { + "type": [ + "string", + "null", + ], + }, + }, + "required": [ + "status", + "resourceId", + "txHash", + ], + "type": "object", + }, + { + "properties": { + "intentions": { + "type": "object", + }, + "mode": { + "const": "dry-run", + "type": "string", + }, + "operation": { + "type": "string", + }, + "steps": { + "items": { + "type": "string", + }, + "type": "array", + }, + "validation": { + "type": "object", + }, + }, + "required": [ + "mode", + "operation", + "validation", + "intentions", + "steps", + ], + "type": "object", + }, + { + "properties": { + "message": { + "type": "string", + }, + "status": { + "const": "text", + "type": "string", + }, + }, + "required": [ + "status", + "message", + ], + "type": "object", + }, + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_use_profile > annotations 1`] = ` { @@ -1471,7 +2627,33 @@ exports[`tool schema snapshots > mindvault_use_profile > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_use_profile > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_use_profile > output schema 1`] = ` +{ + "properties": { + "address": { + "type": [ + "string", + "null", + ], + }, + "profile": { + "type": "string", + }, + "publisherRegistered": { + "type": [ + "boolean", + "null", + ], + }, + }, + "required": [ + "profile", + "address", + "publisherRegistered", + ], + "type": "object", +} +`; exports[`tool schema snapshots > mindvault_verify_install > annotations 1`] = ` { @@ -1509,7 +2691,54 @@ exports[`tool schema snapshots > mindvault_wallet_info > input schema 1`] = ` } `; -exports[`tool schema snapshots > mindvault_wallet_info > output schema 1`] = `null`; +exports[`tool schema snapshots > mindvault_wallet_info > output schema 1`] = ` +{ + "properties": { + "address": { + "type": "string", + }, + "note": { + "type": [ + "string", + "null", + ], + }, + "profile": { + "type": "string", + }, + "publisherRegistered": { + "type": "boolean", + }, + "usdcBalance": { + "type": "string", + }, + "usdcStatus": { + "type": "string", + }, + "xlmAvailable": { + "type": "string", + }, + "xlmBalance": { + "type": "string", + }, + "xlmReserve": { + "type": "string", + }, + }, + "required": [ + "profile", + "address", + "xlmBalance", + "xlmReserve", + "xlmAvailable", + "usdcBalance", + "usdcStatus", + "publisherRegistered", + "note", + ], + "type": "object", +} +`; exports[`tool schema snapshots > the covered tool names are pinned 1`] = ` [ diff --git a/mcp/src/auditLog.ts b/mcp/src/auditLog.ts index 3a4a0400..0568b37c 100644 --- a/mcp/src/auditLog.ts +++ b/mcp/src/auditLog.ts @@ -8,6 +8,7 @@ import { redactSecrets, redactObject } from "./redaction.js"; import { createRotatingWriter, type RotatingJsonlWriter } from "./auditLogRotation.js"; +import { currentCorrelationId } from "./correlation.js"; export interface AuditLogEntry { timestamp: string; @@ -84,18 +85,6 @@ export function setAuditFileWriter(writer: RotatingJsonlWriter | null): void { auditFileWriter = writer; } -/** - * Emit one entry to every configured sink. - * - * stderr keeps the indented form it has always had — it is read by humans - * watching a session — while the file gets one compact line per entry, which - * is what makes the file greppable and shippable. - */ -function emit(entry: AuditLogEntry | NetworkAuditLog): void { - console.error(JSON.stringify(entry, null, 2)); - auditFileWriter?.write(entry); -} - /** * Check if audit logging is enabled. */ @@ -128,9 +117,18 @@ function withCorrelation(entry: T): T return correlationId ? { ...entry, correlationId } : entry; } -/** Single exit point for every audit entry, so correlation is never skipped. */ +/** + * Single exit point for every audit entry, so correlation is never skipped and + * every configured sink sees the same entry. + * + * stderr keeps the indented form it has always had — it is read by humans + * watching a session — while the file gets one compact line per entry, which + * is what makes the file greppable and shippable. + */ function emit(entry: AuditLogEntry | NetworkAuditLog): void { - console.error(JSON.stringify(withCorrelation(entry), null, 2)); + const correlated = withCorrelation(entry); + console.error(JSON.stringify(correlated, null, 2)); + auditFileWriter?.write(correlated); } /** diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 461f4fc9..4be0bfe1 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -32,20 +32,8 @@ import { wrapFetchWithPayment, x402Client } from "@x402/fetch"; import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"; import { homedir } from "os"; import { join } from "path"; -import { Mutex } from "./mutex.js"; import { cacheStalenessNotice } from "./cacheStaleness.js"; -import { - catalogCacheLabel, - getCatalogSnapshot, - getPreviewSnapshot, - recordCatalogSnapshot, - recordPreviewSnapshot, -} from "./catalogCache.js"; -import { - collectStartupDiagnostics, - formatDiagnostics, - hasBlockingDiagnostics, -} from "./diagnostics.js"; +import { buildConfig, resolveConfig } from "./config.js"; import { assertMainnetMutationAllowed, formatMainnetDiagnostics, @@ -106,7 +94,6 @@ import { } from "./publishStatus.js"; import { type ApiResponse } from "./apiResponse.js"; import { safeErrorMessage, safeLog } from "./redaction.js"; -import { safeErrorMessage } from "./redaction.js"; import { assertAutoPaymentWithinCeiling } from "./paymentCeiling.js"; import { signMutatingHeaders } from "./requestSignature.js"; import { @@ -1159,46 +1146,37 @@ function listProfilesOutcome(): ToolOutcome { return { text: [`Profiles (* = active):`, ...lines].join("\n"), structured }; } -interface CatalogLoad { - items: any[]; - /** Notice appended to the output. Fresh reads use the server cache headers; offline reads use the snapshot-age label. */ - notice: string | null; - /** True when the catalog was served from the offline snapshot rather than the live API. */ - fromCache: boolean; +export function listProfiles(): string { + return outcomeText(listProfilesOutcome()); } -/** - * Fetch the catalog resource array, recording a snapshot on success and falling - * back to the last snapshot on transport failure (#556). A reachable-but-error - * response is surfaced verbatim — the cache is only for the unreachable case. - */ -async function loadCatalog(url: string, operation: string): Promise { - let res; +async function browseOutcome(filters: CatalogFilters = {}): Promise { + const qs = buildCatalogQueryString(filters); + const url = qs ? `${BASE_URL}/resources?${qs}` : `${BASE_URL}/resources`; + let raw: any[] = []; + let notice: string | null = null; try { - res = await jsonFetch(url); + const res = await jsonFetch(url); + if (!res.ok) { + throw mcpError( + mapHttpError({ + operation: "Browse failed", + source: "api", + status: res.status, + data: res.data, + }), + ); + } + raw = Array.isArray(res.data) ? res.data : []; + recordCatalogSnapshot(raw); + notice = cacheStalenessNotice(res.headers); } catch (err) { const snapshot = getCatalogSnapshot(); - // No cached snapshot — surface the original deterministic error from jsonFetch. if (!snapshot) throw err; - return { - items: Array.isArray(snapshot.resources) ? (snapshot.resources as any[]) : [], - notice: catalogCacheLabel(snapshot.savedAtMs), - fromCache: true, - }; + raw = Array.isArray(snapshot.resources) ? (snapshot.resources as any[]) : []; + notice = catalogCacheLabel(snapshot.savedAtMs); } - if (!res.ok) { - throw mcpError(mapHttpError({ operation, source: "api", status: res.status, data: res.data })); - } - const items = Array.isArray(res.data) ? res.data : []; - recordCatalogSnapshot(items); - return { items, notice: cacheStalenessNotice(res.headers), fromCache: false }; -} - -export async function browse(filters: CatalogFilters = {}): Promise { - const qs = buildCatalogQueryString(filters); - const url = qs ? `${BASE_URL}/resources?${qs}` : `${BASE_URL}/resources`; - const { items: raw, notice } = await loadCatalog(url, "Browse failed"); - const items = applyCatalogSort(applyClientCatalogFilters(raw, filters), filters.sort); + const items: any[] = applyCatalogSort(applyClientCatalogFilters(raw, filters), filters.sort); const body = items.length === 0 ? filters.query || @@ -1212,10 +1190,7 @@ export async function browse(filters: CatalogFilters = {}): Promise { ? `No resources match ${describeCatalogFilters(filters)}.` : "No resources listed yet." : items.map(formatResource).join("\n\n"); - // Warn when the catalog may be stale relative to the on-chain registry, based - // on the server's cache headers. Silent when there is no cache metadata. - const full = notice ? `${body}\n\n${notice}` : body; - return truncateResponse(full); + return catalogOutcome(items, body, notice); } export async function browse(filters: CatalogFilters = {}): Promise { @@ -1245,19 +1220,50 @@ async function searchOutcome(filtersOrQuery: string | CatalogFilters): Promise { + return outcomeText(await searchOutcome(filtersOrQuery)); +} + +/** + * Fetch one resource's public metadata, recording a snapshot on success and + * falling back to the last snapshot on transport failure (#556). A + * reachable-but-error response is surfaced verbatim — the cache only covers the + * unreachable case. + */ async function previewData(resourceId: string): Promise<{ r: any; label: string | null }> { try { const res = await jsonFetch(`${BASE_URL}/resources/${resourceId}/meta`); @@ -1280,7 +1286,9 @@ async function previewData(resourceId: string): Promise<{ r: any; label: string export async function preview(resourceId: string): Promise { const { r, label } = await previewData(resourceId); - const out: Record = { + // Publisher-supplied title/description are unbounded at the source, so cap + // them before serializing rather than truncating the JSON afterwards (#582). + const out: Record = applyPreviewLimits({ id: r.id, title: r.title, description: r.description, @@ -1288,9 +1296,9 @@ export async function preview(resourceId: string): Promise { type: r.resourceType, verificationStatus: r.verificationStatus, accessUrl: r.accessUrl, - }; + }); if (label) out.offlineCache = label; - return JSON.stringify(out, null, 2); + return serializePreview(out); } async function fetchPublishStatusData(resourceId: string): Promise { @@ -2455,9 +2463,7 @@ export function networkProfile(): string { /** * Verify the installed registry-client bindings match the deployed contract's - * interface. Returns the check's deterministic, agent-safe message (a match - * summary, a mismatch warning with a recommended fix, or a "could not verify" - * note when the contract/RPC is unreachable). + * interface. Returns the check's deterministic, agent-safe message. */ async function checkBindings(): Promise { if (_isMock()) return "Mock mode: contract binding check skipped (no live RPC)."; @@ -2470,29 +2476,6 @@ async function checkBindings(): Promise { return result.message; } -/** - * Return opt-in tool-level metrics as JSON. Only counts, durations, and tool - * names are included — never arguments, wallets, or API keys. When metrics are - * disabled, returns an actionable note instead of counters. Pass reset=true to - * clear counters after reading. - */ -function toolMetrics(reset: boolean): string { - const snapshot = metrics.snapshot(); - if (reset) metrics.reset(); - if (!snapshot.enabled) { - return JSON.stringify( - { - enabled: false, - message: - "Metrics are disabled. Set MINDVAULT_METRICS=1 (or true/yes/on) and restart the server to collect tool-level metrics.", - }, - null, - 2, - ); - } - return JSON.stringify(snapshot, null, 2); -} - /** * Return opt-in tool-level metrics as JSON. Pass reset=true to clear counters * after reading. Text-only when disabled (still JSON so structuredContent works). @@ -2571,7 +2554,7 @@ async function dispatchToolOutcome( await assertApiReachableFor(name); } - const execute = async (): Promise => { + const execute = async (): Promise => { switch (name) { case "mindvault_setup_wallet": return setupWallet(optionalString(args, "profile")); @@ -3276,12 +3259,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { ? createProgressEmitter({ token: progressToken, send: extra.sendNotification }) : undefined; try { - const result = await measureTool(metrics, name, () => dispatchTool(name, args, onProgress)); - const structured = structuredResult(name, result); - return { - content: [{ type: "text", text: result }], - ...(structured ? { structuredContent: structured } : {}), - }; + const result = await measureTool(metrics, name, () => + dispatchToolOutcome(name, args, onProgress), + ); + return normalizeToolResult(name, result, hasOutputSchema); } catch (err: any) { const mapped = mappedErrorOf(err); return { From b81b8807c9a594408fc271051a0a4135aebb9586 Mon Sep 17 00:00:00 2001 From: kaluuba-org Date: Sun, 30 Aug 2026 16:43:25 +0100 Subject: [PATCH 2/4] feat(mcp): single-source the tool surface, add read-only mode and a paid-operation policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #596, #593, #594. ## #596 — list_tools contract drift check tools.ts calls itself "the single source of truth for the tool surface advertised to agent clients", and scripts/generate-tool-docs.ts builds docs/mcp-tool-reference.md on that promise. It was not true: the ListTools handler in index.ts carried its own ~490-line literal copy, and the two had drifted apart in every direction at once. - Six implemented, validated, documented tools were missing from the copy and so undiscoverable: mindvault_update_metadata, _set_price, _transfer_ownership, _set_listed, _export_receipts, _recover_catalog_cache. - mindvault_publish_status and mindvault_purchase_history existed only in the copy, so the generated reference never listed them and the schema snapshots never covered them. - mindvault_reset advertised a `confirm` argument that resetGuard reads and TOOL_ARGUMENT_SPECS did not declare, so the validator rejected it as an unknown argument. The tool could never be confirmed — it was permanently stuck returning its preview. - mindvault_publish and mindvault_buy had stopped advertising `dryRun` and `maxAutoPayUsdc`, and several schemas had lost their field descriptions and examples. toolSurface.ts now derives the ListTools payload from TOOL_DEFINITIONS, which removes the cause; listToolsContract.test.ts covers the category. A tool is four declarations — definition, argument spec, dispatch case, output schema — and any one can be added without the others. The test checks all four agree against the response a real client receives over a real transport, and was verified to fail on three injected regressions (a definition with no handler, an argument the validator rejects, a tool dropped from the surface). mindvault_set_tags is defined and validated but has no dispatch case, as docs/mcp-structured-output.md records. It is withheld rather than advertised — an agent that calls an advertised tool and gets `Unknown tool` learns nothing it can act on — and TOOLS_WITHOUT_HANDLERS is asserted to name exactly the tools in that state, in both directions. EXTRA_TOOL_ANNOTATIONS and EXTRA_OUTPUT_SCHEMAS existed only to patch over the two tools missing from TOOL_DEFINITIONS. Both are gone. ## #593 — read-only mode for catalog browsing MINDVAULT_READ_ONLY=1 restricts the server to catalog browsing. Unlike the mainnet guardrail it is operator-scoped rather than network-scoped, and cannot be lifted by a tool argument — confirmMainnet is supplied from inside the very call you wanted to prevent. ListTools advertises only tools declaring readOnlyHint, and dispatch refuses the rest. Both are needed: the listing keeps an agent from planning around a tool it cannot use, but a client with a cached list still calls it, so the gate that enforces the mode is the one in the dispatcher. The refusal names what is still available. Classification reads each tool's own readOnlyHint, so a tool added later is covered by what it declares about itself. ## #594 — paid-operation confirmation policy MINDVAULT_CONFIRM_PAID_OPERATIONS=off|usdc|all requires confirmPaid: true before a tool spends. It is a third axis, not a replacement: the mainnet guardrail asks *where* a spend happens and never fires on testnet; the auto-pay ceiling asks *how much* and permits a hundred cheap purchases. This asks whether the caller meant to spend at all. Default is off, so an upgrade changes no existing deployment. Dry runs are exempt — gating one would mean confirming a spend to discover what the spend would be. mindvault_setup_wallet is not gated: the sponsored-account service funds it, not the agent's wallet. An unrecognized value raises rather than falling back to off, because a typo that silently disables a safety setting leaves the operator believing they are protected. Tests pin that confirmPaid and confirmMainnet cannot substitute for each other. ## Verification mcp: 71 files / 2258 tests pass, including 31 consecutive runs and 6 with --sequence.shuffle. Workspace `pnpm test`, eslint (0 errors), prettier, the mcp build, and smoke:install all pass. index.ts drops 586 lines. --- docs/environment-variables.md | 62 +- docs/mcp-tool-reference.md | 28 +- mcp/.env.example | 11 + .../__snapshots__/toolMetadata.test.ts.snap | 6 + .../toolSchemaSnapshots.test.ts.snap | 231 +++++++ mcp/src/index.ts | 586 +----------------- mcp/src/listToolsContract.test.ts | 403 ++++++++++++ mcp/src/outputSchemas.test.ts | 13 +- mcp/src/outputSchemas.ts | 6 - mcp/src/paidOperations.test.ts | 456 ++++++++++++++ mcp/src/paidOperations.ts | 215 +++++++ mcp/src/readOnlyMode.test.ts | 298 +++++++++ mcp/src/readOnlyMode.ts | 129 ++++ mcp/src/toolMetadata.test.ts | 32 - mcp/src/toolSurface.ts | 103 +++ mcp/src/tools.ts | 108 ++++ mcp/src/validation.test.ts | 29 +- mcp/src/validation.ts | 43 +- 18 files changed, 2130 insertions(+), 629 deletions(-) create mode 100644 mcp/src/listToolsContract.test.ts create mode 100644 mcp/src/paidOperations.test.ts create mode 100644 mcp/src/paidOperations.ts create mode 100644 mcp/src/readOnlyMode.test.ts create mode 100644 mcp/src/readOnlyMode.ts create mode 100644 mcp/src/toolSurface.ts diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 9d0e997c..8d25a697 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -163,24 +163,60 @@ All other variables are either public addresses or non-sensitive configuration. ## MCP -| Variable | Required | Default | Description | -| ----------------------------------- | -------- | --------- | ------------------------------------------------------------------------------------------------------- | -| `STELLAR_NETWORK` | no | `testnet` | MCP deployment target (`testnet` or `mainnet` / `pubnet` / `public`). | -| `MINDVAULT_ALLOW_MAINNET` | no | unset | Set to `1` / `true` to allow gated MCP mutations and buys on mainnet without per-call `confirmMainnet`. | -| `MINDVAULT_HTTP_TIMEOUT_MS` | no | `15000` | Request deadline for the MindVault API and sponsored-account service. `0` disables. | -| `MINDVAULT_HORIZON_TIMEOUT_MS` | no | `15000` | Request deadline for Horizon balance/account reads. `0` disables. | -| `MINDVAULT_SOROBAN_TIMEOUT_MS` | no | `20000` | Request deadline for Soroban RPC calls. `0` disables. | -| `MINDVAULT_PAYMENT_TIMEOUT_MS` | no | `45000` | Request deadline for x402 paid fetches, which include on-chain settlement. `0` disables. | -| `MINDVAULT_RETRY_ATTEMPTS` | no | `3` | Total attempts (including the first) for idempotent MCP calls. `1` disables retrying. | -| `MINDVAULT_RETRY_BASE_DELAY_MS` | no | `250` | Backoff delay before the first retry; doubles each attempt. | -| `MINDVAULT_RETRY_MAX_DELAY_MS` | no | `4000` | Ceiling on the backoff delay before jitter is applied. | -| `MINDVAULT_PREVIEW_MAX_BYTES` | no | `8192` | Byte ceiling for a `mindvault_preview` response. `0` disables; values below `1024` are raised to it. | -| `MINDVAULT_PREVIEW_FIELD_MAX_CHARS` | no | `1000` | Character ceiling for each free-text preview field (`title`, `description`). `0` disables. | +| Variable | Required | Default | Description | +| ----------------------------------- | -------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `STELLAR_NETWORK` | no | `testnet` | MCP deployment target (`testnet` or `mainnet` / `pubnet` / `public`). | +| `MINDVAULT_ALLOW_MAINNET` | no | unset | Set to `1` / `true` to allow gated MCP mutations and buys on mainnet without per-call `confirmMainnet`. | +| `MINDVAULT_READ_ONLY` | no | unset | Set to `1` / `true` / `yes` / `on` to restrict the MCP server to catalog browsing. Only read-only tools are advertised and all others are refused. | +| `MINDVAULT_CONFIRM_PAID_OPERATIONS` | no | `off` | `off`, `usdc`, or `all`. Requires `confirmPaid: true` on tools that spend, independently of network. An unrecognized value is an error, not a fallback. | +| `MINDVAULT_HTTP_TIMEOUT_MS` | no | `15000` | Request deadline for the MindVault API and sponsored-account service. `0` disables. | +| `MINDVAULT_HORIZON_TIMEOUT_MS` | no | `15000` | Request deadline for Horizon balance/account reads. `0` disables. | +| `MINDVAULT_SOROBAN_TIMEOUT_MS` | no | `20000` | Request deadline for Soroban RPC calls. `0` disables. | +| `MINDVAULT_PAYMENT_TIMEOUT_MS` | no | `45000` | Request deadline for x402 paid fetches, which include on-chain settlement. `0` disables. | +| `MINDVAULT_RETRY_ATTEMPTS` | no | `3` | Total attempts (including the first) for idempotent MCP calls. `1` disables retrying. | +| `MINDVAULT_RETRY_BASE_DELAY_MS` | no | `250` | Backoff delay before the first retry; doubles each attempt. | +| `MINDVAULT_RETRY_MAX_DELAY_MS` | no | `4000` | Ceiling on the backoff delay before jitter is applied. | +| `MINDVAULT_PREVIEW_MAX_BYTES` | no | `8192` | Byte ceiling for a `mindvault_preview` response. `0` disables; values below `1024` are raised to it. | +| `MINDVAULT_PREVIEW_FIELD_MAX_CHARS` | no | `1000` | Character ceiling for each free-text preview field (`title`, `description`). `0` disables. | Timeouts are enforced with `AbortController`. Retries apply to idempotent calls only — catalog `GET`s, Horizon reads, and Soroban `getTransaction` — and never to x402 payments, which could settle twice. See [`mcp-timeouts-retries.md`](./mcp-timeouts-retries.md) for budgets, policy, and tuning guidance. On mainnet, MCP tools that mutate state or spend funds (`mindvault_buy`, `mindvault_publish`, `mindvault_register`, `mindvault_register_onchain`, `mindvault_setup_wallet`, `mindvault_reset`, `mindvault_update_metadata`, `mindvault_set_price`, `mindvault_transfer_ownership`, `mindvault_set_listed`) require either `confirmMainnet: true` on the tool call or `MINDVAULT_ALLOW_MAINNET=1`. Read-only tools are unrestricted. See [`mainnet-deployment-checklist.md`](./mainnet-deployment-checklist.md#mcp-mainnet-guardrails). +### MCP read-only mode + +`MINDVAULT_READ_ONLY=1` turns the MCP server into a catalog browser. It is set on the server process and cannot be lifted by a tool argument — unlike `confirmMainnet`, which an agent supplies from inside the very call you wanted to prevent. + +Two things change together. `tools/list` advertises only tools whose definition declares `readOnlyHint: true`, so an agent does not plan around a tool it cannot use; and the dispatcher refuses every other tool, so a client working from a cached tool list — or guessing a name — is still stopped. The refusal names the tools that remain available. + +Read-only classification comes from the `readOnlyHint` annotation each tool already advertises, so a tool added later is covered by whatever it declares about itself. + +``` +MINDVAULT_READ_ONLY=1 +``` + +Browsing, search, preview, registry lookups, wallet info, purchase history, and the diagnostic tools stay available. Everything that mutates state, spends funds, or changes stored credentials is refused — including dry runs, which are exempt from the paid-operation policy below but not from this one: read-only mode is about what the server is for, not about what a call costs. + +### MCP paid-operation confirmation + +`MINDVAULT_CONFIRM_PAID_OPERATIONS` requires an explicit `confirmPaid: true` before a tool spends from the agent wallet. It is independent of the network (unlike the mainnet guardrail, which never fires on testnet) and of the amount (unlike `MINDVAULT_MAX_AUTO_PAY_USDC`, which stops one large purchase but not a hundred small ones). + +| Value | Effect | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `off` | Default. No confirmation required. The mainnet guardrail and the auto-pay ceiling still apply. | +| `usdc` | `mindvault_publish` and `mindvault_buy` require `confirmPaid: true`. | +| `all` | Additionally `mindvault_register_onchain`, `mindvault_update_metadata`, `mindvault_set_price`, `mindvault_transfer_ownership`, and `mindvault_set_listed`. | + +``` +MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc +``` + +Dry runs (`dryRun: true` on publish or buy) are never gated — they submit no payment, and requiring confirmation would mean confirming a spend in order to find out what the spend would be. `mindvault_setup_wallet` is not gated either: account creation runs through the sponsored-account service, so the agent's own wallet funds nothing. + +The policies compose rather than replace one another. A mainnet buy above the auto-pay ceiling with `MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc` must satisfy all three guardrails; `confirmPaid` does not stand in for `confirmMainnet`, or the reverse. + +An unrecognized value (`true`, `1`, `yes`) raises an error on the first paid call rather than silently falling back to `off`. A typo in a safety setting that quietly disables it is worse than one that fails loudly. + --- ## Mainnet-Specific Notes diff --git a/docs/mcp-tool-reference.md b/docs/mcp-tool-reference.md index 12bc6aa8..d4713a33 100644 --- a/docs/mcp-tool-reference.md +++ b/docs/mcp-tool-reference.md @@ -15,7 +15,7 @@ For structured JSON results (`structuredContent` + `outputSchema`) see For client installation and configuration see [mcp-client-configs.md](mcp-client-configs.md). -**35 tools** as of last generation. +**37 tools** as of last generation. --- @@ -44,11 +44,12 @@ For client installation and configuration see ## Publishing & Buying -| Tool | Description | Structured | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -| `mindvault_register` | Register as a publisher using the agent wallet. The API key is persisted to ~/.mindvault/state.json (mode 0600, key not shown in output) and reloaded on restart so mindvault_publish works across sessions. | text only | -| `mindvault_publish` | Publish a link resource to the MindVault catalog. The resource undergoes AI verification (agent wallet pays ~$0.10 USDC via x402) and is automatically registered on-chain if verified. Returns resource ID, access URL, verification result, and on-chain registration status. Pass dryRun: true to validate inputs without submitting payment. | yes | -| `mindvault_buy` | Pay USDC via x402 and access a resource. Payments above MINDVAULT_MAX_AUTO_PAY_USDC (10 USDC by default) require maxAutoPayUsdc set to at least the resource price. On mainnet, pass confirmMainnet: true (or set MINDVAULT_ALLOW_MAINNET=1). Pass dryRun: true to validate the resource and show intended payment flow without submitting payment. | yes | +| Tool | Description | Structured | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `mindvault_register` | Register as a publisher using the agent wallet. The API key is persisted to ~/.mindvault/state.json (mode 0600, key not shown in output) and reloaded on restart so mindvault_publish works across sessions. | text only | +| `mindvault_publish` | Publish a link resource to the MindVault catalog. The resource undergoes AI verification (agent wallet pays ~$0.10 USDC via x402) and is automatically registered on-chain if verified. Returns resource ID, access URL, verification result, and on-chain registration status. Pass dryRun: true to validate inputs without submitting payment. | yes | +| `mindvault_buy` | Pay USDC via x402 and access a resource. Payments above MINDVAULT_MAX_AUTO_PAY_USDC (10 USDC by default) require maxAutoPayUsdc set to at least the resource price. On mainnet, pass confirmMainnet: true (or set MINDVAULT_ALLOW_MAINNET=1). Pass dryRun: true to validate the resource and show intended payment flow without submitting payment. | yes | +| `mindvault_publish_status` | Poll a published resource's verification and on-chain sync status. Returns verificationStatus (pending, verified, rejected, skipped), listed, onchainStatus, onchainTxHash, and optional verification details. Pass wait: true to poll until verification settles or timeoutMs elapses. Deterministic errors for missing resourceId and 404s. | yes | ## On-chain Management @@ -75,9 +76,10 @@ For client installation and configuration see ## Receipts -| Tool | Description | Structured | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | -| `mindvault_export_receipts` | Export receipts for resources this agent has purchased as a schema-versioned document (JSON, or RFC 4180 CSV in the envelope's csv field). Filter by resource, network, and date range. Reports a row count and the summed USDC total, so an agent can reconcile spend without re-reading each purchase. | yes | +| Tool | Description | Structured | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| `mindvault_export_receipts` | Export receipts for resources this agent has purchased as a schema-versioned document (JSON, or RFC 4180 CSV in the envelope's csv field). Filter by resource, network, and date range. Reports a row count and the summed USDC total, so an agent can reconcile spend without re-reading each purchase. | yes | +| `mindvault_purchase_history` | List locally persisted purchase receipts from successful mindvault_buy calls (~/.mindvault/purchases.json). Read-only. Optional filters: resourceId and network (exact match, e.g. stellar:testnet). Returns count + purchases (newest first), or an empty list when nothing matches. | yes | ## State Management @@ -104,12 +106,6 @@ For client installation and configuration see | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | `mindvault_recover_catalog_cache` | Attempt a catalog stale-cache recovery: requests the MCP to refresh or re-fetch catalog index data and provides recovery guidance. Useful when browse results appear stale. | yes | -## Other - -| Tool | Description | -| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mindvault_recover_catalog_cache` | Attempt a catalog stale-cache recovery: requests the MCP to refresh or re-fetch catalog index data and provides recovery guidance. Useful when browse results appear stale. | - --- -_This file was generated from `mcp/src/tools.ts` — 35 tools._ +_This file was generated from `mcp/src/tools.ts` — 37 tools._ diff --git a/mcp/.env.example b/mcp/.env.example index dc3fa575..5ba63b45 100644 --- a/mcp/.env.example +++ b/mcp/.env.example @@ -17,6 +17,17 @@ SPONSORED_ACCOUNT_URL=https://stellar-sponsored-agent-account.onrender.com # Disabled unless set to 1/true/yes/on. Query with the mindvault_metrics tool. # MINDVAULT_METRICS=1 +# Read-only mode: restrict this server to catalog browsing. Only tools that +# declare readOnlyHint are advertised in tools/list, and every other tool is +# refused at dispatch. Set on the process — no tool argument can override it. +# MINDVAULT_READ_ONLY=1 + +# Require explicit confirmPaid: true before a tool spends from the agent wallet, +# regardless of network or amount. off (default) | usdc (publish, buy) | all +# (also the on-chain mutations, which spend network fees). Dry runs are never +# gated. An unrecognized value is an error, not a silent fallback. +# MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc + # Custom User-Agent sent on every outbound HTTP request (MindVault API, Horizon, # Soroban RPC, sponsored-account service). Useful when you want to identify a # specific agent or deployment in server logs. Defaults to "mindvault-mcp/1.0.0". diff --git a/mcp/src/__snapshots__/toolMetadata.test.ts.snap b/mcp/src/__snapshots__/toolMetadata.test.ts.snap index 72996326..0bf71417 100644 --- a/mcp/src/__snapshots__/toolMetadata.test.ts.snap +++ b/mcp/src/__snapshots__/toolMetadata.test.ts.snap @@ -37,6 +37,8 @@ exports[`MCP tool metadata > exposes the expected tool surface 1`] = ` "mindvault_rotate_publisher_key", "mindvault_verify_install", "mindvault_recover_catalog_cache", + "mindvault_publish_status", + "mindvault_purchase_history", ] `; @@ -47,6 +49,10 @@ exports[`MCP tool metadata > mindvault_publish inputSchema 1`] = ` "description": "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", "type": "boolean", }, + "confirmPaid": { + "description": "Required when the server runs with MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc or =all. Explicitly confirm that this call may spend from the agent wallet. Ignored when the policy is off (the default) and on dry runs.", + "type": "boolean", + }, "description": { "description": "Optional detailed description of the resource content (max 2048 characters).", "examples": [ diff --git a/mcp/src/__snapshots__/toolSchemaSnapshots.test.ts.snap b/mcp/src/__snapshots__/toolSchemaSnapshots.test.ts.snap index 98c9d7a3..4a6f47dc 100644 --- a/mcp/src/__snapshots__/toolSchemaSnapshots.test.ts.snap +++ b/mcp/src/__snapshots__/toolSchemaSnapshots.test.ts.snap @@ -263,6 +263,10 @@ exports[`tool schema snapshots > mindvault_buy > input schema 1`] = ` "description": "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", "type": "boolean", }, + "confirmPaid": { + "description": "Required when the server runs with MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc or =all. Explicitly confirm that this call may spend from the agent wallet. Ignored when the policy is off (the default) and on dry runs.", + "type": "boolean", + }, "dryRun": { "description": "Optional dry-run flag. When true, validates the resource ID and shows intended network, endpoint, and required wallet state without submitting payment.", "type": "boolean", @@ -1040,6 +1044,10 @@ exports[`tool schema snapshots > mindvault_publish > input schema 1`] = ` "description": "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", "type": "boolean", }, + "confirmPaid": { + "description": "Required when the server runs with MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc or =all. Explicitly confirm that this call may spend from the agent wallet. Ignored when the policy is off (the default) and on dry runs.", + "type": "boolean", + }, "description": { "description": "Optional detailed description of the resource content (max 2048 characters).", "examples": [ @@ -1172,6 +1180,199 @@ exports[`tool schema snapshots > mindvault_publish > output schema 1`] = ` } `; +exports[`tool schema snapshots > mindvault_publish_status > annotations 1`] = ` +{ + "destructiveHint": false, + "idempotentHint": true, + "readOnlyHint": true, + "title": "Publish Status", +} +`; + +exports[`tool schema snapshots > mindvault_publish_status > input schema 1`] = ` +{ + "properties": { + "intervalMs": { + "description": "Delay between polls in milliseconds when wait is true (default 2000, min 200).", + "examples": [ + 1000, + 2000, + 5000, + ], + "type": "number", + }, + "resourceId": { + "description": "The resource ID from mindvault_publish (or browse/search). Example: 'cm7x8y9z'", + "examples": [ + "cm7x8y9z", + "res-001", + "swcn98besxpp6t1u8e77fqz3", + ], + "type": "string", + }, + "timeoutMs": { + "description": "Max wait time in milliseconds when wait is true (default 60000, max 300000).", + "examples": [ + 30000, + 60000, + 120000, + ], + "type": "number", + }, + "wait": { + "description": "When true, poll until verificationStatus is verified, rejected, or skipped (or until timeoutMs). Default false (single fetch).", + "type": "boolean", + }, + }, + "required": [ + "resourceId", + ], + "type": "object", +} +`; + +exports[`tool schema snapshots > mindvault_publish_status > output schema 1`] = ` +{ + "properties": { + "accessUrl": { + "type": [ + "string", + "null", + ], + }, + "attempts": { + "type": "integer", + }, + "contentHash": { + "type": [ + "string", + "null", + ], + }, + "listed": { + "type": [ + "boolean", + "null", + ], + }, + "message": { + "type": "string", + }, + "onchainStatus": { + "type": [ + "string", + "null", + ], + }, + "onchainTxHash": { + "type": [ + "string", + "null", + ], + }, + "polled": { + "type": "boolean", + }, + "resourceId": { + "type": "string", + }, + "settled": { + "type": "boolean", + }, + "timedOut": { + "type": "boolean", + }, + "title": { + "type": [ + "string", + "null", + ], + }, + "verification": { + "type": [ + "object", + "null", + ], + }, + "verificationStatus": { + "type": "string", + }, + }, + "required": [ + "resourceId", + "title", + "verificationStatus", + "listed", + "onchainStatus", + "onchainTxHash", + "contentHash", + "accessUrl", + "verification", + "polled", + "attempts", + "settled", + "timedOut", + "message", + ], + "type": "object", +} +`; + +exports[`tool schema snapshots > mindvault_purchase_history > annotations 1`] = ` +{ + "destructiveHint": false, + "idempotentHint": true, + "readOnlyHint": true, + "title": "Purchase History", +} +`; + +exports[`tool schema snapshots > mindvault_purchase_history > input schema 1`] = ` +{ + "properties": { + "network": { + "description": "Optional. Only return receipts recorded on this x402 network id. Example: 'stellar:testnet'", + "examples": [ + "stellar:testnet", + "stellar:pubnet", + ], + "type": "string", + }, + "resourceId": { + "description": "Optional. Only return receipts for this resource id. Example: 'cm7x8y9z'", + "examples": [ + "cm7x8y9z", + "res-001", + ], + "type": "string", + }, + }, + "required": [], + "type": "object", +} +`; + +exports[`tool schema snapshots > mindvault_purchase_history > output schema 1`] = ` +{ + "properties": { + "count": { + "type": "integer", + }, + "message": { + "type": "string", + }, + "purchases": { + "type": "array", + }, + }, + "required": [ + "count", + "purchases", + ], + "type": "object", +} +`; + exports[`tool schema snapshots > mindvault_recover_catalog_cache > annotations 1`] = ` { "destructiveHint": false, @@ -1276,6 +1477,10 @@ exports[`tool schema snapshots > mindvault_register_onchain > input schema 1`] = "description": "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", "type": "boolean", }, + "confirmPaid": { + "description": "Required when the server runs with MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc or =all. Explicitly confirm that this call may spend from the agent wallet. Ignored when the policy is off (the default) and on dry runs.", + "type": "boolean", + }, "resourceId": { "description": "The resource ID to register on-chain (from mindvault_publish output). Must be verified and not already registered. Example: 'cm7x8y9z'", "examples": [ @@ -1616,6 +1821,14 @@ exports[`tool schema snapshots > mindvault_reset > input schema 1`] = ` ], "type": "boolean", }, + "confirm": { + "description": "Required to actually clear anything. Omitted or false returns a warning describing what would be removed and performs no deletion. Example: true clears the credentials.", + "examples": [ + true, + false, + ], + "type": "boolean", + }, "confirmMainnet": { "description": "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", "type": "boolean", @@ -1904,6 +2117,10 @@ exports[`tool schema snapshots > mindvault_set_listed > input schema 1`] = ` "description": "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation on the public Stellar network.", "type": "boolean", }, + "confirmPaid": { + "description": "Required when the server runs with MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc or =all. Explicitly confirm that this call may spend from the agent wallet. Ignored when the policy is off (the default) and on dry runs.", + "type": "boolean", + }, "listed": { "description": "Set to true to list/relist the resource in the catalog, or false to delist it.", "examples": [ @@ -2034,6 +2251,10 @@ exports[`tool schema snapshots > mindvault_set_price > input schema 1`] = ` "description": "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", "type": "boolean", }, + "confirmPaid": { + "description": "Required when the server runs with MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc or =all. Explicitly confirm that this call may spend from the agent wallet. Ignored when the policy is off (the default) and on dry runs.", + "type": "boolean", + }, "price": { "description": "New price in USDC as a decimal string. Example: '10.00' charges 10 USDC per access.", "examples": [ @@ -2271,6 +2492,10 @@ exports[`tool schema snapshots > mindvault_transfer_ownership > input schema 1`] "description": "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", "type": "boolean", }, + "confirmPaid": { + "description": "Required when the server runs with MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc or =all. Explicitly confirm that this call may spend from the agent wallet. Ignored when the policy is off (the default) and on dry runs.", + "type": "boolean", + }, "newCreator": { "description": "The Stellar public key (G… , 56 chars) of the new resource owner.", "examples": [ @@ -2484,6 +2709,10 @@ exports[`tool schema snapshots > mindvault_update_metadata > input schema 1`] = "description": "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", "type": "boolean", }, + "confirmPaid": { + "description": "Required when the server runs with MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc or =all. Explicitly confirm that this call may spend from the agent wallet. Ignored when the policy is off (the default) and on dry runs.", + "type": "boolean", + }, "metadata": { "description": "The new metadata pointer string (max 512 characters). Must start with ipfs://, ar://, http(s)://, sha256:, sha-256:, or 0x. Example: 'ipfs://QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco'", "examples": [ @@ -2756,6 +2985,8 @@ exports[`tool schema snapshots > the covered tool names are pinned 1`] = ` "mindvault_network_profile", "mindvault_preview", "mindvault_publish", + "mindvault_publish_status", + "mindvault_purchase_history", "mindvault_recover_catalog_cache", "mindvault_register", "mindvault_register_onchain", diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 4be0bfe1..bcf0d8df 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -39,6 +39,8 @@ import { formatMainnetDiagnostics, mainnetAllowedFromEnv, } from "./mainnetGuardrails.js"; +import { assertPaidOperationConfirmed } from "./paidOperations.js"; +import { assertToolAllowedInReadOnlyMode } from "./readOnlyMode.js"; import { createMetricsRecorder, measureTool, @@ -57,10 +59,9 @@ import { } from "./mock.js"; import { purchaseHistoryTool, recordPurchase } from "./purchaseHistory.js"; import { Mutex } from "./mutex.js"; -import { EXTRA_OUTPUT_SCHEMAS } from "./outputSchemas.js"; import { exportReceiptsTool } from "./receipts.js"; import { normalizeToolResult, outcomeText, type ToolOutcome } from "./toolResult.js"; -import { TOOL_DEFINITIONS, type ToolDefinition } from "./tools.js"; +import { advertisedTools, hasOutputSchema } from "./toolSurface.js"; import { dryRunPublish, dryRunBuy } from "./dryRun.js"; import { initAuditLogging } from "./auditLog.js"; import { REGISTRY_LIST_DEFAULT_LIMIT, REGISTRY_LIST_DEFAULT_START } from "./registryPagination.js"; @@ -70,6 +71,7 @@ import { optionalString, requiredString, TOOL_ARGUMENT_SPECS, + TOOLS_WITHOUT_ARG_VALIDATION, UnknownToolError, validateToolArgs, type ValidatedArgs, @@ -146,7 +148,6 @@ import { applyCatalogSort, applyClientCatalogFilters, buildCatalogQueryString, - catalogFilterInputProperties, describeCatalogFilters, parseCatalogFilters, type CatalogFilters, @@ -2497,13 +2498,10 @@ function toolMetrics(reset: boolean): string { return JSON.stringify(snapshot, null, 2); } -const TOOLS_WITHOUT_ARG_VALIDATION = new Set([ - "mindvault_publish_status", - "mindvault_purchase_history", -]); +const SELF_VALIDATING_TOOLS = new Set(TOOLS_WITHOUT_ARG_VALIDATION); function isDispatchableTool(name: string): boolean { - return name in TOOL_ARGUMENT_SPECS || TOOLS_WITHOUT_ARG_VALIDATION.has(name); + return name in TOOL_ARGUMENT_SPECS || SELF_VALIDATING_TOOLS.has(name); } const STATE_MUTATING_TOOLS = new Set([ @@ -2536,6 +2534,12 @@ async function dispatchToolOutcome( throw new UnknownToolError(name); } + // Read-only mode is checked before argument validation (#593): when the + // server cannot run this tool at all, a malformed-arguments error would be + // a misleading thing to report, and the refusal does not depend on the + // arguments being well-formed. + assertToolAllowedInReadOnlyMode(name, process.env); + const rawRecord = typeof rawArgs === "object" && rawArgs !== null && !Array.isArray(rawArgs) ? (rawArgs as Record) @@ -2550,6 +2554,17 @@ async function dispatchToolOutcome( assertMainnetMutationAllowed(NETWORK, name, rawRecord); + // Network-independent spend confirmation (#594). Distinct from the mainnet + // guardrail above (which only fires on pubnet) and from the auto-pay ceiling + // in buy() (which only fires above an amount); a call may have to satisfy + // all three. Off unless MINDVAULT_CONFIRM_PAID_OPERATIONS says otherwise. + assertPaidOperationConfirmed({ + toolName: name, + args: rawRecord, + dryRun: isDryRunCall, + env: process.env, + }); + if (API_MUTATION_TOOLS.has(name) && !isDryRunCall) { await assertApiReachableFor(name); } @@ -2681,55 +2696,6 @@ export async function dispatchTool( return outcomeText(await dispatchToolOutcome(name, rawArgs, onProgress)); } -function toolDefinition(name: string): ToolDefinition { - const definition = TOOL_DEFINITIONS.find((tool) => tool.name === name); - if (!definition) throw new Error(`No tool definition for ${name} in TOOL_DEFINITIONS.`); - return definition; -} - -const EXTRA_TOOL_ANNOTATIONS: Record< - string, - { title: string; readOnlyHint: boolean; destructiveHint: boolean; idempotentHint: boolean } -> = { - mindvault_publish_status: { - title: "Publish Status", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - }, - mindvault_purchase_history: { - title: "Purchase History", - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - }, -}; - -function toolAnnotations(name: string): { - title: string; - readOnlyHint: boolean; - destructiveHint: boolean; - idempotentHint: boolean; -} { - if (name in EXTRA_TOOL_ANNOTATIONS) return EXTRA_TOOL_ANNOTATIONS[name]; - const { annotations } = toolDefinition(name); - return { - title: annotations.title, - readOnlyHint: annotations.readOnlyHint, - destructiveHint: annotations.destructiveHint, - idempotentHint: annotations.idempotentHint, - }; -} - -function outputSchemaFor(name: string): Record | undefined { - if (name in EXTRA_OUTPUT_SCHEMAS) return EXTRA_OUTPUT_SCHEMAS[name]; - return TOOL_DEFINITIONS.find((tool) => tool.name === name)?.outputSchema; -} - -function hasOutputSchema(name: string): boolean { - return outputSchemaFor(name) !== undefined; -} - /** Test helper: wrap a handler outcome the same way CallTool does. */ export function normalizeToolResultForTest(name: string, outcome: ToolOutcome) { return normalizeToolResult(name, outcome, hasOutputSchema); @@ -2754,502 +2720,16 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => ({ contents: [await readCatalogResource(request.params.uri)], })); -server.setRequestHandler(ListToolsRequestSchema, async () => { - const advertisedTools = [ - { - name: "mindvault_setup_wallet", - description: - "Create a Stellar wallet using the sponsored account protocol. Optionally pass a profile name to create the wallet under a named profile (e.g. testnet, mainnet, publisher, buyer) and make it active; defaults to the active profile. The wallet (public key + secret key) is persisted to ~/.mindvault/state.json (mode 0600) and reloaded automatically on restart.", - inputSchema: { - type: "object", - properties: { - profile: { - type: "string", - description: - "Optional profile name to create/switch to. Use letters, digits, dot, dash, or underscore (1–64 chars). Examples: 'testnet', 'mainnet-publisher', 'buyer.alice'", - examples: ["testnet", "mainnet-publisher", "buyer.alice"], - }, - confirmMainnet: { - type: "boolean", - description: - "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", - }, - }, - required: [], - }, - }, - { - name: "mindvault_wallet_info", - description: - "Check the active profile name, its agent wallet address, USDC balance, and whether it is registered as a publisher.", - inputSchema: { type: "object", properties: {}, required: [] }, - }, - { - name: "mindvault_use_profile", - description: - "Switch the active wallet profile, creating it if it does not exist. Profiles let one agent keep separate identities (e.g. testnet vs mainnet, publisher vs buyer); each has its own wallet and publisher API key. Subsequent tools operate on the active profile.", - inputSchema: { - type: "object", - properties: { - name: { - type: "string", - description: - "Profile name to make active. Use letters, digits, dot, dash, or underscore (1–64 chars). Examples: 'mainnet', 'testnet-buyer', 'publisher.bob'", - examples: ["mainnet", "testnet-buyer", "publisher.bob"], - }, - }, - required: ["name"], - }, - }, - { - name: "mindvault_list_profiles", - description: - "List all named wallet profiles, marking the active one and showing each profile's wallet address and whether it is registered as a publisher. Secret keys are never shown.", - inputSchema: { type: "object", properties: {}, required: [] }, - }, - { - name: "mindvault_browse", - description: - "List resources in the MindVault catalog with the same optional filters as mindvault_search and GET /resources: keyword, price range, verification status, resource type, owner, sort, pagination, tags, and listed state.", - inputSchema: { - type: "object", - properties: { ...catalogFilterInputProperties }, - required: [], - }, - }, - { - name: "mindvault_search", - description: - "Search the MindVault catalog by keyword and optional filters for price, resource type, verification status, owner, sort, pagination, tags, and listed state. Uses server-side filtering where supported and returns compact resource summaries.", - inputSchema: { - type: "object", - properties: { ...catalogFilterInputProperties }, - required: [], - }, - }, - { - name: "mindvault_preview", - description: - "Get details and price for a specific resource before purchasing. Returns title, description, price, type, verification status, and access URL.", - inputSchema: { - type: "object", - properties: { - resourceId: { - type: "string", - description: - "The unique resource identifier from mindvault_browse or mindvault_search. Example: 'cm7x8y9z'", - examples: ["cm7x8y9z", "res-001", "ckx9j2h3f"], - }, - }, - required: ["resourceId"], - }, - }, - { - name: "mindvault_register", - description: - "Register as a publisher using the agent wallet. The API key is persisted to ~/.mindvault/state.json (mode 0600, key not shown in output) and reloaded on restart so mindvault_publish works across sessions.", - inputSchema: { - type: "object", - properties: { - name: { type: "string" }, - email: { type: "string" }, - walletAddress: { type: "string" }, - confirmMainnet: { - type: "boolean", - description: - "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", - }, - }, - required: ["name", "email"], - }, - }, - { - name: "mindvault_publish", - description: - "Publish a link resource to the MindVault catalog. The resource undergoes AI verification (agent wallet pays ~$0.10 USDC via x402) and is automatically registered on-chain if verified. Returns resource ID, access URL, verification result, and on-chain registration status.", - inputSchema: { - type: "object", - properties: { - title: { type: "string" }, - description: { type: "string" }, - price: { type: "string" }, - externalUrl: { type: "string" }, - confirmMainnet: { - type: "boolean", - description: - "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", - }, - }, - required: ["title", "price", "externalUrl"], - }, - }, - { - name: "mindvault_publish_status", - description: - "Poll a published resource's verification and on-chain sync status. Returns verificationStatus (pending, verified, rejected, skipped), listed, onchainStatus, onchainTxHash, and optional verification details. Pass wait: true to poll until verification settles or timeoutMs elapses. Deterministic errors for missing resourceId and 404s.", - inputSchema: { - type: "object", - properties: { - resourceId: { - type: "string", - description: - "The resource ID from mindvault_publish (or browse/search). Example: 'cm7x8y9z'", - examples: ["cm7x8y9z", "res-001", "swcn98besxpp6t1u8e77fqz3"], - }, - wait: { - type: "boolean", - description: - "When true, poll until verificationStatus is verified, rejected, or skipped (or until timeoutMs). Default false (single fetch).", - }, - timeoutMs: { - type: "number", - description: - "Max wait time in milliseconds when wait is true (default 60000, max 300000).", - examples: [30000, 60000, 120000], - }, - intervalMs: { - type: "number", - description: - "Delay between polls in milliseconds when wait is true (default 2000, min 200).", - examples: [1000, 2000, 5000], - }, - }, - required: ["resourceId"], - }, - }, - { - name: "mindvault_buy", - description: - "Pay USDC via x402 and access a resource. On mainnet, pass confirmMainnet: true (or set MINDVAULT_ALLOW_MAINNET=1).", - inputSchema: { - type: "object", - properties: { - resourceId: { type: "string" }, - confirmMainnet: { - type: "boolean", - description: - "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", - }, - }, - required: ["resourceId"], - }, - }, - { - name: "mindvault_purchase_history", - description: - "List locally persisted purchase receipts from successful mindvault_buy calls (~/.mindvault/purchases.json). Read-only. Optional filters: resourceId and network (exact match, e.g. stellar:testnet). Returns count + purchases (newest first), or an empty list when nothing matches.", - inputSchema: { - type: "object", - properties: { - resourceId: { - type: "string", - description: "Optional. Only return receipts for this resource id. Example: 'cm7x8y9z'", - examples: ["cm7x8y9z", "res-001"], - }, - network: { - type: "string", - description: - "Optional. Only return receipts recorded on this x402 network id. Example: 'stellar:testnet'", - examples: ["stellar:testnet", "stellar:pubnet"], - }, - }, - required: [], - }, - }, - toolDefinition("mindvault_export_receipts"), - { - name: "mindvault_register_onchain", - description: - "Register an already-published, verified resource on the vault registry contract. Use this to retry on-chain registration after mindvault_publish reports the on-chain step failed. Prepares the unsigned transaction, signs it with the agent wallet (which must be the resource creator), submits it, and returns the registry status and on-chain tx hash.", - inputSchema: { - type: "object", - properties: { - resourceId: { - type: "string", - description: - "The resource ID to register on-chain (from mindvault_publish output). Must be verified and not already registered. Example: 'cm7x8y9z'", - examples: ["cm7x8y9z", "res-001", "ckx9j2h3f"], - }, - confirmMainnet: { - type: "boolean", - description: - "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", - }, - }, - required: ["resourceId"], - }, - }, - { - name: "mindvault_agent_status", - description: - "Check the verification agent's earnings and activity. Returns total verifications, pass/fail counts, total USDC earned, average confidence score, and recent verification history with resource titles.", - inputSchema: { type: "object", properties: {}, required: [] }, - }, - { - name: "mindvault_registry_info", - description: - "Return the on-chain vault-registry contract ID, network passphrase, RPC URL, and the resource fields available for direct Soroban queries. Use this to verify ownership, price, and listing state directly from Stellar without trusting the MindVault API.", - inputSchema: { type: "object", properties: {}, required: [] }, - }, - { - name: "mindvault_network_profile", - description: - "Report current Stellar/x402 network configuration (testnet/mainnet), RPC URLs, registry contract ID, and warnings for custom overrides. Use this to verify which network the MCP is connected to and diagnose configuration issues.", - inputSchema: { type: "object", properties: {}, required: [] }, - }, - { - name: "mindvault_check_bindings", - description: - "Verify the installed registry-client bindings match the deployed vault-registry contract interface. Reports a match, or a warning listing the drifting methods with the contract ID, network, client version, and a recommended fix (redeploy the contract or regenerate bindings). Useful after a contract redeploy or client upgrade.", - inputSchema: { type: "object", properties: {}, required: [] }, - }, - { - name: "mindvault_check_consistency", - description: - "Compare a resource from the API catalog with the same resource in the vault-registry contract. Reports matching fields, mismatches, missing API records, and missing on-chain records. Useful for detecting synchronization issues between the API and on-chain registry.", - inputSchema: { - type: "object", - properties: { - resourceId: { - type: "string", - description: "The resource ID to compare between API and on-chain registry.", - }, - expectedMetadataHash: { - type: "string", - description: - "Optional. The canonical SHA-256 digest (sha256:) of the off-chain content the agent expects to be anchored on-chain. When supplied, it is compared against the contentHash in the on-chain metadata pointer.", - examples: ["sha256:1f09d48cb617cd04c123454e2b1b6d51acd66378f2c4b79d5ac09e9d3b123456"], - }, - }, - required: ["resourceId"], - }, - }, - { - name: "mindvault_registry_lookup", - description: - "Look up a resource directly from the on-chain vault registry by its ID. Returns creator wallet address, price (USDC), metadata (title/description), listed state, tags, contract ID, and network. Data comes from Stellar/Soroban, not the MindVault API. Returns an actionable message when the resource is not registered on-chain.", - inputSchema: { - type: "object", - properties: { - resourceId: { - type: "string", - description: - "The resource ID to look up on-chain. Must be a registered resource. Example: 'cm7x8y9z'", - examples: ["cm7x8y9z", "res-001", "ckx9j2h3f"], - }, - }, - required: ["resourceId"], - }, - }, - { - name: "mindvault_registry_list", - description: - "List resources registered in the on-chain vault-registry contract with pagination (Soroban list). Returns compact summaries directly from Stellar, not the MindVault API catalog. Use start/limit to page through insertion order; limit is capped at 20 to match the contract. Empty pages return a clear message and next-step hint.", - inputSchema: { - type: "object", - properties: { - start: { - type: "integer", - minimum: 0, - description: - "0-based index into the on-chain registry (default 0). Example: 0 for the first page, 20 for the second page when limit is 20.", - examples: [0, 20], - }, - limit: { - type: "integer", - minimum: 1, - maximum: 20, - description: - "Page size (1–20, default 20). The contract silently caps higher values at 20.", - examples: [20, 10], - }, - }, - required: [], - }, - }, - { - name: "mindvault_tx_status", - description: - "Look up the status of a Stellar transaction by hash via Soroban RPC. Returns SUCCESS, FAILED, or NOT_FOUND along with ledger number, close time, application order, and XDR envelopes. Useful for debugging on-chain registration failures.", - inputSchema: { - type: "object", - properties: { - txHash: { - type: "string", - description: - "The 64-character hex transaction hash from Stellar. Example: 'abc123def456...' (from mindvault_register_onchain or mindvault_publish output).", - examples: [ - "abc123def456789012345678901234567890123456789012345678901234", - "f47ac10b58cc4372a5670e02b2c3d479c3e5d0a1b2c3d4e5f6a7b8c9d0e1f2a3", - ], - }, - }, - required: ["txHash"], - }, - }, - { - name: "mindvault_reset", - description: - "Clear credentials from memory and disk (~/.mindvault/state.json). Destructive and irreversible, so it is two-step: without confirm=true the call changes nothing and returns a warning listing exactly what would be removed; call again with confirm=true to perform it. By default only the active profile is cleared; pass all=true to remove every profile and delete the state file. After a confirmed reset, run mindvault_setup_wallet and mindvault_register again.", - inputSchema: { - type: "object", - properties: { - confirm: { - type: "boolean", - description: - "Required to actually clear anything. Omitted or false returns a warning describing what would be removed and performs no deletion. Example: true clears the credentials.", - examples: [true, false], - }, - all: { - type: "boolean", - description: - "Clear every profile and delete the state file (default: false clears active profile only). Example: true removes all profiles.", - examples: [true, false], - }, - confirmMainnet: { - type: "boolean", - description: - "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", - }, - }, - required: [], - }, - }, - { - name: "mindvault_backup_state", - description: - "Export an encrypted backup of ~/.mindvault/state.json for moving agent environments. Requires a passphrase (min 8 chars). Output is a self-contained ciphertext blob — wallet secret keys and API keys never appear in plaintext. Restore with mindvault_restore_state using the same passphrase. Does not change reset behavior.", - inputSchema: { - type: "object", - properties: { - passphrase: { - type: "string", - description: - "Passphrase used to encrypt the backup (min 8 characters). Keep it offline.", - }, - }, - required: ["passphrase"], - }, - }, - { - name: "mindvault_restore_state", - description: - "Restore ~/.mindvault/state.json from an encrypted backup produced by mindvault_backup_state. Validates integrity (wrong passphrase or tampered data fails before any write). Replaces in-memory profiles and re-persists to disk (mode 0600). Existing reset behavior is unchanged.", - inputSchema: { - type: "object", - properties: { - blob: { - type: "string", - description: "Encrypted backup blob from mindvault_backup_state (v1:… format).", - }, - passphrase: { - type: "string", - description: "Passphrase used when the backup was created (min 8 characters).", - }, - }, - required: ["blob", "passphrase"], - }, - }, - { - name: "mindvault_metrics", - description: - "Return opt-in tool-level metrics: per-tool call/error counts and durations, plus payment attempt/failure totals. Enable by setting MINDVAULT_METRICS=1 on the server. Output contains only tool names, counts, and durations — never arguments, wallets, or API keys. Pass reset=true to clear counters after reading.", - inputSchema: { - type: "object", - properties: { - reset: { - type: "boolean", - description: - "Clear all counters after returning the current snapshot (default: false leaves counters intact). Example: true resets metrics after reading.", - examples: [true, false], - }, - }, - required: [], - }, - }, - { - name: "mindvault_check_state_permissions", - description: - "Verify the state file (~/.mindvault/state.json) has safe permissions (mode 0600). Warns when the file is world-readable or group-readable, which would expose wallet secret keys and API keys to other system users. Safe by default; run after any manual file operations or environment migration.", - inputSchema: { type: "object", properties: {}, required: [] }, - }, - { - name: "mindvault_registry_health", - description: - "Check the health of every dependency the MCP server relies on: MindVault API, Horizon, Soroban RPC, vault-registry contract, and x402 network alignment. Returns per-dependency status (ok/error) with actionable failure messages. Does not leak secrets or environment variables.", - inputSchema: { type: "object", properties: {}, required: [] }, - }, - { - name: "mindvault_import_wallet", - description: - "Import an existing Stellar wallet by providing a secret key (or reading MINDVAULT_AGENT_SECRET from the environment). Validates the key, optionally persists it to the active profile (or a named profile), and never logs the secret. Use this to restore a wallet from backup or connect to an existing identity.", - inputSchema: { - type: "object", - properties: { - secretKey: { - type: "string", - description: - "Stellar secret key (S… , 56 chars) to import. If omitted, reads from MINDVAULT_AGENT_SECRET env var.", - examples: ["SCHZPJ..."], - }, - profile: { - type: "string", - description: "Optional profile name to import into. Defaults to the active profile.", - examples: ["testnet", "mainnet-publisher"], - }, - persist: { - type: "boolean", - description: - "When true (default), save the imported wallet to the state file. When false, validate only and return the public key without writing to disk.", - examples: [true, false], - }, - confirmMainnet: { - type: "boolean", - description: - "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation on the public Stellar network.", - }, - }, - required: [], - }, - }, - { - name: "mindvault_rotate_publisher_key", - description: - "Rotate the publisher API key for the active profile. Calls the MindVault server rotation endpoint (POST /publishers/rotate-key), stores the new key in the state file, and returns the updated publisher ID. The old key is invalidated server-side. Requires an existing registration (mindvault_register).", - inputSchema: { - type: "object", - properties: { - profile: { - type: "string", - description: - "Optional profile name to rotate the key for. Defaults to the active profile.", - examples: ["testnet", "mainnet-publisher"], - }, - confirmMainnet: { - type: "boolean", - description: - "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation on the public Stellar network.", - }, - }, - required: [], - }, - }, - { - name: "mindvault_verify_install", - description: - "Verify the MindVault MCP server is installed and configured correctly. Checks Node.js version (>=20), network settings, URL variables, vault-registry contract ID, and warns about plaintext secrets in the environment. No network calls are made — all checks are local. Run this first when setting up a new agent or diagnosing a configuration problem.", - inputSchema: { type: "object", properties: {}, required: [] }, - }, - ]; - - return { - tools: advertisedTools.map((tool) => ({ - ...tool, - annotations: toolAnnotations(tool.name), - ...(outputSchemaFor(tool.name) ? { outputSchema: outputSchemaFor(tool.name) } : {}), - })), - }; -}); +// ListTools is derived from TOOL_DEFINITIONS rather than restated here (#596). +// The handler used to carry its own copy of the whole list, which had drifted +// from tools.ts in both directions — six implemented tools were undiscoverable, +// two advertised tools were missing from the generated docs, and several +// schemas had lost their field descriptions and optional arguments. +// `listToolsContract.test.ts` checks this response against the definitions, the +// argument validator, and the dispatch switch on every run. +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: advertisedTools(process.env), +})); server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const { name, arguments: args = {} } = request.params; diff --git a/mcp/src/listToolsContract.test.ts b/mcp/src/listToolsContract.test.ts new file mode 100644 index 00000000..4dab1004 --- /dev/null +++ b/mcp/src/listToolsContract.test.ts @@ -0,0 +1,403 @@ +/** + * ListTools contract drift check (#596). + * + * A tool is not one declaration but four, spread across four files: + * + * 1. `tools.ts` — the definition (description, input schema, annotations) + * 2. `validation.ts` — the argument spec the dispatcher validates against + * 3. `index.ts` — the `case` in the dispatch switch that runs it + * 4. `outputSchemas.ts` — the structured-output schema, when it declares one + * + * Any one can be added without the others, and nothing about adding it fails. + * The tool is simply broken in a way that only shows up when an agent calls it: + * undiscoverable, or advertised but unimplemented, or advertised with arguments + * the validator rejects. + * + * Every one of those had happened before this test existed, because the + * ListTools handler in index.ts carried a hand-maintained copy of (1): + * + * - `mindvault_update_metadata`, `mindvault_set_price`, + * `mindvault_transfer_ownership`, `mindvault_set_listed`, + * `mindvault_export_receipts` and `mindvault_recover_catalog_cache` were + * implemented, validated and documented, but absent from the copy — so no + * agent could discover them. + * - `mindvault_publish_status` and `mindvault_purchase_history` existed only + * in the copy, so `docs/mcp-tool-reference.md` (generated from `tools.ts`) + * never listed them. + * - `mindvault_reset` advertised a `confirm` argument that `resetGuard` reads + * and `TOOL_ARGUMENT_SPECS` did not declare, so every confirmed reset was + * rejected as an unknown argument and the tool could never do anything. + * - `mindvault_publish` and `mindvault_buy` had quietly stopped advertising + * `dryRun` and `maxAutoPayUsdc`. + * + * The copy is gone (`toolSurface.ts` derives the response from + * `TOOL_DEFINITIONS`), which removes the *cause*. These tests cover the + * *category*: they assert the four declarations agree, against the response a + * real client gets over a real transport rather than against the arrays that + * produce it. + */ + +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { MAINNET_GATED_TOOLS } from "./mainnetGuardrails.js"; +import { paidOperationToolNames } from "./paidOperations.js"; +import { TEXT_ONLY_TOOLS } from "./outputSchemas.js"; +import { TOOL_DEFINITIONS } from "./tools.js"; +import { TOOLS_WITHOUT_HANDLERS, servableToolDefinitions } from "./toolSurface.js"; +import { + TOOL_ARGUMENT_SPECS, + TOOLS_WITHOUT_ARG_VALIDATION, + type ArgumentSpec, +} from "./validation.js"; +import { startIntegrationHarness, type IntegrationHarness } from "./integrationHarness.js"; + +process.env.MINDVAULT_MOCK = "1"; +process.env.STELLAR_NETWORK = "testnet"; +const home = mkdtempSync(join(tmpdir(), "mindvault-mcp-contract-")); +process.env.HOME = home; +process.env.USERPROFILE = home; + +const { server, dispatchTool } = await import("./index.js"); + +/** One tool exactly as a client receives it from ListTools. */ +interface WireTool { + name: string; + description?: string; + inputSchema?: { type?: string; properties?: Record; required?: string[] }; + outputSchema?: Record; + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + }; +} + +let harness: IntegrationHarness; +let wireTools: WireTool[]; +let byName: Map; + +beforeAll(async () => { + harness = await startIntegrationHarness(server); + wireTools = (await harness.listTools()).tools as WireTool[]; + byName = new Map(wireTools.map((tool) => [tool.name, tool])); +}); + +afterAll(async () => { + await harness?.close(); + rmSync(home, { recursive: true, force: true }); +}); + +describe("ListTools ↔ TOOL_DEFINITIONS", () => { + it("advertises every servable definition, and nothing else", () => { + expect(wireTools.map((t) => t.name).sort()).toEqual( + servableToolDefinitions() + .map((t) => t.name) + .sort(), + ); + }); + + it("sends each tool's definition verbatim", () => { + // The whole point of deriving the response: description and schema on the + // wire are the same objects the generated docs and the snapshots read. + for (const definition of servableToolDefinitions()) { + const wire = byName.get(definition.name); + expect(wire, `${definition.name} is advertised`).toBeDefined(); + expect(wire?.description, `${definition.name} description`).toBe(definition.description); + expect(wire?.inputSchema, `${definition.name} inputSchema`).toEqual(definition.inputSchema); + expect(wire?.annotations, `${definition.name} annotations`).toEqual(definition.annotations); + } + }); + + it("advertises outputSchema exactly when the definition declares one", () => { + for (const definition of servableToolDefinitions()) { + const wire = byName.get(definition.name); + if (definition.outputSchema) { + expect(wire?.outputSchema, `${definition.name} advertises its output schema`).toEqual( + definition.outputSchema, + ); + } else { + // Absent, not `undefined`: MCP clients distinguish the two, and a tool + // that grows a null-valued outputSchema fails structured-content checks. + expect(wire && "outputSchema" in wire, `${definition.name} declares no output schema`).toBe( + false, + ); + } + } + }); + + it("keeps TEXT_ONLY_TOOLS free of a structured-output schema", () => { + for (const name of TEXT_ONLY_TOOLS) { + const wire = byName.get(name); + if (!wire) continue; // withheld tools are covered separately + expect(wire.outputSchema, `${name} must stay text-only`).toBeUndefined(); + } + }); + + it("returns a stable list across repeated calls", async () => { + const again = (await harness.listTools()).tools as WireTool[]; + expect(again.map((t) => t.name)).toEqual(wireTools.map((t) => t.name)); + }); +}); + +/** + * Minimal arguments that satisfy one argument spec. + * + * The reachability probes below have to get *past* the validator to learn + * anything about the dispatch switch: a tool with required arguments called + * with `{}` fails validation long before the `switch`, so a missing handler + * would go unnoticed. Building the arguments from the spec rather than + * hand-listing them per tool means a new required argument does not silently + * turn these checks back into no-ops. + */ +function sampleValue(spec: ArgumentSpec): unknown { + switch (spec.kind) { + case "flag": + return true; + case "integer": + return spec.min ?? 1; + case "enum": + return spec.values?.[0] ?? ""; + case "hash": + return "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; + case "tag_array": + return ["sample"]; + case "string": + // `contract-probe` satisfies every string pattern in use (resource ids, + // profile names, metadata pointers are all covered by the looser ones); + // the few with a stricter pattern are listed in SAMPLE_OVERRIDES. + return "contract-probe"; + } +} + +/** Specs whose pattern the generic string sample cannot satisfy. */ +const SAMPLE_OVERRIDES: Record> = { + mindvault_register: { email: "probe@example.com" }, + mindvault_publish: { price: "1.00", externalUrl: "https://example.com/probe" }, + mindvault_set_price: { price: "1.00" }, + mindvault_transfer_ownership: { + newCreator: "GA6HCMBLTZS5VYYBCATRBRZ3BZJMAFUDKYYF6AH6MVCMGWMRDNSWJPIH", + }, + mindvault_update_metadata: { metadata: "ipfs://QmProbe" }, + mindvault_import_wallet: { + secretKey: "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, +}; + +/** A value satisfying one advertised JSON Schema property. */ +function sampleFromSchema(property: unknown): unknown { + const type = (property as { type?: string } | undefined)?.type; + if (type === "boolean") return true; + if (type === "number" || type === "integer") return 1; + return "contract-probe"; +} + +/** + * Arguments that pass validation for `name`, so dispatch reaches the switch. + * + * Tools in TOOLS_WITHOUT_ARG_VALIDATION have no spec to read, so their + * required arguments come from the advertised schema instead — they normalize + * their own input and raise on a missing field just as the validator would. + */ +function probeArgs(name: string): Record { + const overrides = SAMPLE_OVERRIDES[name] ?? {}; + const spec = TOOL_ARGUMENT_SPECS[name]; + + if (spec) { + const args: Record = {}; + for (const [field, fieldSpec] of Object.entries(spec)) { + if (fieldSpec.required) args[field] = sampleValue(fieldSpec); + } + return { ...args, ...overrides }; + } + + const schema = byName.get(name)?.inputSchema; + const args: Record = {}; + for (const field of schema?.required ?? []) { + args[field] = sampleFromSchema(schema?.properties?.[field]); + } + return { ...args, ...overrides }; +} + +describe("ListTools ↔ the dispatcher", () => { + /** + * `Unknown tool: ` is what the `default` arm of the dispatch switch + * raises for a name it has no `case` for. Any other outcome — success, a + * network failure, a missing wallet — means the tool was reached and failed + * on its own terms, which is all this check needs to establish. + */ + async function isReachable(name: string): Promise { + try { + await dispatchTool(name, probeArgs(name)); + return true; + } catch (err) { + const message = (err as Error).message; + // A validation failure means the probe arguments were wrong, not that the + // tool is unreachable — fail loudly rather than reporting a false pass. + if (/not a recognized argument|is required|Invalid arguments/i.test(message)) { + throw new Error(`probe arguments for ${name} did not validate: ${message}`); + } + return !/^Unknown tool:/.test(message); + } + } + + it("advertises no tool the dispatcher cannot reach", async () => { + const unreachable: string[] = []; + for (const tool of wireTools) { + if (!(await isReachable(tool.name))) unreachable.push(tool.name); + } + expect(unreachable).toEqual([]); + }); + + it("withholds exactly the tools that have no handler", async () => { + // Both directions. A definition that gains a handler must leave + // TOOLS_WITHOUT_HANDLERS, and one that loses its handler must not be left + // advertised — an agent calling an advertised tool and getting + // `Unknown tool` learns nothing it can act on. + const withheld: string[] = []; + for (const definition of TOOL_DEFINITIONS) { + if (!(await isReachable(definition.name))) withheld.push(definition.name); + } + expect(withheld.sort()).toEqual([...TOOLS_WITHOUT_HANDLERS].sort()); + for (const name of TOOLS_WITHOUT_HANDLERS) { + expect(byName.has(name), `${name} has no handler and must not be advertised`).toBe(false); + } + }); +}); + +describe("ListTools ↔ the argument validator", () => { + it("gives every advertised tool a validation spec, or a declared exemption", () => { + const exempt = new Set(TOOLS_WITHOUT_ARG_VALIDATION); + for (const tool of wireTools) { + if (exempt.has(tool.name)) continue; + expect(TOOL_ARGUMENT_SPECS, `${tool.name} is advertised but not validated`).toHaveProperty( + tool.name, + ); + } + }); + + it("validates no tool that is not advertised", () => { + // A spec for a tool nobody can call is dead weight that still has to be + // maintained; more usefully, this catches a tool dropped from the surface + // while the rest of its wiring stayed behind. + const advertised = new Set(wireTools.map((t) => t.name)); + const withheld = new Set(TOOLS_WITHOUT_HANDLERS); + for (const name of Object.keys(TOOL_ARGUMENT_SPECS)) { + if (withheld.has(name)) continue; + expect(advertised.has(name), `${name} is validated but not advertised`).toBe(true); + } + }); + + it("advertises exactly the arguments the validator accepts", () => { + // The `mindvault_reset.confirm` failure: an argument advertised in the + // schema and rejected by the validator makes the tool unusable, and an + // argument the validator accepts but the schema hides is undiscoverable. + const exempt = new Set(TOOLS_WITHOUT_ARG_VALIDATION); + for (const tool of wireTools) { + if (exempt.has(tool.name)) continue; + const spec = TOOL_ARGUMENT_SPECS[tool.name]; + expect( + Object.keys(tool.inputSchema?.properties ?? {}).sort(), + `${tool.name} arguments`, + ).toEqual(Object.keys(spec).sort()); + } + }); + + it("advertises exactly the arguments the validator requires", () => { + const exempt = new Set(TOOLS_WITHOUT_ARG_VALIDATION); + for (const tool of wireTools) { + if (exempt.has(tool.name)) continue; + const required = Object.entries(TOOL_ARGUMENT_SPECS[tool.name]) + .filter(([, argSpec]) => argSpec.required) + .map(([field]) => field); + expect([...(tool.inputSchema?.required ?? [])].sort(), `${tool.name} required`).toEqual( + required.sort(), + ); + } + }); + + it("rejects an unadvertised argument on an advertised tool", async () => { + // The contract has to hold at call time, not only in the metadata: a + // schema that lists an argument is a promise the dispatcher honours it. + await expect( + dispatchTool("mindvault_registry_lookup", { resourceId: "mock-1", nonsense: true }), + ).rejects.toThrow(/not a recognized argument/i); + }); + + it("accepts every argument mindvault_reset advertises", async () => { + // Regression for the drift that made `confirm` unusable: the guard read it, + // ListTools advertised it, and the validator threw on it. + const properties = Object.keys(byName.get("mindvault_reset")?.inputSchema?.properties ?? {}); + expect(properties).toContain("confirm"); + await expect(dispatchTool("mindvault_reset", { confirm: true })).resolves.toBeTypeOf("string"); + }); +}); + +describe("ListTools ↔ the guardrails", () => { + it("gates only tools that exist on the surface", () => { + const known = new Set(TOOL_DEFINITIONS.map((t) => t.name)); + for (const name of MAINNET_GATED_TOOLS) { + expect(known.has(name), `mainnet-gated ${name} has no definition`).toBe(true); + } + for (const name of paidOperationToolNames()) { + expect(known.has(name), `paid-gated ${name} has no definition`).toBe(true); + } + }); + + it("never gates a tool it also advertises as read-only", () => { + // A read-only tool spends nothing and mutates nothing, so gating one would + // be a contradiction between the annotation and the guardrail — and agents + // do plan around `readOnlyHint`. + const readOnly = new Set( + wireTools.filter((t) => t.annotations?.readOnlyHint === true).map((t) => t.name), + ); + for (const name of [...MAINNET_GATED_TOOLS, ...paidOperationToolNames()]) { + expect(readOnly.has(name), `${name} is gated but advertised read-only`).toBe(false); + } + }); + + it("advertises the confirmation argument every gated tool needs", () => { + for (const name of MAINNET_GATED_TOOLS) { + const properties = byName.get(name)?.inputSchema?.properties ?? {}; + expect(properties, `${name} advertises confirmMainnet`).toHaveProperty("confirmMainnet"); + } + for (const name of paidOperationToolNames()) { + const properties = byName.get(name)?.inputSchema?.properties ?? {}; + expect(properties, `${name} advertises confirmPaid`).toHaveProperty("confirmPaid"); + } + }); +}); + +describe("advertised annotations", () => { + it("declares complete annotations on every tool", () => { + for (const tool of wireTools) { + expect(typeof tool.annotations?.title, `${tool.name} title`).toBe("string"); + expect(tool.annotations?.title?.length).toBeGreaterThan(0); + expect(typeof tool.annotations?.readOnlyHint, `${tool.name} readOnlyHint`).toBe("boolean"); + expect(typeof tool.annotations?.destructiveHint, `${tool.name} destructiveHint`).toBe( + "boolean", + ); + expect(typeof tool.annotations?.idempotentHint, `${tool.name} idempotentHint`).toBe( + "boolean", + ); + } + }); + + it("never marks a tool both read-only and destructive", () => { + for (const tool of wireTools) { + if (tool.annotations?.readOnlyHint) { + expect(tool.annotations.destructiveHint, `${tool.name}`).toBe(false); + } + } + }); + + it("gives every tool a distinct title", () => { + const titles = wireTools.map((t) => t.annotations?.title); + expect(new Set(titles).size, "tool titles are shown to users and must disambiguate").toBe( + titles.length, + ); + }); +}); diff --git a/mcp/src/outputSchemas.test.ts b/mcp/src/outputSchemas.test.ts index ee3f0a98..210e5b5f 100644 --- a/mcp/src/outputSchemas.test.ts +++ b/mcp/src/outputSchemas.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it } from "vitest"; import { dryRunBuy, dryRunPublish } from "./dryRun.js"; import { CATALOG_LIST_OUTPUT_SCHEMA, - EXTRA_OUTPUT_SCHEMAS, LIST_PROFILES_OUTPUT_SCHEMA, PREVIEW_OUTPUT_SCHEMA, PUBLISH_BUY_OUTPUT_SCHEMA, @@ -53,9 +52,15 @@ describe("structured tools advertise a schema", () => { } }); - it("publish_status and purchase_history have extra schemas", () => { - expect(EXTRA_OUTPUT_SCHEMAS.mindvault_publish_status).toBe(PUBLISH_STATUS_OUTPUT_SCHEMA); - expect(EXTRA_OUTPUT_SCHEMAS.mindvault_purchase_history).toBe(PURCHASE_HISTORY_OUTPUT_SCHEMA); + it("publish_status and purchase_history advertise their schemas from TOOL_DEFINITIONS", () => { + // They used to live in a side table because they were missing from + // TOOL_DEFINITIONS; both are defined there now, so the schemas travel with + // the definition like every other tool's (#596). + const byName = new Map(TOOL_DEFINITIONS.map((t) => [t.name, t])); + expect(byName.get("mindvault_publish_status")?.outputSchema).toBe(PUBLISH_STATUS_OUTPUT_SCHEMA); + expect(byName.get("mindvault_purchase_history")?.outputSchema).toBe( + PURCHASE_HISTORY_OUTPUT_SCHEMA, + ); }); }); diff --git a/mcp/src/outputSchemas.ts b/mcp/src/outputSchemas.ts index 742bb8ac..e182ec1a 100644 --- a/mcp/src/outputSchemas.ts +++ b/mcp/src/outputSchemas.ts @@ -385,12 +385,6 @@ export const RECOVER_CACHE_OUTPUT_SCHEMA = { required: ["source", "action", "message"], } as const; -/** Tools advertised in ListTools but not listed in TOOL_DEFINITIONS. */ -export const EXTRA_OUTPUT_SCHEMAS: Record> = { - mindvault_publish_status: PUBLISH_STATUS_OUTPUT_SCHEMA as unknown as Record, - mindvault_purchase_history: PURCHASE_HISTORY_OUTPUT_SCHEMA as unknown as Record, -}; - /** Tools that must stay text-only (no schema, no structuredContent). */ export const TEXT_ONLY_TOOLS = [ "mindvault_check_bindings", diff --git a/mcp/src/paidOperations.test.ts b/mcp/src/paidOperations.test.ts new file mode 100644 index 00000000..7d608fb8 --- /dev/null +++ b/mcp/src/paidOperations.test.ts @@ -0,0 +1,456 @@ +/** + * Tests for the paid-operation confirmation policy (#594). + * + * The policy answers a question neither existing guardrail asks. The mainnet + * guardrail asks *where* the spend happens and lets everything through on + * testnet; the auto-pay ceiling asks *how much* and lets a hundred cheap + * purchases through. This one asks whether the caller meant to spend at all, + * on any network, at any price. + * + * Three things have to hold, and the third is the one most easily lost: + * + * 1. The policy classifies and decides correctly (pure functions). + * 2. The dispatcher consults it (wiring). + * 3. It *composes* with the other two guardrails rather than replacing them — + * satisfying this one must not quietly unlock a mainnet spend, and the + * default must leave every existing deployment behaving exactly as before. + */ + +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { + DEFAULT_PAID_CONFIRMATION_POLICY, + FEE_SPENDING_TOOLS, + PAID_CONFIRMATION_ARG, + PAID_CONFIRMATION_ENV_VAR, + USDC_SPENDING_TOOLS, + assertPaidOperationConfirmed, + formatPaidConfirmationDiagnostics, + paidConfirmationRequiredError, + paidOperationClass, + paidOperationToolNames, + requiresPaidConfirmation, + resolvePaidConfirmationPolicy, +} from "./paidOperations.js"; +import { + harnessIsToolError, + harnessResultText, + startIntegrationHarness, +} from "./integrationHarness.js"; +import type { IntegrationHarness } from "./integrationHarness.js"; + +process.env.MINDVAULT_MOCK = "1"; +process.env.STELLAR_NETWORK = "testnet"; +const home = mkdtempSync(join(tmpdir(), "mindvault-mcp-paid-")); +process.env.HOME = home; +process.env.USERPROFILE = home; + +const { server, dispatchTool } = await import("./index.js"); + +const envWith = (policy: string) => ({ [PAID_CONFIRMATION_ENV_VAR]: policy }); + +/** + * The message a dispatch failed with, or `null` when it succeeded. + * + * Used where the assertion is about *which* error came back rather than + * whether one did: several gated tools fail downstream in mock mode (no + * wallet, no live RPC), and a test that asserted "rejects" would break if one + * of them ever started succeeding — for a reason unrelated to what it checks. + */ +async function dispatchFailure( + name: string, + args: Record, +): Promise { + try { + await dispatchTool(name, args); + return null; + } catch (err) { + return (err as Error).message; + } +} + +// ── Policy resolution ──────────────────────────────────────────────────────── + +describe("resolvePaidConfirmationPolicy", () => { + it("defaults to off so an upgrade changes no existing deployment", () => { + expect(resolvePaidConfirmationPolicy({})).toBe(DEFAULT_PAID_CONFIRMATION_POLICY); + expect(DEFAULT_PAID_CONFIRMATION_POLICY).toBe("off"); + }); + + it.each(["off", "usdc", "all"])("accepts %j", (policy) => { + expect(resolvePaidConfirmationPolicy(envWith(policy))).toBe(policy); + }); + + it("is case- and whitespace-insensitive", () => { + expect(resolvePaidConfirmationPolicy(envWith(" USDC "))).toBe("usdc"); + }); + + it("reads an empty value as unset", () => { + // What a shell leaves behind for `export MINDVAULT_CONFIRM_PAID_OPERATIONS=`. + expect(resolvePaidConfirmationPolicy(envWith(""))).toBe("off"); + expect(resolvePaidConfirmationPolicy(envWith(" "))).toBe("off"); + }); + + it("throws on an unrecognized value instead of falling back to off", () => { + // The important one. A typo in a safety setting that silently disables it + // is worse than one that stops the server: the operator believes they are + // protected. `true` is the likeliest typo, so it is named explicitly. + expect(() => resolvePaidConfirmationPolicy(envWith("true"))).toThrow( + /must be one of: off, usdc, all/, + ); + expect(() => resolvePaidConfirmationPolicy(envWith("1"))).toThrow(/must be one of/); + expect(() => resolvePaidConfirmationPolicy(envWith("yes"))).toThrow(/must be one of/); + }); + + it("quotes the offending value in the error", () => { + expect(() => resolvePaidConfirmationPolicy(envWith("usdcc"))).toThrow(/"usdcc"/); + }); +}); + +// ── Classification ─────────────────────────────────────────────────────────── + +describe("paidOperationClass", () => { + it("classifies the USDC spenders", () => { + for (const name of USDC_SPENDING_TOOLS) { + expect(paidOperationClass(name), name).toBe("usdc"); + } + expect(USDC_SPENDING_TOOLS).toEqual(["mindvault_publish", "mindvault_buy"]); + }); + + it("classifies the network-fee spenders", () => { + for (const name of FEE_SPENDING_TOOLS) { + expect(paidOperationClass(name), name).toBe("fee"); + } + }); + + it("classifies tools that cost nothing as null", () => { + for (const name of [ + "mindvault_browse", + "mindvault_search", + "mindvault_preview", + "mindvault_wallet_info", + "mindvault_reset", + "mindvault_registry_lookup", + ]) { + expect(paidOperationClass(name), name).toBeNull(); + } + }); + + it("does not gate wallet setup, which the sponsor pays for", () => { + // Account creation goes through the sponsored-account service; the agent's + // own wallet funds nothing, so requiring a spend confirmation would be a + // lie about what the call costs. + expect(paidOperationClass("mindvault_setup_wallet")).toBeNull(); + }); + + it("treats an unknown name as costing nothing", () => { + // Failing open is right here: this classifier must not become a second, + // accidental allowlist for tool names. The dispatcher rejects unknown tools. + expect(paidOperationClass("mindvault_not_a_tool")).toBeNull(); + }); + + it("lists every gated tool once, sorted", () => { + const names = paidOperationToolNames(); + expect(new Set(names).size).toBe(names.length); + expect(names).toEqual([...names].sort()); + expect(names).toHaveLength(USDC_SPENDING_TOOLS.length + FEE_SPENDING_TOOLS.length); + }); +}); + +// ── The decision ───────────────────────────────────────────────────────────── + +describe("requiresPaidConfirmation", () => { + it("requires nothing under off", () => { + for (const name of paidOperationToolNames()) { + expect(requiresPaidConfirmation(name, "off"), name).toBe(false); + } + }); + + it("requires confirmation for USDC spends under usdc", () => { + for (const name of USDC_SPENDING_TOOLS) { + expect(requiresPaidConfirmation(name, "usdc"), name).toBe(true); + } + }); + + it("leaves fee-only spends alone under usdc", () => { + // The whole reason `all` is a separate step: an operator may care about + // USDC leaving the wallet without wanting every on-chain edit gated. + for (const name of FEE_SPENDING_TOOLS) { + expect(requiresPaidConfirmation(name, "usdc"), name).toBe(false); + } + }); + + it("requires confirmation for every spend under all", () => { + for (const name of paidOperationToolNames()) { + expect(requiresPaidConfirmation(name, "all"), name).toBe(true); + } + }); + + it("never requires confirmation for a tool that costs nothing", () => { + for (const policy of ["off", "usdc", "all"] as const) { + expect(requiresPaidConfirmation("mindvault_browse", policy), policy).toBe(false); + } + }); +}); + +describe("assertPaidOperationConfirmed", () => { + it("allows everything under the default policy", () => { + for (const name of paidOperationToolNames()) { + expect(() => + assertPaidOperationConfirmed({ toolName: name, args: {}, env: {} }), + ).not.toThrow(); + } + }); + + it("blocks an unconfirmed spend under usdc", () => { + expect(() => + assertPaidOperationConfirmed({ toolName: "mindvault_buy", args: {}, env: envWith("usdc") }), + ).toThrow(/Paid-operation guardrail/); + }); + + it("allows a confirmed spend", () => { + expect(() => + assertPaidOperationConfirmed({ + toolName: "mindvault_buy", + args: { [PAID_CONFIRMATION_ARG]: true }, + env: envWith("usdc"), + }), + ).not.toThrow(); + }); + + it.each([true, 1, "true", "1", "yes"])("accepts %j as confirmation", (value) => { + // The same truthy forms as confirmMainnet, so agents learn one convention + // rather than one per guardrail. + expect(() => + assertPaidOperationConfirmed({ + toolName: "mindvault_buy", + args: { [PAID_CONFIRMATION_ARG]: value }, + env: envWith("usdc"), + }), + ).not.toThrow(); + }); + + it.each([false, 0, "false", "no", "", null, undefined])( + "does not accept %j as confirmation", + (value) => { + expect(() => + assertPaidOperationConfirmed({ + toolName: "mindvault_buy", + args: { [PAID_CONFIRMATION_ARG]: value }, + env: envWith("usdc"), + }), + ).toThrow(/Paid-operation guardrail/); + }, + ); + + it("exempts a dry run", () => { + // A dry run submits no payment. Gating it would mean confirming a spend in + // order to find out what the spend would be. + expect(() => + assertPaidOperationConfirmed({ + toolName: "mindvault_buy", + args: {}, + dryRun: true, + env: envWith("all"), + }), + ).not.toThrow(); + }); + + it("tolerates missing arguments", () => { + expect(() => + assertPaidOperationConfirmed({ + toolName: "mindvault_buy", + args: undefined, + env: envWith("usdc"), + }), + ).toThrow(/Paid-operation guardrail/); + }); + + it("surfaces a misconfigured policy rather than skipping the check", () => { + expect(() => + assertPaidOperationConfirmed({ toolName: "mindvault_buy", args: {}, env: envWith("on") }), + ).toThrow(/must be one of/); + }); +}); + +describe("paidConfirmationRequiredError", () => { + const message = paidConfirmationRequiredError("mindvault_buy", "usdc").message; + + it("names the tool, the policy, and the argument that satisfies it", () => { + expect(message).toContain("mindvault_buy"); + expect(message).toContain(PAID_CONFIRMATION_ENV_VAR); + expect(message).toContain(PAID_CONFIRMATION_ARG); + }); + + it("says what the call would cost", () => { + expect(message).toMatch(/spends USDC/); + expect(paidConfirmationRequiredError("mindvault_set_price", "all").message).toMatch( + /network fees/, + ); + }); + + it("is deterministic and leaks nothing", () => { + expect(paidConfirmationRequiredError("mindvault_buy", "usdc").message).toBe(message); + expect(message).not.toMatch(/\/(home|Users|tmp)\//); + expect(message).not.toContain("at "); + }); +}); + +describe("formatPaidConfirmationDiagnostics", () => { + it("reports the policy as off and how to enable it", () => { + expect(formatPaidConfirmationDiagnostics({})).toMatch(/off/); + expect(formatPaidConfirmationDiagnostics({})).toContain(PAID_CONFIRMATION_ENV_VAR); + }); + + it("names the gated tools under each active policy", () => { + expect(formatPaidConfirmationDiagnostics(envWith("usdc"))).toContain("mindvault_buy"); + expect(formatPaidConfirmationDiagnostics(envWith("all"))).toContain("mindvault_set_price"); + }); + + it("reports a misconfiguration instead of throwing", () => { + // Diagnostics run in status output; they have to describe a broken setting + // rather than take the status tool down with them. + expect(formatPaidConfirmationDiagnostics(envWith("nope"))).toMatch(/misconfigured/); + }); +}); + +// ── Wiring ─────────────────────────────────────────────────────────────────── + +describe("the paid-operation policy through the MCP server", () => { + let harness: IntegrationHarness; + + beforeAll(async () => { + harness = await startIntegrationHarness(server); + }); + + afterAll(async () => { + // Also clear it here, not only in afterEach: vitest may place another file + // in this worker, and a suite that died mid-run would otherwise leak the + // variable into it. + delete process.env[PAID_CONFIRMATION_ENV_VAR]; + await harness?.close(); + rmSync(home, { recursive: true, force: true }); + }); + + afterEach(() => { + delete process.env[PAID_CONFIRMATION_ENV_VAR]; + }); + + it("blocks an unconfirmed buy under usdc", async () => { + process.env[PAID_CONFIRMATION_ENV_VAR] = "usdc"; + const result = await harness.callTool("mindvault_buy", { resourceId: "mock-1" }); + + expect(harnessIsToolError(result)).toBe(true); + expect(harnessResultText(result)).toContain("Paid-operation guardrail"); + }); + + it("lets a confirmed buy past the guardrail", async () => { + process.env[PAID_CONFIRMATION_ENV_VAR] = "usdc"; + // The call still fails — there is no wallet in this profile — but it fails + // *inside the tool*, which is what proves the guardrail let it through. + await expect( + dispatchTool("mindvault_buy", { resourceId: "mock-1", confirmPaid: true }), + ).rejects.toThrow(/No wallet in profile/); + }); + + it("lets a dry run past without confirmation", async () => { + process.env[PAID_CONFIRMATION_ENV_VAR] = "all"; + const result = await harness.callTool("mindvault_buy", { + resourceId: "mock-1", + dryRun: true, + }); + + expect(harnessIsToolError(result)).toBe(false); + expect(harnessResultText(result)).toContain("dry-run"); + }); + + it("leaves fee-only tools alone under usdc but gates them under all", async () => { + const args = { resourceId: "mock-1", price: "1.00" }; + + process.env[PAID_CONFIRMATION_ENV_VAR] = "usdc"; + expect(await dispatchFailure("mindvault_set_price", args)).not.toMatch( + /Paid-operation guardrail/, + ); + + process.env[PAID_CONFIRMATION_ENV_VAR] = "all"; + expect(await dispatchFailure("mindvault_set_price", args)).toMatch(/Paid-operation guardrail/); + }); + + it("does not gate read-only tools under any policy", async () => { + for (const policy of ["usdc", "all"]) { + process.env[PAID_CONFIRMATION_ENV_VAR] = policy; + const result = await harness.callTool("mindvault_browse", {}); + expect(harnessIsToolError(result), policy).toBe(false); + } + }); + + it("changes nothing when the policy is off", async () => { + // The upgrade-safety property: an existing deployment that never sets the + // variable must behave exactly as it did before this guardrail existed. + await expect(dispatchTool("mindvault_buy", { resourceId: "mock-1" })).rejects.toThrow( + /No wallet in profile/, + ); + }); + + it("accepts confirmPaid as a validated argument on every gated tool", async () => { + // A guardrail whose own argument the validator rejects is unusable — the + // shape of the mindvault_reset.confirm bug in #596. + process.env[PAID_CONFIRMATION_ENV_VAR] = "all"; + for (const name of paidOperationToolNames()) { + const failure = await dispatchFailure(name, { confirmPaid: true }); + expect(failure, `${name} rejected its own confirmation argument`).not.toMatch( + /not a recognized argument/, + ); + expect(failure, `${name} was still gated after confirming`).not.toMatch( + /Paid-operation guardrail/, + ); + } + }); + + it("reports a misconfigured policy as a tool error, not a crash", async () => { + process.env[PAID_CONFIRMATION_ENV_VAR] = "loud"; + const result = await harness.callTool("mindvault_buy", { resourceId: "mock-1" }); + + expect(harnessIsToolError(result)).toBe(true); + expect(harnessResultText(result)).toContain("must be one of"); + }); +}); + +describe("composition with the other guardrails", () => { + afterEach(() => { + delete process.env[PAID_CONFIRMATION_ENV_VAR]; + }); + + it("does not let confirmPaid stand in for confirmMainnet", async () => { + // Each guardrail answers its own question. Satisfying the spend policy must + // not imply consent to spend on the *public* network — that is a separate + // decision with a separate flag. + const { assertMainnetMutationAllowed } = await import("./mainnetGuardrails.js"); + expect(() => + assertMainnetMutationAllowed("mainnet", "mindvault_buy", { confirmPaid: true }, {}), + ).toThrow(/Mainnet guardrail/); + }); + + it("does not let confirmMainnet stand in for confirmPaid", () => { + expect(() => + assertPaidOperationConfirmed({ + toolName: "mindvault_buy", + args: { confirmMainnet: true }, + env: envWith("usdc"), + }), + ).toThrow(/Paid-operation guardrail/); + }); + + it("requires both when both apply", async () => { + const { assertMainnetMutationAllowed } = await import("./mainnetGuardrails.js"); + const args = { confirmMainnet: true, confirmPaid: true }; + + expect(() => assertMainnetMutationAllowed("mainnet", "mindvault_buy", args, {})).not.toThrow(); + expect(() => + assertPaidOperationConfirmed({ toolName: "mindvault_buy", args, env: envWith("usdc") }), + ).not.toThrow(); + }); +}); diff --git a/mcp/src/paidOperations.ts b/mcp/src/paidOperations.ts new file mode 100644 index 00000000..5e38c44c --- /dev/null +++ b/mcp/src/paidOperations.ts @@ -0,0 +1,215 @@ +/** + * Paid-operation confirmation policy (#594). + * + * Some MCP tools spend the agent's money. `mindvault_buy` transfers the + * resource's asking price in USDC; `mindvault_publish` pays the ~$0.10 USDC + * x402 verification fee. Others spend only network fees, but they still debit + * the wallet and land an irreversible transaction on-chain. + * + * Two guardrails already exist and neither covers this: + * + * - The **mainnet guardrail** (`mainnetGuardrails.ts`) is network-scoped. On + * testnet it never fires, so an agent loop that spends in a cycle is only + * caught once it is spending real money. + * - The **auto-pay ceiling** (`paymentCeiling.ts`) is amount-scoped. It stops + * one large purchase; it says nothing about a hundred small ones. + * + * This policy is the third axis: *did the caller mean to spend at all?* It is + * network-independent and amount-independent, so an operator can require an + * explicit `confirmPaid: true` on every spend regardless of where the agent is + * pointed or how cheap the resource is. + * + * MINDVAULT_CONFIRM_PAID_OPERATIONS=off (default — unchanged behaviour) + * MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc (tools that spend USDC) + * MINDVAULT_CONFIRM_PAID_OPERATIONS=all (also tools that spend network fees) + * + * The default is `off` on purpose. This is an opt-in belt for operators who + * want one, not a new obstacle in front of every existing agent — a guardrail + * that breaks working deployments on upgrade gets switched off wholesale, + * which is worse than not shipping it. + * + * The policy composes with the other two rather than replacing them: a mainnet + * buy above the ceiling with `MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc` must + * satisfy all three. Each answers a different question, so each keeps its own + * error message. + * + * This module is pure — it classifies, decides, and formats. `index.ts` calls + * `assertPaidOperationConfirmed` in the dispatcher. + */ + +import { isTruthyConfirm } from "./mainnetGuardrails.js"; + +/** Environment variable selecting the confirmation policy. */ +export const PAID_CONFIRMATION_ENV_VAR = "MINDVAULT_CONFIRM_PAID_OPERATIONS"; + +/** The tool argument that satisfies the policy for one call. */ +export const PAID_CONFIRMATION_ARG = "confirmPaid"; + +/** + * How much confirmation the operator wants. + * + * - `off` — none. The mainnet guardrail and the auto-pay ceiling still apply. + * - `usdc` — tools that move USDC must carry `confirmPaid: true`. + * - `all` — additionally, tools that spend Stellar network fees must too. + */ +export type PaidConfirmationPolicy = "off" | "usdc" | "all"; + +/** The policy in force when {@link PAID_CONFIRMATION_ENV_VAR} is unset. */ +export const DEFAULT_PAID_CONFIRMATION_POLICY: PaidConfirmationPolicy = "off"; + +const POLICIES: readonly PaidConfirmationPolicy[] = ["off", "usdc", "all"]; + +/** + * Tools that spend USDC from the agent wallet. + * + * `mindvault_publish` pays the x402 verification fee; `mindvault_buy` pays the + * resource's asking price. Both settle on-chain and neither can be undone. + */ +export const USDC_SPENDING_TOOLS = ["mindvault_publish", "mindvault_buy"] as const; + +/** + * Tools that submit a Stellar transaction and so spend network fees. + * + * No USDC leaves the wallet, but XLM does and the on-chain effect is + * permanent — which is why `all` exists as a distinct step above `usdc`. + * + * `mindvault_setup_wallet` is deliberately absent: account creation runs + * through the sponsored-account service, so the agent's own wallet funds + * nothing. + */ +export const FEE_SPENDING_TOOLS = [ + "mindvault_register_onchain", + "mindvault_update_metadata", + "mindvault_set_price", + "mindvault_transfer_ownership", + "mindvault_set_listed", +] as const; + +/** What a tool spends, or `null` when it spends nothing. */ +export type PaidOperationClass = "usdc" | "fee"; + +const USDC_SET: ReadonlySet = new Set(USDC_SPENDING_TOOLS); +const FEE_SET: ReadonlySet = new Set(FEE_SPENDING_TOOLS); + +/** Classify what a tool spends. `null` for tools that cost nothing. */ +export function paidOperationClass(toolName: string): PaidOperationClass | null { + if (USDC_SET.has(toolName)) return "usdc"; + if (FEE_SET.has(toolName)) return "fee"; + return null; +} + +/** Every tool the policy can gate, sorted — used in errors and by tests. */ +export function paidOperationToolNames(): string[] { + return [...USDC_SPENDING_TOOLS, ...FEE_SPENDING_TOOLS].sort(); +} + +/** + * Read the policy from the environment. + * + * Throws on an unrecognized value rather than falling back to `off`. A typo in + * a safety setting must not silently disable it — an operator who wrote + * `MINDVAULT_CONFIRM_PAID_OPERATIONS=true` needs to hear about it at the first + * paid call, not discover months later that nothing was ever gated. An empty + * or whitespace-only value reads as unset, which is the shape a shell leaves + * behind for a variable that was exported but never given a value. + */ +export function resolvePaidConfirmationPolicy( + env: NodeJS.ProcessEnv = process.env, +): PaidConfirmationPolicy { + const raw = env[PAID_CONFIRMATION_ENV_VAR]; + if (raw == null || raw.trim() === "") return DEFAULT_PAID_CONFIRMATION_POLICY; + + const value = raw.trim().toLowerCase(); + if ((POLICIES as readonly string[]).includes(value)) return value as PaidConfirmationPolicy; + + throw new Error( + `${PAID_CONFIRMATION_ENV_VAR} must be one of: ${POLICIES.join(", ")}. ` + + `Received "${raw}". Unset the variable to use the default (${DEFAULT_PAID_CONFIRMATION_POLICY}).`, + ); +} + +/** Whether `policy` requires explicit confirmation for `toolName`. */ +export function requiresPaidConfirmation( + toolName: string, + policy: PaidConfirmationPolicy, +): boolean { + const operation = paidOperationClass(toolName); + if (operation === null) return false; + if (policy === "off") return false; + if (policy === "all") return true; + return operation === "usdc"; +} + +/** Human phrase for what the tool costs, used in the refusal. */ +function describeCost(operation: PaidOperationClass): string { + return operation === "usdc" + ? "spends USDC from the agent wallet and settles on-chain" + : "submits a Stellar transaction and spends network fees"; +} + +/** + * Deterministic error when a paid operation is attempted without confirmation. + * + * Names the tool, what it costs, and the two ways forward, so an agent can + * recover without a round trip to its operator. Safe for agent-facing output: + * no secrets, no paths, no stack traces. + */ +export function paidConfirmationRequiredError( + toolName: string, + policy: PaidConfirmationPolicy, +): Error { + const operation = paidOperationClass(toolName) ?? "fee"; + return new Error( + [ + `Paid-operation guardrail: "${toolName}" requires explicit confirmation because this server runs with ${PAID_CONFIRMATION_ENV_VAR}=${policy}.`, + `This tool ${describeCost(operation)}.`, + `To proceed, pass ${PAID_CONFIRMATION_ARG}: true on this tool call.`, + `To stop requiring it, restart the server with ${PAID_CONFIRMATION_ENV_VAR}=off.`, + "Read-only tools and dry runs are never gated.", + ].join(" "), + ); +} + +/** + * Assert a paid operation may run under the current policy. + * + * No-op when the policy is `off`, the tool spends nothing, the call is a dry + * run, or the caller passed `confirmPaid: true`. + * + * Dry runs are exempt because `mindvault_publish`/`mindvault_buy` with + * `dryRun: true` submit no payment and no transaction — gating them would + * require confirming a spend in order to find out what the spend would be, + * which defeats the purpose of having a dry run. + */ +export function assertPaidOperationConfirmed(input: { + toolName: string; + args: Record | undefined; + /** True when this call was recognized as a dry run by the dispatcher. */ + dryRun?: boolean; + env?: NodeJS.ProcessEnv; +}): void { + const policy = resolvePaidConfirmationPolicy(input.env ?? process.env); + if (!requiresPaidConfirmation(input.toolName, policy)) return; + if (input.dryRun) return; + if (isTruthyConfirm(input.args?.[PAID_CONFIRMATION_ARG])) return; + throw paidConfirmationRequiredError(input.toolName, policy); +} + +/** Compact policy line for operator/agent status output. */ +export function formatPaidConfirmationDiagnostics(env: NodeJS.ProcessEnv = process.env): string { + let policy: PaidConfirmationPolicy; + try { + policy = resolvePaidConfirmationPolicy(env); + } catch (err) { + return `Paid-operation confirmation: misconfigured — ${(err as Error).message}`; + } + + switch (policy) { + case "off": + return `Paid-operation confirmation: off — set ${PAID_CONFIRMATION_ENV_VAR}=usdc to require ${PAID_CONFIRMATION_ARG} on spends`; + case "usdc": + return `Paid-operation confirmation: USDC spends require ${PAID_CONFIRMATION_ARG}: true (${USDC_SPENDING_TOOLS.join(", ")})`; + case "all": + return `Paid-operation confirmation: every spend requires ${PAID_CONFIRMATION_ARG}: true (${paidOperationToolNames().join(", ")})`; + } +} diff --git a/mcp/src/readOnlyMode.test.ts b/mcp/src/readOnlyMode.test.ts new file mode 100644 index 00000000..e40718ba --- /dev/null +++ b/mcp/src/readOnlyMode.test.ts @@ -0,0 +1,298 @@ +/** + * Tests for read-only mode (#593). + * + * Two layers, because read-only mode is only useful if both hold: + * + * - the pure decisions in `readOnlyMode.ts` (parsing, classification, the + * refusal text), which need no server; and + * - the wiring, checked through the SDK client over a real transport, since + * a correct decision that the dispatcher never consults protects nothing. + * + * The second layer is the one that matters. Narrowing ListTools is a + * convenience — it keeps an agent from planning around a tool it cannot use — + * but a client with a cached tool list, or one that simply guesses a name, will + * still call it. The gate that enforces the mode is the one in dispatch, so + * these tests call withheld tools directly rather than trusting the listing. + */ + +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +import { + READ_ONLY_ENV_VAR, + assertToolAllowedInReadOnlyMode, + filterToolsForReadOnlyMode, + formatReadOnlyDiagnostics, + isReadOnlyTool, + readOnlyModeEnabled, + readOnlyRefusalError, + readOnlyToolNames, +} from "./readOnlyMode.js"; +import { TOOL_DEFINITIONS } from "./tools.js"; +import { + harnessIsToolError, + harnessResultText, + startIntegrationHarness, +} from "./integrationHarness.js"; +import type { IntegrationHarness } from "./integrationHarness.js"; + +process.env.MINDVAULT_MOCK = "1"; +process.env.STELLAR_NETWORK = "testnet"; +const home = mkdtempSync(join(tmpdir(), "mindvault-mcp-readonly-")); +process.env.HOME = home; +process.env.USERPROFILE = home; + +const { server, dispatchTool } = await import("./index.js"); + +// ── Pure decisions ─────────────────────────────────────────────────────────── + +describe("readOnlyModeEnabled", () => { + it("is off when the variable is unset", () => { + expect(readOnlyModeEnabled({})).toBe(false); + }); + + it.each(["1", "true", "TRUE", "yes", "on", " 1 "])("is on for %j", (value) => { + expect(readOnlyModeEnabled({ [READ_ONLY_ENV_VAR]: value })).toBe(true); + }); + + it.each(["0", "false", "no", "off", ""])("is off for %j", (value) => { + expect(readOnlyModeEnabled({ [READ_ONLY_ENV_VAR]: value })).toBe(false); + }); + + it("treats an unrecognized value as off rather than guessing", () => { + // Failing open is the right default *here*: an operator who never meant to + // enable read-only mode gets exactly the behaviour they had before the + // variable existed, instead of a server that silently refuses to work. + expect(readOnlyModeEnabled({ [READ_ONLY_ENV_VAR]: "maybe" })).toBe(false); + }); +}); + +describe("isReadOnlyTool", () => { + it("follows the readOnlyHint each tool declares", () => { + for (const tool of TOOL_DEFINITIONS) { + expect(isReadOnlyTool(tool.name), tool.name).toBe(tool.annotations.readOnlyHint); + } + }); + + it("classifies the catalog browsing tools as read-only", () => { + for (const name of [ + "mindvault_browse", + "mindvault_search", + "mindvault_preview", + "mindvault_registry_lookup", + "mindvault_registry_list", + ]) { + expect(isReadOnlyTool(name), name).toBe(true); + } + }); + + it("classifies spending and state-clearing tools as not read-only", () => { + for (const name of [ + "mindvault_buy", + "mindvault_publish", + "mindvault_reset", + "mindvault_restore_state", + "mindvault_import_wallet", + "mindvault_rotate_publisher_key", + ]) { + expect(isReadOnlyTool(name), name).toBe(false); + } + }); + + it("fails closed on an unknown name", () => { + // A safety boundary must not admit a name it has never heard of. + expect(isReadOnlyTool("mindvault_not_a_tool")).toBe(false); + }); +}); + +describe("filterToolsForReadOnlyMode", () => { + const tools = [{ name: "mindvault_browse" }, { name: "mindvault_buy" }]; + + it("passes everything through when the mode is off", () => { + expect(filterToolsForReadOnlyMode(tools, {})).toEqual(tools); + }); + + it("keeps only read-only tools when the mode is on", () => { + expect(filterToolsForReadOnlyMode(tools, { [READ_ONLY_ENV_VAR]: "1" })).toEqual([ + { name: "mindvault_browse" }, + ]); + }); + + it("returns a copy rather than the caller's array", () => { + const result = filterToolsForReadOnlyMode(tools, {}); + expect(result).not.toBe(tools); + }); +}); + +describe("assertToolAllowedInReadOnlyMode", () => { + const on = { [READ_ONLY_ENV_VAR]: "1" }; + + it("allows anything when the mode is off", () => { + expect(() => assertToolAllowedInReadOnlyMode("mindvault_buy", {})).not.toThrow(); + }); + + it("allows read-only tools when the mode is on", () => { + expect(() => assertToolAllowedInReadOnlyMode("mindvault_browse", on)).not.toThrow(); + }); + + it("refuses mutating tools when the mode is on", () => { + expect(() => assertToolAllowedInReadOnlyMode("mindvault_buy", on)).toThrow(/Read-only mode/); + }); +}); + +describe("readOnlyRefusalError", () => { + const message = readOnlyRefusalError("mindvault_buy").message; + + it("names the tool and the variable that caused the refusal", () => { + expect(message).toContain("mindvault_buy"); + expect(message).toContain(READ_ONLY_ENV_VAR); + }); + + it("tells the agent what it can still do", () => { + expect(message).toContain("mindvault_browse"); + expect(message).toContain("mindvault_search"); + }); + + it("says the restriction cannot be lifted from a tool call", () => { + // The distinction from the mainnet guardrail, which *is* per-call + // overridable. An agent that retries with a confirmation flag here would + // just fail twice. + expect(message).toMatch(/no tool argument can override it/i); + }); + + it("is deterministic and leaks nothing", () => { + expect(readOnlyRefusalError("mindvault_buy").message).toBe(message); + expect(message).not.toMatch(/\/(home|Users|tmp)\//); + expect(message).not.toContain("at "); + }); +}); + +describe("formatReadOnlyDiagnostics", () => { + it("reports the mode as off and how to turn it on", () => { + expect(formatReadOnlyDiagnostics({})).toMatch(/off/); + expect(formatReadOnlyDiagnostics({})).toContain(READ_ONLY_ENV_VAR); + }); + + it("reports the mode as on with the number of tools left", () => { + const line = formatReadOnlyDiagnostics({ [READ_ONLY_ENV_VAR]: "1" }); + expect(line).toMatch(/ON/); + expect(line).toContain(String(readOnlyToolNames().length)); + }); +}); + +// ── Wiring ─────────────────────────────────────────────────────────────────── + +describe("read-only mode through the MCP server", () => { + let harness: IntegrationHarness; + + beforeAll(async () => { + harness = await startIntegrationHarness(server); + }); + + afterAll(async () => { + // Also clear it here, not only in afterEach: vitest may place another file + // in this worker, and a suite that died mid-run would otherwise leak the + // variable into it. + delete process.env[READ_ONLY_ENV_VAR]; + await harness?.close(); + rmSync(home, { recursive: true, force: true }); + }); + + afterEach(() => { + delete process.env[READ_ONLY_ENV_VAR]; + }); + + it("advertises the full surface when the mode is off", async () => { + const { tools } = await harness.listTools(); + expect(tools.some((t) => t.name === "mindvault_buy")).toBe(true); + expect(tools.some((t) => t.name === "mindvault_browse")).toBe(true); + }); + + it("advertises only read-only tools when the mode is on", async () => { + process.env[READ_ONLY_ENV_VAR] = "1"; + const { tools } = await harness.listTools(); + + expect(tools.length).toBeGreaterThan(0); + for (const tool of tools) { + expect(isReadOnlyTool(tool.name), `${tool.name} is advertised in read-only mode`).toBe(true); + } + expect(tools.some((t) => t.name === "mindvault_browse")).toBe(true); + expect(tools.some((t) => t.name === "mindvault_buy")).toBe(false); + expect(tools.some((t) => t.name === "mindvault_reset")).toBe(false); + }); + + it("re-reads the environment on every ListTools call", async () => { + // The handler is not allowed to snapshot the mode at module load: the + // listing has to reflect what dispatch will actually enforce. + const before = (await harness.listTools()).tools.length; + process.env[READ_ONLY_ENV_VAR] = "1"; + const during = (await harness.listTools()).tools.length; + delete process.env[READ_ONLY_ENV_VAR]; + const after = (await harness.listTools()).tools.length; + + expect(during).toBeLessThan(before); + expect(after).toBe(before); + }); + + it("still browses the catalog in read-only mode", async () => { + process.env[READ_ONLY_ENV_VAR] = "1"; + const result = await harness.callTool("mindvault_browse", {}); + + expect(harnessIsToolError(result)).toBe(false); + expect(harnessResultText(result)).toContain("mock-1"); + }); + + it("still previews a resource in read-only mode", async () => { + process.env[READ_ONLY_ENV_VAR] = "1"; + const result = await harness.callTool("mindvault_preview", { resourceId: "mock-1" }); + + expect(harnessIsToolError(result)).toBe(false); + }); + + it("refuses a withheld tool that a client calls anyway", async () => { + // The case the ListTools filter cannot cover: a stale tool list, or a + // client guessing the name. This is the check that makes the mode a + // guarantee rather than a hint. + process.env[READ_ONLY_ENV_VAR] = "1"; + const result = await harness.callTool("mindvault_buy", { resourceId: "mock-1" }); + + expect(harnessIsToolError(result)).toBe(true); + expect(harnessResultText(result)).toContain("Read-only mode"); + }); + + it("refuses every non-read-only tool, not just the spending ones", async () => { + process.env[READ_ONLY_ENV_VAR] = "1"; + for (const name of ["mindvault_reset", "mindvault_setup_wallet", "mindvault_restore_state"]) { + await expect(dispatchTool(name, {}), name).rejects.toThrow(/Read-only mode/); + } + }); + + it("refuses before validating arguments", async () => { + // A malformed-arguments error would be misleading when the server was never + // going to run the tool: the agent would "fix" the arguments and fail again. + process.env[READ_ONLY_ENV_VAR] = "1"; + await expect(dispatchTool("mindvault_buy", { resourceId: "" })).rejects.toThrow( + /Read-only mode/, + ); + }); + + it("refuses a dry run too", async () => { + // A dry run spends nothing, but read-only mode is about what this server is + // *for*, not about cost — and the paid-operation policy is the guardrail + // that exempts dry runs. + process.env[READ_ONLY_ENV_VAR] = "1"; + await expect( + dispatchTool("mindvault_buy", { resourceId: "mock-1", dryRun: true }), + ).rejects.toThrow(/Read-only mode/); + }); + + it("restores the full surface once the mode is turned off", async () => { + process.env[READ_ONLY_ENV_VAR] = "1"; + await expect(dispatchTool("mindvault_reset", {})).rejects.toThrow(/Read-only mode/); + + delete process.env[READ_ONLY_ENV_VAR]; + await expect(dispatchTool("mindvault_reset", {})).resolves.toBeTypeOf("string"); + }); +}); diff --git a/mcp/src/readOnlyMode.ts b/mcp/src/readOnlyMode.ts new file mode 100644 index 00000000..9efaf38f --- /dev/null +++ b/mcp/src/readOnlyMode.ts @@ -0,0 +1,129 @@ +/** + * Read-only mode for catalog browsing (#593). + * + * An agent that only needs to *discover* what is in the vault should not be + * one malformed plan away from spending USDC, rotating a publisher key, or + * wiping `~/.mindvault/state.json`. The mainnet guardrail in + * `mainnetGuardrails.ts` is the wrong instrument for that: it is + * network-scoped (testnet is wide open) and per-call (`confirmMainnet: true` + * unlocks it from inside the very tool call you wanted to prevent). + * + * Read-only mode is the operator-scoped complement. It is set once, on the + * server process, and cannot be lifted by a tool argument: + * + * MINDVAULT_READ_ONLY=1 + * + * With it on, the server is a catalog browser and nothing else. Two things + * change, and they must change together: + * + * 1. **ListTools advertises only read-only tools.** An agent that cannot see + * `mindvault_buy` does not plan around it, so the common case never + * reaches a refusal at all. + * 2. **Dispatch refuses the rest.** Advertisement is a hint; a client that + * cached an older tool list, or one that simply guesses a name, still + * calls it. The gate that matters is the one in the dispatcher. + * + * "Read-only" is not a list maintained here — it is + * `annotations.readOnlyHint` from {@link TOOL_DEFINITIONS}, the same hint + * ListTools already advertises to clients. A tool added later is gated by + * whatever it declares about itself, so this module cannot fall out of sync + * with the tool surface. `listToolsContract.test.ts` pins that relationship. + */ + +import { TOOL_DEFINITIONS } from "./tools.js"; + +/** Environment variable that puts the server into read-only mode. */ +export const READ_ONLY_ENV_VAR = "MINDVAULT_READ_ONLY"; + +/** + * Truthy forms accepted for {@link READ_ONLY_ENV_VAR}. + * + * Deliberately narrow, and deliberately not the `isTruthyConfirm` set from the + * mainnet guardrail: that one exists to read a *tool argument* leniently. + * This reads an operator's deployment config, where a value that is neither + * clearly on nor clearly off is safer treated as off than guessed at — the + * server then behaves exactly as it did before anyone set the variable. + */ +const TRUTHY = new Set(["1", "true", "yes", "on"]); + +/** Whether the server process is running in read-only mode. */ +export function readOnlyModeEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = env[READ_ONLY_ENV_VAR]; + if (raw == null) return false; + return TRUTHY.has(raw.trim().toLowerCase()); +} + +/** Tool names whose definition declares `readOnlyHint: true`. */ +const READ_ONLY_TOOLS: ReadonlySet = new Set( + TOOL_DEFINITIONS.filter((tool) => tool.annotations.readOnlyHint).map((tool) => tool.name), +); + +/** + * Whether a tool is safe to run in read-only mode. + * + * An unknown name is **not** read-only. Read-only mode is a safety boundary, + * so a name this module has never heard of has to fail closed; the dispatcher + * will reject it as an unknown tool a moment later anyway. + */ +export function isReadOnlyTool(toolName: string): boolean { + return READ_ONLY_TOOLS.has(toolName); +} + +/** Every read-only tool name, sorted — used in the refusal and by tests. */ +export function readOnlyToolNames(): string[] { + return [...READ_ONLY_TOOLS].sort(); +} + +/** + * Deterministic refusal for a mutating tool called in read-only mode. + * + * Agent-facing, so it says what to do instead rather than only what failed: + * the browsing tools that *are* available, and the one thing (restarting the + * server without the variable) that would lift the restriction. No secrets, no + * paths, no stack traces. + */ +export function readOnlyRefusalError(toolName: string): Error { + return new Error( + [ + `Read-only mode: "${toolName}" is disabled because the MCP server was started with ${READ_ONLY_ENV_VAR}.`, + "This server can browse the catalog but cannot mutate state, spend funds, or change stored credentials.", + `Available tools: ${readOnlyToolNames().join(", ")}.`, + `Lifting this requires restarting the server without ${READ_ONLY_ENV_VAR} — no tool argument can override it.`, + ].join(" "), + ); +} + +/** + * Assert a tool may run under the current read-only setting. + * + * No-op when read-only mode is off or the tool is read-only. + */ +export function assertToolAllowedInReadOnlyMode( + toolName: string, + env: NodeJS.ProcessEnv = process.env, +): void { + if (!readOnlyModeEnabled(env)) return; + if (isReadOnlyTool(toolName)) return; + throw readOnlyRefusalError(toolName); +} + +/** + * Narrow an advertised tool list to what read-only mode permits. + * + * Generic over the element type so it applies equally to `ToolDefinition`s and + * to the shape ListTools actually returns. + */ +export function filterToolsForReadOnlyMode( + tools: readonly T[], + env: NodeJS.ProcessEnv = process.env, +): T[] { + if (!readOnlyModeEnabled(env)) return [...tools]; + return tools.filter((tool) => isReadOnlyTool(tool.name)); +} + +/** Compact read-only line for operator/agent status output. */ +export function formatReadOnlyDiagnostics(env: NodeJS.ProcessEnv = process.env): string { + return readOnlyModeEnabled(env) + ? `Read-only mode: ON (${READ_ONLY_ENV_VAR}) — ${READ_ONLY_TOOLS.size} browsing tools advertised, all others refused` + : `Read-only mode: off — set ${READ_ONLY_ENV_VAR}=1 to restrict this server to catalog browsing`; +} diff --git a/mcp/src/toolMetadata.test.ts b/mcp/src/toolMetadata.test.ts index 231639c9..ee41eb3d 100644 --- a/mcp/src/toolMetadata.test.ts +++ b/mcp/src/toolMetadata.test.ts @@ -15,38 +15,6 @@ import { TEXT_ONLY_TOOLS } from "./outputSchemas.js"; describe("MCP tool metadata", () => { it("all tools have required fields", () => { - // Inline expected tool names from index.ts for snapshot validation. - // Integration tests assert the live ListTools response via the SDK harness. - const expectedToolNames = [ - "mindvault_setup_wallet", - "mindvault_wallet_info", - "mindvault_use_profile", - "mindvault_list_profiles", - "mindvault_browse", - "mindvault_search", - "mindvault_preview", - "mindvault_register", - "mindvault_publish", - "mindvault_publish_status", - "mindvault_buy", - "mindvault_purchase_history", - "mindvault_register_onchain", - "mindvault_agent_status", - "mindvault_registry_info", - "mindvault_network_profile", - "mindvault_check_bindings", - "mindvault_check_consistency", - "mindvault_registry_lookup", - "mindvault_tx_status", - "mindvault_reset", - "mindvault_backup_state", - "mindvault_restore_state", - "mindvault_metrics", - "mindvault_update_metadata", - "mindvault_set_price", - "mindvault_transfer_ownership", - "mindvault_set_listed", - ]; for (const tool of TOOL_DEFINITIONS) { expect(tool.name).toMatch(/^mindvault_/); expect(typeof tool.description).toBe("string"); diff --git a/mcp/src/toolSurface.ts b/mcp/src/toolSurface.ts new file mode 100644 index 00000000..23768911 --- /dev/null +++ b/mcp/src/toolSurface.ts @@ -0,0 +1,103 @@ +/** + * The advertised tool surface — what ListTools returns (#596). + * + * `tools.ts` says of itself that it is "the single source of truth for the tool + * surface advertised to agent clients", and `scripts/generate-tool-docs.ts` + * builds `docs/mcp-tool-reference.md` on that promise. It was not true: the + * ListTools handler in `index.ts` carried its own ~490-line literal copy of the + * list, and the two drifted apart in every direction at once — + * + * - Six implemented, documented, validated tools (`mindvault_update_metadata`, + * `mindvault_set_price`, `mindvault_transfer_ownership`, + * `mindvault_set_listed`, `mindvault_export_receipts`, + * `mindvault_recover_catalog_cache`) were missing from the copy, so no + * agent could discover them. + * - `mindvault_publish_status` and `mindvault_purchase_history` existed only + * in the copy, so the generated reference page never mentioned them. + * - The copy's schemas for `mindvault_register`, `mindvault_publish`, + * `mindvault_buy` and others had lost their per-field descriptions and + * examples, and `mindvault_publish`/`mindvault_buy` no longer advertised + * `dryRun` or `maxAutoPayUsdc` at all. + * + * Deriving the surface here makes the promise structural instead of + * aspirational: there is one list, and `listToolsContract.test.ts` checks it + * against the three other places a tool has to be registered (the argument + * validator, the dispatch switch, and the annotation/output-schema maps). + * + * Two filters sit between the definitions and the wire, and both are deliberate: + * tools with no handler are withheld, and read-only mode narrows the surface to + * browsing (#593). + */ + +import { filterToolsForReadOnlyMode } from "./readOnlyMode.js"; +import { TOOL_DEFINITIONS, type ToolDefinition } from "./tools.js"; + +/** + * Defined and validated, but with no case in the dispatch switch. + * + * `mindvault_set_tags` has an entry in `TOOL_DEFINITIONS`, a spec in + * `TOOL_ARGUMENT_SPECS`, an output schema, and a row in the generated tool + * reference — but no handler, as `docs/mcp-structured-output.md` records. It is + * withheld from ListTools rather than advertised: an agent that calls an + * advertised tool and gets `Unknown tool` learns nothing useful, whereas one + * that never sees it simply plans around it. + * + * This list is a ledger of known gaps, not a place to park new tools. + * `listToolsContract.test.ts` asserts it names exactly the tools that are + * missing a handler, so implementing `mindvault_set_tags` fails the suite until + * this entry is removed, and adding a definition without a handler fails until + * one is added here on purpose. + */ +export const TOOLS_WITHOUT_HANDLERS: readonly string[] = ["mindvault_set_tags"]; + +const WITHOUT_HANDLERS: ReadonlySet = new Set(TOOLS_WITHOUT_HANDLERS); + +/** + * The tool definitions this build can actually serve, before any environment + * filtering. Ordering follows `TOOL_DEFINITIONS` so ListTools stays stable + * across calls and diffs stay readable. + */ +export function servableToolDefinitions(): ToolDefinition[] { + return TOOL_DEFINITIONS.filter((tool) => !WITHOUT_HANDLERS.has(tool.name)); +} + +/** One tool exactly as it goes out over ListTools. */ +export interface AdvertisedToolDescriptor { + name: string; + description: string; + inputSchema: ToolDefinition["inputSchema"]; + annotations: ToolDefinition["annotations"]; + outputSchema?: Record; +} + +/** + * Build the ListTools payload for an environment. + * + * `outputSchema` is omitted rather than set to `undefined` for tools that + * declare none: MCP treats an absent key and a null-valued one differently, and + * `toolSchemaSnapshots.test.ts` pins the distinction. + */ +export function advertisedTools(env: NodeJS.ProcessEnv = process.env): AdvertisedToolDescriptor[] { + return filterToolsForReadOnlyMode(servableToolDefinitions(), env).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.annotations, + ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}), + })); +} + +/** Look up one definition by name, or `undefined` when there is none. */ +export function toolDefinition(name: string): ToolDefinition | undefined { + return TOOL_DEFINITIONS.find((tool) => tool.name === name); +} + +/** The output schema advertised for a tool, or `undefined` when it declares none. */ +export function outputSchemaFor(name: string): Record | undefined { + return toolDefinition(name)?.outputSchema; +} + +/** Whether a tool declares structured output. */ +export function hasOutputSchema(name: string): boolean { + return outputSchemaFor(name) !== undefined; +} diff --git a/mcp/src/tools.ts b/mcp/src/tools.ts index a11302aa..3f5173de 100644 --- a/mcp/src/tools.ts +++ b/mcp/src/tools.ts @@ -6,6 +6,12 @@ * argument-validation layer can import it without booting the server or its * stdio transport. Every tool listed here must have a matching entry in * TOOL_ARGUMENT_SPECS (see validation.ts) — enforced by validation.test.ts. + * + * `toolSurface.ts` turns this array into the ListTools payload, and + * `listToolsContract.test.ts` checks the live response against it along with + * the argument validator and the dispatch switch. Until #596 the handler in + * index.ts kept its own copy of this list and the two had drifted apart in + * both directions; the contract test exists so that cannot recur silently. */ import { catalogFilterInputProperties } from "./catalogFilters.js"; @@ -19,6 +25,8 @@ import { ONCHAIN_MUTATION_OUTPUT_SCHEMA, PREVIEW_OUTPUT_SCHEMA, PUBLISH_BUY_OUTPUT_SCHEMA, + PUBLISH_STATUS_OUTPUT_SCHEMA, + PURCHASE_HISTORY_OUTPUT_SCHEMA, RECOVER_CACHE_OUTPUT_SCHEMA, REGISTER_ONCHAIN_OUTPUT_SCHEMA, REGISTRY_INFO_OUTPUT_SCHEMA, @@ -31,6 +39,19 @@ import { } from "./outputSchemas.js"; import { RECEIPT_EXPORT_MAX_LIMIT, RECEIPT_EXPORT_OUTPUT_SCHEMA } from "./receipts.js"; +/** + * The `confirmPaid` argument advertised by every tool the paid-operation policy + * can gate (#594). + * + * Declared once and spread into each schema so the wording an agent reads can + * never drift between two tools that answer to the same guardrail. + */ +const CONFIRM_PAID_PROPERTY = { + type: "boolean", + description: + "Required when the server runs with MINDVAULT_CONFIRM_PAID_OPERATIONS=usdc or =all. Explicitly confirm that this call may spend from the agent wallet. Ignored when the policy is off (the default) and on dry runs.", +} as const; + /** JSON Schema (draft subset) advertised for a tool's arguments. */ export interface ToolInputSchema { type: "object"; @@ -287,6 +308,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ description: "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", }, + confirmPaid: { ...CONFIRM_PAID_PROPERTY }, }, required: ["title", "price", "externalUrl"], }, @@ -327,6 +349,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ description: "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", }, + confirmPaid: { ...CONFIRM_PAID_PROPERTY }, }, required: ["resourceId"], }, @@ -410,6 +433,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ description: "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", }, + confirmPaid: { ...CONFIRM_PAID_PROPERTY }, }, required: ["resourceId"], }, @@ -594,6 +618,12 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ inputSchema: { type: "object", properties: { + confirm: { + type: "boolean", + description: + "Required to actually clear anything. Omitted or false returns a warning describing what would be removed and performs no deletion. Example: true clears the credentials.", + examples: [true, false], + }, all: { type: "boolean", description: @@ -747,6 +777,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ description: "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", }, + confirmPaid: { ...CONFIRM_PAID_PROPERTY }, }, required: ["resourceId", "metadata"], }, @@ -781,6 +812,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ description: "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", }, + confirmPaid: { ...CONFIRM_PAID_PROPERTY }, }, required: ["resourceId", "price"], }, @@ -814,6 +846,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ description: "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.", }, + confirmPaid: { ...CONFIRM_PAID_PROPERTY }, }, required: ["resourceId", "newCreator"], }, @@ -848,6 +881,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ description: "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation on the public Stellar network.", }, + confirmPaid: { ...CONFIRM_PAID_PROPERTY }, }, required: ["resourceId", "listed"], }, @@ -976,4 +1010,78 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ idempotentHint: true, }, }, + { + // Advertised by the ListTools handler in index.ts long before it was + // defined here, so the generated tool reference never listed it (#596). + name: "mindvault_publish_status", + description: + "Poll a published resource's verification and on-chain sync status. Returns verificationStatus (pending, verified, rejected, skipped), listed, onchainStatus, onchainTxHash, and optional verification details. Pass wait: true to poll until verification settles or timeoutMs elapses. Deterministic errors for missing resourceId and 404s.", + inputSchema: { + type: "object", + properties: { + resourceId: { + type: "string", + description: + "The resource ID from mindvault_publish (or browse/search). Example: 'cm7x8y9z'", + examples: ["cm7x8y9z", "res-001", "swcn98besxpp6t1u8e77fqz3"], + }, + wait: { + type: "boolean", + description: + "When true, poll until verificationStatus is verified, rejected, or skipped (or until timeoutMs). Default false (single fetch).", + }, + timeoutMs: { + type: "number", + description: + "Max wait time in milliseconds when wait is true (default 60000, max 300000).", + examples: [30000, 60000, 120000], + }, + intervalMs: { + type: "number", + description: + "Delay between polls in milliseconds when wait is true (default 2000, min 200).", + examples: [1000, 2000, 5000], + }, + }, + required: ["resourceId"], + }, + outputSchema: PUBLISH_STATUS_OUTPUT_SCHEMA as unknown as Record, + annotations: { + title: "Publish Status", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, + { + // As with mindvault_publish_status: advertised from index.ts only, so it + // was invisible to the generated docs and the schema snapshots (#596). + name: "mindvault_purchase_history", + description: + "List locally persisted purchase receipts from successful mindvault_buy calls (~/.mindvault/purchases.json). Read-only. Optional filters: resourceId and network (exact match, e.g. stellar:testnet). Returns count + purchases (newest first), or an empty list when nothing matches.", + inputSchema: { + type: "object", + properties: { + resourceId: { + type: "string", + description: "Optional. Only return receipts for this resource id. Example: 'cm7x8y9z'", + examples: ["cm7x8y9z", "res-001"], + }, + network: { + type: "string", + description: + "Optional. Only return receipts recorded on this x402 network id. Example: 'stellar:testnet'", + examples: ["stellar:testnet", "stellar:pubnet"], + }, + }, + required: [], + }, + outputSchema: PURCHASE_HISTORY_OUTPUT_SCHEMA as unknown as Record, + annotations: { + title: "Purchase History", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }, + }, ]; diff --git a/mcp/src/validation.test.ts b/mcp/src/validation.test.ts index 1671c2ef..781b5e88 100644 --- a/mcp/src/validation.test.ts +++ b/mcp/src/validation.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect } from "vitest"; import { TOOL_DEFINITIONS } from "./tools.js"; import { TOOL_ARGUMENT_SPECS, + TOOLS_WITHOUT_ARG_VALIDATION, ToolValidationError, UnknownToolError, flag, @@ -22,6 +23,18 @@ import { const VALID_SHA256 = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; +/** + * Advertised tools that go through this layer. + * + * `mindvault_publish_status` and `mindvault_purchase_history` normalize their + * own arguments (see TOOLS_WITHOUT_ARG_VALIDATION) and so have no spec to + * compare against. The exemption itself is checked below. + */ +function specValidatedTools() { + const exempt = new Set(TOOLS_WITHOUT_ARG_VALIDATION); + return TOOL_DEFINITIONS.filter((tool) => !exempt.has(tool.name)); +} + /** Minimum arguments that must pass for each tool. */ const VALID_CALLS: Record> = { mindvault_setup_wallet: {}, @@ -82,11 +95,21 @@ function expectInvalid(tool: string, args: unknown): ToolValidationError { describe("spec coverage", () => { it("every advertised tool has a validation spec", () => { - for (const tool of TOOL_DEFINITIONS) { + for (const tool of specValidatedTools()) { expect(TOOL_ARGUMENT_SPECS, `${tool.name} has no validation spec`).toHaveProperty(tool.name); } }); + it("the self-validating exemption names exactly the tools without a spec", () => { + // Keeps TOOLS_WITHOUT_ARG_VALIDATION honest in both directions: a tool that + // gains a spec must leave the list, and a tool that loses one must not + // silently join it. + const withoutSpec = TOOL_DEFINITIONS.filter((tool) => !(tool.name in TOOL_ARGUMENT_SPECS)).map( + (tool) => tool.name, + ); + expect(withoutSpec.sort()).toEqual([...TOOLS_WITHOUT_ARG_VALIDATION].sort()); + }); + it("every validation spec belongs to an advertised tool", () => { const advertised = new Set(knownToolNames()); for (const name of Object.keys(TOOL_ARGUMENT_SPECS)) { @@ -95,7 +118,7 @@ describe("spec coverage", () => { }); it("spec arguments match the advertised inputSchema properties", () => { - for (const tool of TOOL_DEFINITIONS) { + for (const tool of specValidatedTools()) { const spec = TOOL_ARGUMENT_SPECS[tool.name]; expect(Object.keys(spec).sort(), `${tool.name} argument names`).toEqual( Object.keys(tool.inputSchema.properties).sort(), @@ -104,7 +127,7 @@ describe("spec coverage", () => { }); it("required arguments match the advertised required list", () => { - for (const tool of TOOL_DEFINITIONS) { + for (const tool of specValidatedTools()) { const spec = TOOL_ARGUMENT_SPECS[tool.name]; const specRequired = Object.entries(spec) .filter(([, argSpec]) => argSpec.required) diff --git a/mcp/src/validation.ts b/mcp/src/validation.ts index 1739aca7..c7b9fca1 100644 --- a/mcp/src/validation.ts +++ b/mcp/src/validation.ts @@ -117,6 +117,9 @@ const USDC_AMOUNT: ArgumentSpec = { /** Confirmation flag for mainnet mutations (see mainnetGuardrails.ts). */ const CONFIRM_MAINNET: ArgumentSpec = { kind: "flag" }; +/** Confirmation flag for paid operations (see paidOperations.ts). */ +const CONFIRM_PAID: ArgumentSpec = { kind: "flag" }; + /** Preview flag: publish/buy report what they would do without paying. */ const DRY_RUN: ArgumentSpec = { kind: "flag" }; @@ -173,6 +176,25 @@ const CATALOG_FILTER_ARGS: ToolArgumentSpec = { // ── Per-tool specs ──────────────────────────────────────────────────────────── +/** + * Tools that parse their own arguments instead of going through + * {@link TOOL_ARGUMENT_SPECS}. + * + * Both normalize in their own module — `publishStatus.ts` clamps `timeoutMs` + * to a maximum and floors `intervalMs` at a minimum, and both raise messages + * their own suites pin. Running the generic validator first would reject + * values those functions deliberately accept and clamp, so the exemption is + * real rather than an oversight. + * + * It is a closed list, not a category: `listToolsContract.test.ts` asserts + * every other advertised tool has a spec, so a new tool cannot join this set + * by accident. + */ +export const TOOLS_WITHOUT_ARG_VALIDATION: readonly string[] = [ + "mindvault_publish_status", + "mindvault_purchase_history", +]; + /** * The validation contract for every public tool. Key order is the order in * which problems are reported, which keeps multi-issue errors deterministic. @@ -215,12 +237,14 @@ export const TOOL_ARGUMENT_SPECS: Record = { }, dryRun: DRY_RUN, confirmMainnet: CONFIRM_MAINNET, + confirmPaid: CONFIRM_PAID, }, mindvault_buy: { resourceId: RESOURCE_ID, dryRun: DRY_RUN, maxAutoPayUsdc: { ...USDC_AMOUNT, required: false }, confirmMainnet: CONFIRM_MAINNET, + confirmPaid: CONFIRM_PAID, }, mindvault_export_receipts: { format: { kind: "enum", values: ["json", "csv"] }, @@ -230,7 +254,11 @@ export const TOOL_ARGUMENT_SPECS: Record = { until: { kind: "string", maxLength: 64 }, limit: { kind: "integer", min: 1, max: RECEIPT_EXPORT_MAX_LIMIT }, }, - mindvault_register_onchain: { resourceId: RESOURCE_ID, confirmMainnet: CONFIRM_MAINNET }, + mindvault_register_onchain: { + resourceId: RESOURCE_ID, + confirmMainnet: CONFIRM_MAINNET, + confirmPaid: CONFIRM_PAID, + }, mindvault_agent_status: {}, mindvault_registry_info: {}, mindvault_network_profile: {}, @@ -245,7 +273,14 @@ export const TOOL_ARGUMENT_SPECS: Record = { limit: { kind: "integer", min: 1, max: REGISTRY_LIST_MAX_LIMIT }, }, mindvault_tx_status: { txHash: { kind: "hash", required: true, bareHex: true } }, - mindvault_reset: { all: { kind: "flag" }, confirmMainnet: CONFIRM_MAINNET }, + // `confirm` is what resetGuard.isResetConfirmed reads. It was advertised in + // ListTools and absent here, so every confirmed reset failed validation as an + // unknown argument and the tool was permanently stuck in preview mode (#596). + mindvault_reset: { + confirm: { kind: "flag" }, + all: { kind: "flag" }, + confirmMainnet: CONFIRM_MAINNET, + }, mindvault_backup_state: { passphrase: PASSPHRASE }, mindvault_restore_state: { blob: { kind: "string", required: true, maxLength: 1_048_576 }, @@ -261,21 +296,25 @@ export const TOOL_ARGUMENT_SPECS: Record = { resourceId: RESOURCE_ID, metadata: METADATA_POINTER, confirmMainnet: CONFIRM_MAINNET, + confirmPaid: CONFIRM_PAID, }, mindvault_set_price: { resourceId: RESOURCE_ID, price: { ...USDC_AMOUNT, required: true }, confirmMainnet: CONFIRM_MAINNET, + confirmPaid: CONFIRM_PAID, }, mindvault_transfer_ownership: { resourceId: RESOURCE_ID, newCreator: { ...STELLAR_ADDRESS, required: true }, confirmMainnet: CONFIRM_MAINNET, + confirmPaid: CONFIRM_PAID, }, mindvault_set_listed: { resourceId: RESOURCE_ID, listed: { kind: "flag", required: true }, confirmMainnet: CONFIRM_MAINNET, + confirmPaid: CONFIRM_PAID, }, mindvault_check_state_permissions: {}, mindvault_registry_health: {}, From afbcbcc76fd1710a95254eb65aa91a28a43bfc14 Mon Sep 17 00:00:00 2001 From: kaluuba-org Date: Sun, 30 Aug 2026 16:43:49 +0100 Subject: [PATCH 3/4] test(mcp): add concurrent state write regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #591. Several MCP tools do a read-modify-write against the module-level `profiles` map and then persist the whole map to ~/.mindvault/state.json. The window between the read and the write is not theoretical — mindvault_import_wallet awaits a dynamic import("@stellar/stellar-sdk") in the middle of it: activeProfileName = target; // read/modify …await… // another call runs here activeProfile().wallet = { … }; // modify, against whatever saveState(); // activeProfileName now says STATE_MUTATING_TOOLS + stateMutex.runExclusive close that window. These tests exist so the closing stays closed. They drive real concurrent dispatchTool calls and assert on the bytes that land on disk; mutex.test.ts already covers the primitive in isolation, which is not the same thing. The failure being guarded against is not a lost write but a misdirected one. Interleaved calls do not drop a profile — they write one call's wallet into another call's profile, which persists cleanly, reads back as valid JSON, and hands the wrong agent a wallet. "Every profile is present" passes while that happens, so the assertions check each profile holds the key meant for it. Twelve overlapping imports are checked for correct per-profile assignment, a parseable state file throughout the run, 0600 preserved across concurrent rewrites, lock release after a failed mutation (a leaked lock would deadlock every later mutating tool and look like a hang rather than a fault), reads interleaved with writes, and a state file left read-only by an earlier run. A final pair models importWallet's exact shape against a local mirror to show which interleaving does the damage: both bodies run to their await before either resumes, so both resume reading the *last* writer's activeProfileName. Two successful calls, no error raised, one profile silently gone. The interleaving is forced with microtask yields rather than timers, so it is deterministic and cannot flake. Keypairs are generated rather than inlined — they only need to be valid and distinct, and a committed S… literal reads like a leaked credential to every scanner that meets it. Nothing here is funded or submitted. --- mcp/src/concurrentStateWrites.test.ts | 324 ++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 mcp/src/concurrentStateWrites.test.ts diff --git a/mcp/src/concurrentStateWrites.test.ts b/mcp/src/concurrentStateWrites.test.ts new file mode 100644 index 00000000..25408097 --- /dev/null +++ b/mcp/src/concurrentStateWrites.test.ts @@ -0,0 +1,324 @@ +/** + * Regression tests for concurrent state writes (#591). + * + * MCP tool handlers are async and a client may have several in flight at once. + * Several of them do a read-modify-write against the module-level `profiles` + * map and then persist the whole map to `~/.mindvault/state.json`. The window + * between the read and the write is not theoretical — `mindvault_import_wallet` + * awaits a dynamic `import("@stellar/stellar-sdk")` and a `Keypair.fromSecret` + * in the middle of exactly that sequence: + * + * activeProfileName = target; // ← read/modify + * …await… // ← another call runs here + * activeProfile().wallet = { … }; // ← modify, against whatever + * saveState(); // activeProfileName now says + * + * `STATE_MUTATING_TOOLS` + `stateMutex.runExclusive` in index.ts close that + * window. These tests exist so that closing stays closed: they drive real + * concurrent tool calls through `dispatchTool` and assert on the bytes that + * land on disk, not on the mutex in isolation (`mutex.test.ts` covers the + * primitive itself). + * + * The failure being guarded against is not a lost write — it is a *misdirected* + * one. Interleaved calls do not drop a profile; they write one call's wallet + * into another call's profile, which persists cleanly, reads back as valid + * JSON, and hands the wrong agent a wallet. "Every profile is present" would + * pass while that happened, so the assertions below check that each profile + * holds the key that was actually meant for it. + */ + +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import { Mutex } from "./mutex.js"; + +// Isolate HOME before importing index.js: STATE_DIR/STATE_FILE are resolved +// from homedir() at module load, so a later assignment would not be seen. +process.env.MINDVAULT_MOCK = "1"; +process.env.STELLAR_NETWORK = "testnet"; +const home = mkdtempSync(join(tmpdir(), "mindvault-mcp-concurrent-")); +process.env.HOME = home; +process.env.USERPROFILE = home; + +const { dispatchTool, _resetProfiles } = await import("./index.js"); +const { Keypair } = await import("@stellar/stellar-sdk"); + +const STATE_FILE = join(home, ".mindvault", "state.json"); + +/** How many calls to overlap. Enough to interleave, small enough to stay fast. */ +const CONCURRENCY = 12; + +interface TestIdentity { + profile: string; + secretKey: string; + publicKey: string; +} + +/** + * Distinct throwaway keypairs, one per concurrent call. + * + * Generated rather than inlined: these only need to be valid and mutually + * distinct, and a committed `S…` literal reads like a leaked credential to + * every scanner that meets it. Nothing here is ever funded or submitted. + */ +function makeIdentities(count: number, prefix: string): TestIdentity[] { + return Array.from({ length: count }, (_, i) => { + const keypair = Keypair.random(); + return { + profile: `${prefix}-${i}`, + secretKey: keypair.secret(), + publicKey: keypair.publicKey(), + }; + }); +} + +/** The persisted state file, parsed. Throws if it is missing or not JSON. */ +function readPersistedState(): { + version: number; + activeProfile: string; + profiles: Record; +} { + return JSON.parse(readFileSync(STATE_FILE, "utf-8")); +} + +afterAll(() => { + rmSync(home, { recursive: true, force: true }); +}); + +beforeEach(() => { + _resetProfiles(); + rmSync(STATE_FILE, { force: true }); +}); + +describe("concurrent state writes (#591)", () => { + it("persists every profile when imports overlap", async () => { + const identities = makeIdentities(CONCURRENCY, "overlap"); + + await Promise.all( + identities.map((id) => + dispatchTool("mindvault_import_wallet", { + secretKey: id.secretKey, + profile: id.profile, + persist: true, + }), + ), + ); + + const state = readPersistedState(); + expect(Object.keys(state.profiles).sort()).toEqual(identities.map((i) => i.profile).sort()); + }); + + it("never writes one call's wallet into another call's profile", async () => { + // The actual corruption mode. Without the mutex, `activeProfileName` is + // reassigned by a later call while an earlier one is suspended on its + // `await`, so the earlier call's wallet lands under the later call's name. + const identities = makeIdentities(CONCURRENCY, "crosstalk"); + + await Promise.all( + identities.map((id) => + dispatchTool("mindvault_import_wallet", { + secretKey: id.secretKey, + profile: id.profile, + persist: true, + }), + ), + ); + + const state = readPersistedState(); + for (const id of identities) { + const wallet = state.profiles[id.profile]?.wallet; + expect(wallet, `profile ${id.profile} has a wallet`).toBeDefined(); + expect(wallet?.secretKey, `profile ${id.profile} kept its own secret key`).toBe(id.secretKey); + expect(wallet?.publicKey, `profile ${id.profile} kept its own address`).toBe(id.publicKey); + } + }); + + it("keeps the state file parseable at every point during a concurrent run", async () => { + // saveState writes through writeAtomically (temp file + rename), so a + // reader must see either the old file or the new one and never a partial + // one. Reading between every await of the in-flight batch is the closest a + // single-process test can get to a concurrent reader. + const identities = makeIdentities(CONCURRENCY, "atomic"); + const observations: number[] = []; + + const writes = Promise.all( + identities.map((id) => + dispatchTool("mindvault_import_wallet", { + secretKey: id.secretKey, + profile: id.profile, + persist: true, + }), + ), + ); + + for (let i = 0; i < 50; i++) { + await Promise.resolve(); + try { + // A miss is fine — the file may not exist yet. A malformed read is not. + observations.push(Object.keys(readPersistedState().profiles).length); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; + } + } + + await writes; + + // The loop above is the assertion: a torn write would have thrown a + // SyntaxError out of `readPersistedState` and failed the test. Check the + // loop was not vacuous — it has to have caught the file mid-run — and that + // every profile still arrived once the batch drained. + expect(observations.length).toBeGreaterThan(0); + expect(Object.keys(readPersistedState().profiles).sort()).toEqual( + identities.map((i) => i.profile).sort(), + ); + }); + + it("preserves 0600 on the state file across concurrent writes", async () => { + const identities = makeIdentities(CONCURRENCY, "perms"); + + await Promise.all( + identities.map((id) => + dispatchTool("mindvault_import_wallet", { + secretKey: id.secretKey, + profile: id.profile, + persist: true, + }), + ), + ); + + // The file holds wallet secret keys; a concurrent rewrite must not widen it. + expect(statSync(STATE_FILE).mode & 0o777).toBe(0o600); + }); + + it("releases the lock when a mutating call fails, so later calls still land", async () => { + // A rejected critical section that forgot to release would deadlock every + // subsequent state-mutating tool — the server would look hung rather than + // broken, which is the harder failure to diagnose. + const good = makeIdentities(4, "after-failure"); + + const results = await Promise.allSettled([ + dispatchTool("mindvault_import_wallet", { secretKey: "not-a-stellar-key", persist: true }), + ...good.map((id) => + dispatchTool("mindvault_import_wallet", { + secretKey: id.secretKey, + profile: id.profile, + persist: true, + }), + ), + ]); + + expect(results[0].status).toBe("rejected"); + for (const result of results.slice(1)) { + expect(result.status).toBe("fulfilled"); + } + const state = readPersistedState(); + for (const id of good) { + expect(state.profiles[id.profile]?.wallet?.publicKey).toBe(id.publicKey); + } + }); + + it("lets read-only tools run alongside mutations without disturbing them", async () => { + // Read-only tools are deliberately outside STATE_MUTATING_TOOLS so they do + // not queue behind a slow write. This pins that the exemption is safe: + // interleaving reads must not cost the writers any of their updates. + const identities = makeIdentities(CONCURRENCY, "with-reads"); + + await Promise.all([ + ...identities.map((id) => + dispatchTool("mindvault_import_wallet", { + secretKey: id.secretKey, + profile: id.profile, + persist: true, + }), + ), + ...Array.from({ length: CONCURRENCY }, () => dispatchTool("mindvault_list_profiles", {})), + ]); + + const state = readPersistedState(); + for (const id of identities) { + expect(state.profiles[id.profile]?.wallet?.secretKey).toBe(id.secretKey); + } + }); + + it("survives a state file the previous run left read-only", async () => { + // A stray chmod (or a restore from a backup tool) must not turn every + // subsequent concurrent write into an unhandled rejection: saveState logs + // and continues, so the tool calls themselves still resolve. + const seed = makeIdentities(1, "seed")[0]; + await dispatchTool("mindvault_import_wallet", { + secretKey: seed.secretKey, + profile: seed.profile, + persist: true, + }); + chmodSync(STATE_FILE, 0o400); + + const identities = makeIdentities(4, "readonly-file"); + const results = await Promise.allSettled( + identities.map((id) => + dispatchTool("mindvault_import_wallet", { + secretKey: id.secretKey, + profile: id.profile, + persist: true, + }), + ), + ); + + for (const result of results) { + expect(result.status).toBe("fulfilled"); + } + chmodSync(STATE_FILE, 0o600); + }); +}); + +describe("why the state mutex is load-bearing (#591)", () => { + // These two model the exact read-modify-write shape of importWallet against a + // local mirror of the state. They are the control for the tests above: if the + // mutex were removed from index.ts, the misdirected-wallet assertion would + // start failing, and this pair shows precisely which interleaving does it: + // every suspended body resumes against the *last* writer's `activeProfileName`. + // + // The interleaving is forced with explicit microtask yields rather than + // timers, so the ordering is deterministic and these cannot flake. + + interface Mirror { + active: string; + profiles: Record; + } + + /** importWallet's shape: pick the profile, suspend, then write to it. */ + async function importInto(mirror: Mirror, profile: string, value: string): Promise { + mirror.active = profile; + await Promise.resolve(); // stands in for `await import("@stellar/stellar-sdk")` + mirror.profiles[mirror.active] = value; + } + + it("writes into the wrong profile without serialization", async () => { + const mirror: Mirror = { active: "default", profiles: {} }; + + await Promise.all([ + importInto(mirror, "alice", "alice-key"), + importInto(mirror, "bob", "bob-key"), + ]); + + // Both bodies run up to their await before either resumes, so both resume + // with `active` reading "bob": alice's key is written under bob's name, + // then bob's key overwrites it. Two successful calls, no error raised, and + // alice ends up with no entry at all. + expect(mirror.profiles).toEqual({ bob: "bob-key" }); + expect(mirror.profiles.alice).toBeUndefined(); + }); + + it("assigns each profile its own value when serialized", async () => { + const mirror: Mirror = { active: "default", profiles: {} }; + const mutex = new Mutex(); + + await Promise.all([ + mutex.runExclusive(() => importInto(mirror, "alice", "alice-key")), + mutex.runExclusive(() => importInto(mirror, "bob", "bob-key")), + ]); + + expect(mirror.profiles).toEqual({ alice: "alice-key", bob: "bob-key" }); + }); +}); From 5958b5d034eaeaaf1700f25d37c2a39ad5aaaa8a Mon Sep 17 00:00:00 2001 From: kaluuba-org Date: Sun, 30 Aug 2026 16:48:23 +0100 Subject: [PATCH 4/4] test(mcp): fix a flaky correlation-id uniqueness assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `newCorrelationId > produces distinct ids in a tight loop` minted 500 ids from the real clock and Math.random and asserted all 500 were distinct. It failed roughly one run in fourteen, which was enough to make CI red on unrelated PRs. The failure was correct and the assertion was wrong. A 500-iteration loop completes inside one millisecond, so the time component is constant and all 500 ids are drawn from the suffix alone — 36**4 = 1,679,616 values. By the birthday bound that collides about 7% of the time, which matches the observed rate. The obvious fix — a per-process uniqueness counter — is not available. The test directly above it pins that `newCorrelationId` is deterministic in (clock, random), which exists so other tests can assert exact ids, and a counter would break it. The suffix is sized for real tool calls, each of which does network I/O; a handful per millisecond is already an extreme burst. So the loop is replaced by the two properties the design does guarantee: ids across advancing milliseconds are all distinct, and distinct randomness within one millisecond maps to distinct ids (the mapping is injective — collisions come from Math.random repeating, never from the suffix losing information). A third case pins the suffix at its full four digits, since narrowing it would raise the collision rate, and a collision merges two concurrent calls' audit trails — the failure this module exists to prevent. The module docstring claimed a same-millisecond collision was "effectively impossible". Corrected to state the actual size, what it is sized for, and why a counter is not an option. 20 consecutive full-suite runs pass. --- mcp/src/correlation.test.ts | 50 +++++++++++++++++++++++++++++++++++-- mcp/src/correlation.ts | 12 +++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/mcp/src/correlation.test.ts b/mcp/src/correlation.test.ts index e0061ff9..50ce8ae9 100644 --- a/mcp/src/correlation.test.ts +++ b/mcp/src/correlation.test.ts @@ -107,11 +107,57 @@ describe("newCorrelationId", () => { expect(id.split("-")[2]).toHaveLength(4); }); - it("produces distinct ids in a tight loop", () => { - const ids = new Set(Array.from({ length: 500 }, () => newCorrelationId())); + it("produces distinct ids across advancing milliseconds", () => { + let clock = 1_700_000_000_000; + const ids = new Set( + Array.from({ length: 500 }, () => + newCorrelationId( + () => clock++, + () => 0.5, + ), + ), + ); expect(ids.size).toBe(500); }); + + it("maps distinct randomness within one millisecond to distinct ids", () => { + // Within a millisecond the suffix is all that varies, so what this module + // owes is an injective mapping from randomness to suffix — no two distinct + // draws may collapse onto one id. + // + // This replaces an assertion that 500 ids from the *real* clock and + // Math.random were all distinct. That is not a property this design can + // have: `newCorrelationId` is deliberately deterministic in (clock, + // random) — the test above this one pins that — so it cannot also carry a + // uniqueness counter. A 500-call burst lands in one millisecond, drawing + // 500 times from 36**4 = 1,679,616 values, which by the birthday bound + // collides about 7% of the time. The old test failed roughly one run in + // fourteen, and the failure was correct: the assertion was wrong. + const draws = [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1 - Number.EPSILON]; + const ids = new Set( + draws.map((r) => + newCorrelationId( + () => 1, + () => r, + ), + ), + ); + + expect(ids.size).toBe(draws.length); + }); + + it("keeps the suffix at the full four base36 digits", () => { + // Narrowing the suffix would raise the same-millisecond collision rate, + // and a collision merges two concurrent calls' audit trails — the exact + // failure this module exists to prevent. + const highest = newCorrelationId( + () => 1, + () => 1 - Number.EPSILON, + ); + + expect(highest.split("-")[2]).toBe("zzzz"); + }); }); describe("isCorrelationId", () => { diff --git a/mcp/src/correlation.ts b/mcp/src/correlation.ts index 82949fee..e8b80bab 100644 --- a/mcp/src/correlation.ts +++ b/mcp/src/correlation.ts @@ -45,8 +45,16 @@ export const correlationStorage = new AsyncLocalStorage(); * * Format: `mv-