Add structured content with execution metadata to MCP tool results

This commit is contained in:
Geert Bevin 2026-07-17 21:28:18 -04:00
parent 025e68e9e6
commit 124c7695e9
2 changed files with 82 additions and 2 deletions

View file

@ -500,6 +500,7 @@ public class McpOperation extends AbstractOperation<McpOperation> {
.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<McpOperation> {
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.
* <p>
* 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<McpOperation> {
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<McpOperation> {
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<McpOperation> {
}
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);
}

View file

@ -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