Let an MCP tool answer with more than a string - #194
Open
kitakkun wants to merge 4 commits into
Open
Conversation
Base automatically changed from
feature/mcp-call-history
to
feature/ai-operation-indicator
July 28, 2026 21:29
kitakkun
force-pushed
the
feature/mcp-typed-tool-result
branch
from
July 29, 2026 04:44
bb4a276 to
5889c80
Compare
`JetWhaleMcpCommand.execute` returned a bare `String`, so a plugin tool had no
way to say it failed: an AI agent read every call as a success, including the
`{"error": ...}` payload the network tools returned when a change could not be
applied on the debuggee. Nor could a tool answer with structured JSON as such,
or with an image, which a screenshot-style plugin needs.
`execute` now returns a `JetWhaleMcpResult`, built with `text`, `json`, `image`
or `error`. The type is JetWhale's own rather than the MCP library's
`CallToolResult`, so a plugin is not exposed to that library's version churn —
`McpToolRegistry.dispatch` hands the result to `DefaultMcpServerService`, the
single place that translates it onto the wire.
A command whose answer is always text extends `JetWhaleMcpTextCommand` and
returns the string from `executeText`, so the common case stays one line.
A `JetWhaleMcpArgumentException` now becomes an `isError` result rather than an
`{"error": ...}` payload the agent could not tell apart from an answer, and a
call that no plugin instance can handle is reported the same way instead of the
literal text "null".
A plugin tool could describe its input richly through the parameter DSL but said nothing about its output, so an AI agent had to call the tool once just to learn what came back. Now that a tool can answer with `structuredContent`, emitting it without an `outputSchema` is exactly what MCP intends that field to describe. A command declares its answer with `serializableOutput<T>()`, the mirror of the input DSL's `serializable<T>()`: the output schema is derived from `T`'s serializer through the same `SerialDescriptor` walk and the same `json` instance, so the two sides cannot drift. The declaration hands back a `JetWhaleMcpOutput<T>` whose `result(value)` encodes with that same format, which is what keeps a declared schema and the payload the agent receives describing one another rather than two independent statements. Declaring nothing stays the default: a text-only tool advertises no `outputSchema`, and every existing command keeps working untouched. Late or duplicate output declarations throw for the same reason a late parameter does — the schema may already have been shown to a caller. `T` must serialize to a JSON object, which MCP requires of an output schema, so a bare list is rejected at construction rather than advertised and refused on the wire. `JetWhaleMcpToolDescriptor` carries the schema to `DefaultMcpServerService`, which narrows it onto the MCP library's `ToolSchema` and passes it to `addTool(outputSchema = ...)`. The Network Inspector's mock-configuration tools declare their answers through new `@Serializable` result types. The two transaction tools keep building their JSON by hand: a transaction carries a response, a failure, or neither, and a data class would have to advertise all three as nullable properties on every transaction.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR evolves the plugin MCP tool contract from “always returns a String” to a richer JetWhaleMcpResult shape (text / structured JSON / image / error), and wires that through the host MCP server so agents can reliably distinguish failures and consume structured outputs without leaking MCP SDK types into the plugin SDK.
Changes:
- Introduces
JetWhaleMcpResult/JetWhaleMcpContentandJetWhaleMcpTextCommand, plus output-schema support viaserializableOutput<T>(). - Updates host dispatch + SSE server translation to propagate
isError,structuredContent, and image blocks; avoids duplicating mirrored structured payloads in history. - Migrates in-repo plugins and expands tests/docs for the new result and output-schema behavior.
Reviewed changes
Copilot reviewed 26 out of 26 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpCommandsTest.kt | Updates network plugin command tests to assert JetWhaleMcpResult content/error/structured behavior. |
| jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/McpParameterDslTest.kt | Adds output-schema declaration tests and updates existing DSL tests for text-command execution. |
| jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockRulesCommand.kt | Migrates command to return structured output via serializableOutput and proper error results. |
| jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockingEnabledCommand.kt | Migrates command to structured output + error result flow. |
| jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/RemoveMockRuleCommand.kt | Migrates command to structured output + error result flow. |
| jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpResults.kt | Adds @Serializable DTOs for mock-configuration tool outputs to derive schema + payload from one type. |
| jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpJson.kt | Updates rationale comment for keeping transaction JSON hand-assembled while using DTOs for fixed-shape outputs. |
| jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpCommands.kt | Replaces JSON-string error helpers with JetWhaleMcpResult.error for sync failures. |
| jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ListTransactionsCommand.kt | Switches listTransactions to JetWhaleMcpResult.json(...). |
| jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetTransactionCommand.kt | Switches getTransaction to JetWhaleMcpResult.json(...). |
| jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetMockConfigCommand.kt | Declares structured output schema and returns structured results via serializableOutput. |
| jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ClearTransactionsCommand.kt | Declares structured output schema and returns structured results via serializableOutput. |
| jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/AddMockRuleCommand.kt | Declares structured output schema and returns created rule as structured result. |
| jetwhale-plugins/network/host/build.gradle.kts | Enables kotlinx serialization compiler plugin to support new @Serializable output DTOs. |
| jetwhale-plugins/example/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/example/host/ExampleHostPluginFactory.kt | Migrates example MCP tools to JetWhaleMcpResult and JetWhaleMcpTextCommand, including proper failures. |
| jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistryTest.kt | Adds dispatch behavior tests for returning results vs converting thrown failures, plus unroutable behavior. |
| jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt | Adds end-to-end SSE tests for error flagging, structuredContent, image content, outputSchema, and history rendering. |
| jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistry.kt | Changes dispatch to return JetWhaleMcpResult? and converts thrown JetWhaleMcpException into failed results. |
| jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt | Plumbs outputSchema through tool registration and avoids duplicating mirrored structured payloads in history. |
| jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolExtensions.kt | Adds JetWhaleMcpResult.toCallToolResult() and JsonObject.toToolSchema() bridge functions. |
| jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerService.kt | Propagates plugin outputSchema to MCP tool list and returns error results for unroutable plugin calls. |
| jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult.kt | Introduces the SDK-owned result/content types and factory API. |
| jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt | Changes execute to return JetWhaleMcpResult, adds JetWhaleMcpTextCommand, output declaration, and exception hierarchy. |
| jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCapablePlugin.kt | Extends tool descriptor with outputSchema to advertise structured outputs. |
| jetwhale-host-sdk/api/jetwhale-host-sdk.api | Updates published API surface to reflect new result types, text command, and outputSchema additions. |
| docs/guide/developing-plugins.md | Documents result factories, error reporting, and output-schema declaration for plugin authors. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+62
to
+67
| override fun hashCode(): Int { | ||
| var result = content.hashCode() | ||
| result = 31 * result + structuredContent.hashCode() | ||
| result = 31 * result + isError.hashCode() | ||
| return result | ||
| } |
| * applied it — the caller is told so rather than reading a success payload. | ||
| */ | ||
| @OptIn(ExperimentalJetWhaleApi::class) | ||
| internal fun syncErrorResult(failure: JetWhaleMessagingException): JetWhaleMcpResult = JetWhaleMcpResult.error("failed to apply on the debuggee: ${failure.message}") |
| JetWhaleMcpResult.json(buildJsonObject { put("pongReceived", true) }) | ||
| } catch (e: JetWhaleMessagingException) { | ||
| // An unanswered Ping is the debuggee failing to respond, not an answer of "no". | ||
| JetWhaleMcpResult.error("the debuggee did not answer Ping: ${e.message}") |
Comment on lines
251
to
+255
| val arguments = request.arguments ?: emptyMap() | ||
| val result = toolRegistry.dispatch(toolName, arguments) | ||
| CallToolResult(content = listOf(TextContent(result ?: "null"))) | ||
| // A tool is listed for as long as any session offers it, so a call naming a session | ||
| // that no longer has the plugin is a caller mistake rather than a server fault. | ||
| toolRegistry.dispatch(toolName, arguments)?.toCallToolResult() | ||
| ?: errorResult("no plugin instance handles $toolName for the requested sessionId") |
A command's only route to a failed result was `JetWhaleMcpArgumentException`, whose name says the arguments were wrong. Failures that have nothing to do with the arguments — the device disconnected, the target view is gone — had no name to reach for, so the general escape hatch was hidden behind a specific one. The Network Inspector already shows the strain: `GetTransactionCommand` and `RemoveMockRuleCommand` throw it for domain lookups that came up empty. `JetWhaleMcpException` is now the failure a command throws, with `JetWhaleMcpArgumentException` as its subclass for the argument case the accessors raise. `McpToolRegistry` catches the base, so both still become a failed `JetWhaleMcpResult` rather than an MCP-level failure, and every existing throw site keeps compiling and behaving identically. `JetWhaleMcpTextCommand.executeText` is where this matters most: whatever it returns is reported as a success, so a command that returns `"error: no such widget"` tells the agent the tool worked and that was the answer — and JetWhale's own call history records it as a success too. Its KDoc now says so and points at the throw. `JetWhaleMcpResult` stays a plain class with hand-written `equals`/`hashCode`/ `toString`. A data class would generate a public `copy()` and `componentN` even though the constructor is `internal` (KT-11914), handing plugins exactly the bypass the factories exist to prevent; a comment now records that.
The host-scoped commands landed on main from #191 while this branch was being written, so they still declare `execute(): String` — a signature this branch replaced with `execute(): JetWhaleMcpResult`. Rebasing put the two on the same tree and the module stopped compiling. They all answer with text, so `HostMcpCommand` now extends `JetWhaleMcpTextCommand` and its nine subclasses override `executeText`. Their registration converts through `toCallToolResult()` rather than wrapping a string in `TextContent` by hand, which is what lets a host command grow a structured or image answer later without touching the registration path. The catch widens from `JetWhaleMcpArgumentException` to `JetWhaleMcpException`, so a host command reporting a failure unrelated to its arguments still reaches the agent as a readable error rather than falling through to the generic `Exception` arm and being labelled with its class name. Tests call `execute` and read the text back through a new `executeForText` helper, so they assert on the same public surface an MCP client sees instead of the protected `executeText`.
kitakkun
force-pushed
the
feature/mcp-typed-tool-result
branch
from
July 29, 2026 04:53
5889c80 to
f74b547
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
JetWhaleMcpCommand.executereturned a bareString, and the host wrapped it asCallToolResult(content = listOf(TextContent(result ?: "null"))). Three things a plugin tool couldnot do:
{"error": "failed to apply on the debuggee: ..."}as an ordinary answer, which the agent had noreason to distinguish from a result.
What
executenow returns aJetWhaleMcpResult, a JetWhale-owned type. The MCP library'sCallToolResult/ContentBlockstay out of the plugin SDK, so a plugin is insulated fromio.modelcontextprotocol:kotlin-sdkversion churn.Shape:
content: List<JetWhaleMcpContent>(a sealedText/Image) +structuredContent: JsonObject?+isError: Boolean. The constructor is internal and the factories are the only way in,so a later case (audio, resource links, text alongside an image) is an added factory rather than a
breaking constructor change.
json(...)mirrors its payload into a text block as the protocol asks,so agents that read only text still get the whole answer.
Text-only commands keep their one-liner:
JetWhaleMcpTextCommandsealsexecuteand asks forexecuteText(arguments): String.Host side:
McpToolRegistry.dispatchreturnsJetWhaleMcpResult?;DefaultMcpServerServiceconverts it via a single
JetWhaleMcpResult.toCallToolResult()— text →TextContent, image →ImageContent, structured →structuredContent, failure →isError = true. That pairs with theregistrar's new
isErrorhandling, so a plugin that reports a failure is recorded as a failed callin the history pane too.
Declaring the shape of the answer
A tool could describe its input richly through the parameter DSL but said nothing about its output,
so an agent had to call it once just to learn what came back.
serializableOutput<T>()mirrors theinput DSL's
serializable<T>(): the output schema is derived fromT's serializer through the sameSerialDescriptorwalk and the samejsoninstance, so the two sides cannot drift.The declaration hands back a
JetWhaleMcpOutput<T>whoseresult(value)is the only way to buildthe matching result — so "declared schema" and "payload the agent receives" describe one another by
construction rather than by a runtime assertion.
Tmust serialize to a JSON object, which MCPrequires of an output schema, so a bare list is rejected at construction instead of being advertised
and refused on the wire.
Declaring nothing stays the default: a text-only tool advertises no
outputSchema, which is whatmost MCP servers in the wild do. The Network Inspector's mock-configuration tools declare their
answers;
listTransactions/getTransactiondeliberately do not, since a transaction carries aresponse, a failure, or neither, and a DTO would flatten that into nullable properties on every row.
Failing without blaming the arguments
JetWhaleMcpArgumentExceptionwas a command's only route to a failed result, but its name says thearguments were wrong. Failures that have nothing to do with the arguments — the device disconnected,
the target view is gone — had no name to reach for. The Network Inspector already showed the strain:
GetTransactionCommandandRemoveMockRuleCommandthrow it for domain lookups that came up empty.JetWhaleMcpExceptionis now the failure a command throws, withJetWhaleMcpArgumentExceptionasits subclass for the argument case the accessors raise.
McpToolRegistrycatches the base, so bothstill become a failed result rather than an MCP-level failure, and every existing throw site keeps
compiling and behaving identically.
This matters most on
JetWhaleMcpTextCommand.executeText: whatever it returns is reported as asuccess, so a command returning
"error: no such widget"tells the agent the tool worked and thatwas the answer — and JetWhale's own call history records it as a success too. Its KDoc now says so
and points at the throw.
JetWhaleMcpResultstays a plain class with hand-writtenequals/hashCode/toString: a dataclass would generate a public
copy()andcomponentNeven though the constructor isinternal(KT-11914), handing plugins exactly the bypass the
factories exist to prevent.
Host-scoped commands
The host-scoped commands from #191 landed on
mainwhile this branch was being written, so theystill declared
execute(): String. They all answer with text, soHostMcpCommandnow extendsJetWhaleMcpTextCommandand its nine subclasses overrideexecuteText; registration convertsthrough
toCallToolResult()rather than wrapping a string inTextContentby hand, which is whatlets a host command grow a structured or image answer later without touching the registration path.
Their catch widens to
JetWhaleMcpExceptionfor the same reason as above.Behaviour changes
JetWhaleMcpArgumentExceptionis now anisErrorresult with the message as plain text,instead of an
{"error": ...}text payload flagged as success.sessionId) is now anisErrorresult insteadof the literal text
"null".com.kitakkun.jetwhale.example.sendPingreports an unanswered Ping as a failure rather than{"pongReceived": false}.McpToolRegistrar.renderForHistoryno longer repeats a structured payload that a text blockalready spells out — without this, every
json(...)result would appear twice in call history.Migration for plugin authors
Small and mechanical (
@ExperimentalJetWhaleApisurface, so a break is in scope):execute(...): String = "text"JetWhaleMcpTextCommand+executeText(...): String, orJetWhaleMcpResult.text(...)execute(...): String = json.toString()execute(...): JetWhaleMcpResult = JetWhaleMcpResult.json(json)return errorJson("...")return JetWhaleMcpResult.error("...")throw JetWhaleMcpArgumentException("device gone")throw JetWhaleMcpException("device gone")— the argument one still works, and stays right when the arguments really are at faultAll in-repo plugins are migrated:
jetwhale-plugins/example/host(2 commands) and the 8 commandfiles in
jetwhale-plugins/network/host, whose sync failures now usesyncErrorResult(...).Tests
New coverage in
DefaultMcpServerServiceTest(end-to-end over the real SSE transport): a pluginerror result arrives with
isError, a thrownJetWhaleMcpArgumentExceptiondoes too, a structuredresult lands in
structuredContent, an image result arrives as anImageContentblock, and amirrored payload is recorded once.
McpToolRegistryTestcoversdispatchreturning the command'sresult, turning a caller mistake into a failure, and reporting an unroutable call as
null.NetworkMcpCommandsTestgains sync-failure and structured-payload assertions.McpToolRegistryTestalso covers a failure that is not about the arguments becoming a failed result. Host-command tests
read the answer back through a new
executeForTexthelper, so they assert on the same public surfacean MCP client sees rather than the protected
executeText../gradlew spotlessCheck build— BUILD SUCCESSFUL; CI green ontest,spotlessandcheck-legacy-abi.Docs
docs/guide/developing-plugins.mdgains a "What a tool answers with" section (factory table, when touse
error, theJetWhaleMcpTextCommandshortcut) and the examples are updated.