Skip to content

Let an MCP tool answer with more than a string - #194

Open
kitakkun wants to merge 4 commits into
mainfrom
feature/mcp-typed-tool-result
Open

Let an MCP tool answer with more than a string#194
kitakkun wants to merge 4 commits into
mainfrom
feature/mcp-typed-tool-result

Conversation

@kitakkun

@kitakkun kitakkun commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Why

JetWhaleMcpCommand.execute returned a bare String, and the host wrapped it as
CallToolResult(content = listOf(TextContent(result ?: "null"))). Three things a plugin tool could
not do:

  • Report failure. An AI agent saw every call as a success. The network tools returned
    {"error": "failed to apply on the debuggee: ..."} as an ordinary answer, which the agent had no
    reason to distinguish from a result.
  • Return structured JSON as such — everything was flattened to text.
  • Return an image, which a screenshot-style plugin needs.

What

execute now returns a JetWhaleMcpResult, a JetWhale-owned type. The MCP library's
CallToolResult / ContentBlock stay out of the plugin SDK, so a plugin is insulated from
io.modelcontextprotocol:kotlin-sdk version churn.

JetWhaleMcpResult.text("3 widgets are selected")
JetWhaleMcpResult.json(buildJsonObject { put("selectedCount", 3) })
JetWhaleMcpResult.image(base64Data = png, mimeType = "image/png")
JetWhaleMcpResult.error("no widget with id: $id")

Shape: content: List<JetWhaleMcpContent> (a sealed Text / 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: JetWhaleMcpTextCommand seals execute and asks for
executeText(arguments): String.

Host side: McpToolRegistry.dispatch returns JetWhaleMcpResult?; DefaultMcpServerService
converts it via a single JetWhaleMcpResult.toCallToolResult() — text → TextContent, image →
ImageContent, structured → structuredContent, failure → isError = true. That pairs with the
registrar's new isError handling, so a plugin that reports a failure is recorded as a failed call
in 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 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.

private val config = serializableOutput<MockConfig>()

override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult =
    config.result(store.current())

The declaration hands back a JetWhaleMcpOutput<T> whose result(value) is the only way to build
the matching result — so "declared schema" and "payload the agent receives" describe one another by
construction rather than by a runtime assertion. T must serialize to a JSON object, which MCP
requires 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 what
most MCP servers in the wild do. The Network Inspector's mock-configuration tools declare their
answers; listTransactions / getTransaction deliberately do not, since a transaction carries a
response, a failure, or neither, and a DTO would flatten that into nullable properties on every row.

Failing without blaming the arguments

JetWhaleMcpArgumentException was a command's only route to a failed result, but its 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. The Network Inspector already showed 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 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 a
success, so a command returning "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.

Host-scoped commands

The host-scoped commands from #191 landed on main while this branch was being written, so they
still declared execute(): String. They all answer with text, so HostMcpCommand now extends
JetWhaleMcpTextCommand and its nine subclasses override executeText; 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.
Their catch widens to JetWhaleMcpException for the same reason as above.

Behaviour changes

  • A JetWhaleMcpArgumentException is now an isError result with the message as plain text,
    instead of an {"error": ...} text payload flagged as success.
  • A call that no plugin instance can handle (stale sessionId) is now an isError result instead
    of the literal text "null".
  • com.kitakkun.jetwhale.example.sendPing reports an unanswered Ping as a failure rather than
    {"pongReceived": false}.
  • McpToolRegistrar.renderForHistory no longer repeats a structured payload that a text block
    already spells out — without this, every json(...) result would appear twice in call history.

Migration for plugin authors

Small and mechanical (@ExperimentalJetWhaleApi surface, so a break is in scope):

Before After
execute(...): String = "text" JetWhaleMcpTextCommand + executeText(...): String, or JetWhaleMcpResult.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 fault

All in-repo plugins are migrated: jetwhale-plugins/example/host (2 commands) and the 8 command
files in jetwhale-plugins/network/host, whose sync failures now use syncErrorResult(...).

Tests

New coverage in DefaultMcpServerServiceTest (end-to-end over the real SSE transport): a plugin
error result arrives with isError, a thrown JetWhaleMcpArgumentException does too, a structured
result lands in structuredContent, an image result arrives as an ImageContent block, and a
mirrored payload is recorded once. McpToolRegistryTest covers dispatch returning the command's
result, turning a caller mistake into a failure, and reporting an unroutable call as null.
NetworkMcpCommandsTest gains sync-failure and structured-payload assertions. McpToolRegistryTest
also covers a failure that is not about the arguments becoming a failed result. Host-command tests
read the answer back through a new executeForText helper, so they assert on the same public surface
an MCP client sees rather than the protected executeText.

./gradlew spotlessCheck build — BUILD SUCCESSFUL; CI green on test, spotless and
check-legacy-abi.

Docs

docs/guide/developing-plugins.md gains a "What a tool answers with" section (factory table, when to
use error, the JetWhaleMcpTextCommand shortcut) and the examples are updated.

Base automatically changed from feature/mcp-call-history to feature/ai-operation-indicator July 28, 2026 21:29
Base automatically changed from feature/ai-operation-indicator to main July 28, 2026 23:18
Copilot AI review requested due to automatic review settings July 29, 2026 04:43
@kitakkun
kitakkun force-pushed the feature/mcp-typed-tool-result branch from bb4a276 to 5889c80 Compare July 29, 2026 04:44
kitakkun added 2 commits July 29, 2026 13:45
`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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 / JetWhaleMcpContent and JetWhaleMcpTextCommand, plus output-schema support via serializableOutput<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")
kitakkun added 2 commits July 29, 2026 13:46
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
kitakkun force-pushed the feature/mcp-typed-tool-result branch from 5889c80 to f74b547 Compare July 29, 2026 04:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants