Skip to content

Commit 086b2ff

Browse files
committed
McpAPI schema fixes
1 parent 1835fcc commit 086b2ff

File tree

2 files changed

+76
-2
lines changed

2 files changed

+76
-2
lines changed

convex-restapi/src/main/resources/convex/restapi/mcp/tools/prepare.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"description": "Original source expression that was prepared"
2929
},
3030
"address": {
31-
"type": "string",
31+
"type": "integer",
3232
"description": "Address that will execute the transaction"
3333
},
3434
"hash": {

convex-restapi/src/test/java/convex/restapi/test/McpTest.java

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
import org.junit.jupiter.api.Test;
1414

15+
import convex.core.data.prim.ANumeric;
16+
1517
import convex.core.init.Init;
1618
import convex.core.data.ACell;
1719
import convex.core.data.AHashMap;
@@ -34,6 +36,7 @@
3436
import convex.core.lang.Reader;
3537
import convex.core.util.JSON;
3638
import convex.restapi.mcp.McpAPI;
39+
import convex.restapi.mcp.McpTool;
3740

3841
/**
3942
* Integration tests for the MCP HTTP endpoint.
@@ -51,6 +54,9 @@ public class McpTest extends ARESTTest {
5154
private static final AString VALUE_HELLO = Strings.create("68656c6c6f");
5255
private static final AString VALUE_WORLD = Strings.create("776f726c64");
5356

57+
/** Tracks the last tool name called by makeToolCall, used for schema validation in expectResult */
58+
private String lastToolName;
59+
5460
/**
5561
* Happy-path sanity check that the MCP server exposes the required tool list.
5662
* The initialize call is special because it bootstraps protocol features.
@@ -870,6 +876,7 @@ private AMap<AString, ACell> makeToolCall(String toolName, AMap<AString, ACell>
870876
if (arguments == null) {
871877
arguments = Maps.empty();
872878
}
879+
this.lastToolName = toolName;
873880
String id = "test-" + toolName;
874881
AMap<AString, ACell> params = Maps.of(
875882
"name", toolName,
@@ -891,11 +898,72 @@ private AMap<AString, ACell> makeToolCall(String toolName, AMap<AString, ACell>
891898
return responseMap;
892899
}
893900

901+
/**
902+
* Validates that the structured content of a tool response matches the
903+
* declared outputSchema in the tool's JSON definition. Checks that each
904+
* property's actual type matches the schema type (string, object, boolean,
905+
* integer, number, array).
906+
*/
907+
private void validateOutputSchema(String toolName, AMap<AString, ACell> structured) {
908+
String resourcePath = "convex/restapi/mcp/tools/" + toolName + ".json";
909+
AMap<AString, ACell> metadata = McpTool.loadMetadata(resourcePath);
910+
911+
AMap<AString, ACell> outputSchema = RT.ensureMap(metadata.get(Strings.create("outputSchema")));
912+
if (outputSchema == null) return; // no schema to validate
913+
914+
AMap<AString, ACell> properties = RT.ensureMap(outputSchema.get(Strings.create("properties")));
915+
if (properties == null) return; // no properties declared
916+
917+
long n = properties.count();
918+
for (long i = 0; i < n; i++) {
919+
var entry = properties.entryAt(i);
920+
String fieldName = entry.getKey().toString();
921+
AMap<AString, ACell> fieldSchema = RT.ensureMap(entry.getValue());
922+
if (fieldSchema == null) continue;
923+
924+
AString typeCell = RT.ensureString(fieldSchema.get(Strings.create("type")));
925+
if (typeCell == null) continue; // no type constraint
926+
927+
String expectedType = typeCell.toString();
928+
ACell actualValue = structured.get(Strings.create(fieldName));
929+
930+
// Field may be absent (not required) - only validate if present
931+
if (actualValue == null) continue;
932+
933+
switch (expectedType) {
934+
case "string":
935+
assertTrue(actualValue instanceof AString,
936+
() -> "Tool '" + toolName + "' field '" + fieldName + "': expected string but got " + RT.getType(actualValue) + " = " + actualValue);
937+
break;
938+
case "object":
939+
assertTrue(actualValue instanceof AMap,
940+
() -> "Tool '" + toolName + "' field '" + fieldName + "': expected object but got " + RT.getType(actualValue) + " = " + actualValue);
941+
break;
942+
case "boolean":
943+
assertTrue(actualValue instanceof CVMBool,
944+
() -> "Tool '" + toolName + "' field '" + fieldName + "': expected boolean but got " + RT.getType(actualValue) + " = " + actualValue);
945+
break;
946+
case "integer":
947+
case "number":
948+
assertTrue(actualValue instanceof ANumeric,
949+
() -> "Tool '" + toolName + "' field '" + fieldName + "': expected " + expectedType + " but got " + RT.getType(actualValue) + " = " + actualValue);
950+
break;
951+
case "array":
952+
assertTrue(actualValue instanceof AVector,
953+
() -> "Tool '" + toolName + "' field '" + fieldName + "': expected array but got " + RT.getType(actualValue) + " = " + actualValue);
954+
break;
955+
default:
956+
// Unknown type in schema, skip validation
957+
break;
958+
}
959+
}
960+
}
961+
894962
/**
895963
* Common assertion path for successful tool calls. Ensures the result wrapper
896964
* is present, marks {@code isError == false}, checks that a text payload was
897965
* produced for backward compatibility, and returns the structured content map
898-
* for further inspection.
966+
* for further inspection. Also validates the output against the declared schema.
899967
*/
900968
private AMap<AString, ACell> expectResult(AMap<AString, ACell> responseMap) {
901969
assertNull(responseMap.get(McpAPI.FIELD_ERROR));
@@ -910,6 +978,12 @@ private AMap<AString, ACell> expectResult(AMap<AString, ACell> responseMap) {
910978
assertNotNull(textEntry.get(McpAPI.FIELD_TEXT));
911979
AMap<AString, ACell> structured =RT.ensureMap(result.get(McpAPI.FIELD_STRUCTURED_CONTENT));
912980
assertNotNull(structured);
981+
982+
// Validate structured content against the tool's declared outputSchema
983+
if (lastToolName != null) {
984+
validateOutputSchema(lastToolName, structured);
985+
}
986+
913987
return structured;
914988
}
915989

0 commit comments

Comments
 (0)