1212
1313import org .junit .jupiter .api .Test ;
1414
15+ import convex .core .data .prim .ANumeric ;
16+
1517import convex .core .init .Init ;
1618import convex .core .data .ACell ;
1719import convex .core .data .AHashMap ;
3436import convex .core .lang .Reader ;
3537import convex .core .util .JSON ;
3638import 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