diff --git a/src/main/java/rife/bld/operations/McpOperation.java b/src/main/java/rife/bld/operations/McpOperation.java index 35a0c4a..6f24db6 100644 --- a/src/main/java/rife/bld/operations/McpOperation.java +++ b/src/main/java/rife/bld/operations/McpOperation.java @@ -500,6 +500,7 @@ public class McpOperation extends AbstractOperation { .set("type", "array") .object("items", i -> i.set("type", "string")) .set("description", "The command line arguments to pass to the build command")))); + t.set("outputSchema", toolOutputSchema()); if (confirmation) { t.object("annotations", a -> a .set("destructiveHint", true) @@ -510,6 +511,46 @@ public class McpOperation extends AbstractOperation { return new JsonObject().set("tools", tools); } + /** + * Part of the {@link #execute} operation, builds the output schema + * that describes the structured content of the tool call results. + *

+ * The structure captures the execution metadata that is reliably + * known for every command: the command name, the exit status of the + * build process, whether the call timed out or its output was + * truncated, and the execution duration. The console output itself + * stays in the text content. + * + * @return the output schema of the tool call results + * @since 2.4.0 + */ + protected JsonObject toolOutputSchema() { + return new JsonObject() + .set("type", "object") + .object("properties", p -> p + .object("command", c -> c + .set("type", "string") + .set("description", "The build command that was executed")) + .object("exitStatus", e -> e + .set("type", "integer") + .set("description", "The exit status of the build process, 0 for success, -1 when it couldn't be determined")) + .object("timedOut", t -> t + .set("type", "boolean") + .set("description", "Whether the command was terminated after the tool call timeout")) + .object("truncated", t -> t + .set("type", "boolean") + .set("description", "Whether the console output was truncated at the output limit")) + .object("durationMs", d -> d + .set("type", "integer") + .set("description", "The wall clock duration of the command execution in milliseconds"))) + .array("required", r -> r + .append("command") + .append("exitStatus") + .append("timedOut") + .append("truncated") + .append("durationMs")); + } + /** * Part of the {@link #execute} operation, executes a build command * for an MCP tool call. @@ -679,6 +720,10 @@ public class McpOperation extends AbstractOperation { var error = false; var timed_out = false; + // -1 signals that the exit status couldn't be determined, for + // instance when the wait for the process was interrupted + var exit_status = -1; + var start_time = System.currentTimeMillis(); try { // the process is tracked so that it's terminated when the // server shuts down @@ -743,7 +788,8 @@ public class McpOperation extends AbstractOperation { destroyProcessTree(process, descendants); } } - error = process.waitFor() != 0 || timed_out; + exit_status = process.waitFor(); + error = exit_status != 0 || timed_out; // a background child can keep the output pipe open, the join // is bounded so that it can never block the server reader.join(10_000); @@ -793,17 +839,31 @@ public class McpOperation extends AbstractOperation { } String output_text; + boolean truncated_result; synchronized (output) { - if (truncated[0]) { + truncated_result = truncated[0]; + if (truncated_result) { output.append("\nThe output was truncated after ").append(outputLimit_).append(" characters."); } output_text = output.toString(); } var error_result = error; + var timed_out_result = timed_out; + var exit_status_result = exit_status; + var duration = System.currentTimeMillis() - start_time; + // besides the console output, the result carries the execution + // metadata as structured content, matching the output schema of + // the tool listing return new JsonObject() .array("content", c -> c.object(o -> o .set("type", "text") .set("text", output_text))) + .object("structuredContent", s -> s + .set("command", command) + .set("exitStatus", exit_status_result) + .set("timedOut", timed_out_result) + .set("truncated", truncated_result) + .set("durationMs", duration)) .set("isError", error_result); } diff --git a/src/test/java/rife/bld/operations/TestMcpOperation.java b/src/test/java/rife/bld/operations/TestMcpOperation.java index 242bcf0..eacfeb3 100644 --- a/src/test/java/rife/bld/operations/TestMcpOperation.java +++ b/src/test/java/rife/bld/operations/TestMcpOperation.java @@ -321,6 +321,12 @@ public class TestMcpOperation { assertEquals("Greets the caller", hello.getString("description")); assertEquals("object", hello.getObject("inputSchema").getString("type")); assertEquals("array", hello.getObject("inputSchema").getObject("properties").getObject("arguments").getString("type")); + // the output schema describes the structured execution metadata + var output_schema = hello.getObject("outputSchema"); + assertEquals("object", output_schema.getString("type")); + assertEquals("integer", output_schema.getObject("properties").getObject("exitStatus").getString("type")); + assertEquals("boolean", output_schema.getObject("properties").getObject("timedOut").getString("type")); + assertTrue(output_schema.getArray("required").contains("exitStatus")); } @Test @@ -392,6 +398,14 @@ public class TestMcpOperation { var content = result.getArray("content").getObject(0); assertEquals("text", content.getString("type")); assertEquals("hello big world", content.getString("text").trim()); + + // the structured content carries the execution metadata + var structured = result.getObject("structuredContent"); + assertEquals("hello", structured.getString("command")); + assertEquals(0, structured.getInt("exitStatus")); + assertFalse(structured.getBoolean("timedOut")); + assertFalse(structured.getBoolean("truncated")); + assertTrue(structured.getLong("durationMs") >= 0); } @Test @@ -403,6 +417,8 @@ public class TestMcpOperation { var result = response.getObject("result"); assertTrue(result.getBoolean("isError")); assertTrue(result.getArray("content").getObject(0).getString("text").contains("boom")); + // a failed command reports its exit status in the structured content + assertEquals(1, result.getObject("structuredContent").getInt("exitStatus")); } @Test @@ -650,6 +666,8 @@ public class TestMcpOperation { var result = response.getObject("result"); assertTrue(result.getBoolean("isError")); assertTrue(result.getArray("content").getObject(0).getString("text").contains("timed out after 1 seconds")); + // the timeout is reported in the structured content + assertTrue(result.getObject("structuredContent").getBoolean("timedOut")); } @Test @@ -661,6 +679,8 @@ public class TestMcpOperation { var text = response.getObject("result").getArray("content").getObject(0).getString("text"); assertTrue(text.contains("truncated after 150 characters"), text); assertTrue(text.length() < 300, String.valueOf(text.length())); + // the truncation is reported in the structured content + assertTrue(response.getObject("result").getObject("structuredContent").getBoolean("truncated")); } @Test