From d98782895c0503b4d7f3f659c62f3e9dad929710 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:38:36 +0900 Subject: [PATCH 1/4] feat(sdk): let an MCP tool answer with more than a string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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". --- docs/guide/developing-plugins.md | 41 ++++- jetwhale-host-sdk/api/jetwhale-host-sdk.api | 56 +++++++ .../host/sdk/JetWhaleMcpCapablePlugin.kt | 2 +- .../jetwhale/host/sdk/JetWhaleMcpCommand.kt | 45 ++++- .../jetwhale/host/sdk/JetWhaleMcpResult.kt | 107 ++++++++++++ .../host/mcp/DefaultMcpServerService.kt | 8 +- .../jetwhale/host/mcp/McpToolExtensions.kt | 22 +++ .../jetwhale/host/mcp/McpToolRegistrar.kt | 6 +- .../jetwhale/host/mcp/McpToolRegistry.kt | 13 +- .../host/mcp/DefaultMcpServerServiceTest.kt | 155 +++++++++++++++++- .../jetwhale/host/mcp/McpToolRegistryTest.kt | 65 +++++++- .../example/host/ExampleHostPluginFactory.kt | 24 ++- .../network/host/AddMockRuleCommand.kt | 8 +- .../network/host/ClearTransactionsCommand.kt | 3 +- .../network/host/GetMockConfigCommand.kt | 11 +- .../network/host/GetTransactionCommand.kt | 5 +- .../network/host/ListTransactionsCommand.kt | 13 +- .../network/host/NetworkMcpCommands.kt | 13 +- .../network/host/RemoveMockRuleCommand.kt | 7 +- .../network/host/SetMockRulesCommand.kt | 7 +- .../network/host/SetMockingEnabledCommand.kt | 7 +- .../network/host/McpParameterDslTest.kt | 43 ++--- .../network/host/NetworkMcpCommandsTest.kt | 65 ++++++-- 23 files changed, 627 insertions(+), 99 deletions(-) create mode 100644 jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult.kt diff --git a/docs/guide/developing-plugins.md b/docs/guide/developing-plugins.md index 82f2f3fb7..739d378ff 100644 --- a/docs/guide/developing-plugins.md +++ b/docs/guide/developing-plugins.md @@ -270,8 +270,10 @@ class InspectWidgetCommand(private val widgets: WidgetStore) : JetWhaleMcpComman private val widgetId by string("The widget ID") private val verbose by booleanOrNull("Include layout details.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String { - return widgets.describeAsJson(id = arguments[widgetId], verbose = arguments[verbose] ?: false) + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { + val widget = widgets.find(arguments[widgetId]) + ?: return JetWhaleMcpResult.error("no widget with id: ${arguments[widgetId]}") + return JetWhaleMcpResult.json(widget.describe(verbose = arguments[verbose] ?: false)) } } @@ -286,12 +288,43 @@ Things to know: - **`sessionId` is injected for you.** JetWhale adds a required `sessionId` parameter to every plugin tool's schema and routes the call to the right plugin instance, so your command runs against the correct session without handling it yourself. -- **`execute` returns a string** (plain text or JSON). Throw `JetWhaleMcpArgumentException` for - caller mistakes — it is rendered as an `{"error": ...}` payload instead of failing the server. - **Messaging works from tool handlers.** `messenger` is valid for the whole instance lifetime, so a command can `request` the agent directly. - The MCP APIs are marked `@ExperimentalJetWhaleApi` and may change between releases. +### What a tool answers with + +`execute` returns a `JetWhaleMcpResult`, built with one of four factories: + +| Factory | What the AI agent gets | +|------------------------------------|-------------------------------------------------------------------------------| +| `JetWhaleMcpResult.text(s)` | Plain text — prose, or JSON you serialized yourself. | +| `JetWhaleMcpResult.json(obj)` | A `JsonObject` as structured content, repeated as text for agents that ignore it. | +| `JetWhaleMcpResult.image(b64, mime)` | An image the agent can look at, Base64-encoded. | +| `JetWhaleMcpResult.error(message)` | A **failure**: the call is flagged so the agent corrects and retries it. | + +Report failures with `error(...)` rather than returning text that merely mentions the problem — +without the flag, the agent reads "the widget does not exist" as the tool's answer and carries on. +Throwing `JetWhaleMcpArgumentException` produces the same failed result and is the shorter path when +the mistake is spotted deep inside the command; it never fails the MCP server. + +JetWhale deliberately owns this type instead of exposing the MCP library's own result types, so your +plugin does not have to track that library's versions. + +A command that only ever answers with text can extend `JetWhaleMcpTextCommand` and return the string +directly: + +```kotlin +class DescribeWidgetCommand(private val widgets: WidgetStore) : JetWhaleMcpTextCommand() { + override val name = "com.example.myplugin.describeWidget" + override val description = "Describe the selected widget" + + private val widgetId by string("The widget ID") + + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = widgets.describe(arguments[widgetId]) +} +``` + ### Structured parameters Beyond scalars (`string`, `int`, `long`, `boolean`, `enum`), a parameter can take structured input. diff --git a/jetwhale-host-sdk/api/jetwhale-host-sdk.api b/jetwhale-host-sdk/api/jetwhale-host-sdk.api index c150d4fb1..b6b6c3c14 100644 --- a/jetwhale-host-sdk/api/jetwhale-host-sdk.api +++ b/jetwhale-host-sdk/api/jetwhale-host-sdk.api @@ -240,6 +240,35 @@ public abstract class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand { public final fun toDescriptor ()Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpToolDescriptor; } +public abstract interface class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent { +} + +public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent$Image : com/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent$Image; + public static synthetic fun copy$default (Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent$Image;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent$Image; + public fun equals (Ljava/lang/Object;)Z + public final fun getBase64Data ()Ljava/lang/String; + public final fun getMimeType ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent$Text : com/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent { + public static final field $stable I + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent$Text; + public static synthetic fun copy$default (Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent$Text;Ljava/lang/String;ILjava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent$Text; + public fun equals (Ljava/lang/Object;)Z + public final fun getText ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpParameter { public static final field $stable I public final fun getDescription ()Ljava/lang/String; @@ -271,6 +300,33 @@ public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpParameterDescriptor public fun toString ()Ljava/lang/String; } +public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult { + public static final field $stable I + public static final field Companion Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult$Companion; + public fun equals (Ljava/lang/Object;)Z + public final fun getContent ()Ljava/util/List; + public final fun getStructuredContent ()Lkotlinx/serialization/json/JsonObject; + public fun hashCode ()I + public final fun isError ()Z + public fun toString ()Ljava/lang/String; +} + +public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult$Companion { + public final fun error (Ljava/lang/String;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult; + public final fun image (Ljava/lang/String;Ljava/lang/String;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult; + public final fun json (Lkotlinx/serialization/json/JsonObject;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult; + public final fun text (Ljava/lang/String;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult; +} + +public abstract class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpTextCommand : com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand { + public static final field $stable I + public fun ()V + public fun (Lkotlinx/serialization/json/Json;)V + public synthetic fun (Lkotlinx/serialization/json/Json;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun execute (Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpArguments;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + protected abstract fun executeText (Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpArguments;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpToolDescriptor { public static final field $stable I public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCapablePlugin.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCapablePlugin.kt index f408367a5..066d8eb6b 100644 --- a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCapablePlugin.kt +++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCapablePlugin.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.json.JsonObject * The MCP server queries all active plugin instances for this interface as sessions come up, * registers each command's descriptor, and dispatches invocations to the matching command on the * correct plugin instance (keyed by pluginId + sessionId). A [JetWhaleMcpArgumentException] - * thrown by a command is rendered as an `{"error": ...}` payload instead of failing the server. + * thrown by a command becomes a failed [JetWhaleMcpResult] instead of failing the server. * * Usage: * ```kotlin diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt index d6833c827..14068ec37 100644 --- a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt +++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt @@ -32,8 +32,8 @@ import kotlin.reflect.KProperty * private val widgetId by string("The widget ID") * private val verbose by booleanOrNull("Include layout details.") * - * override suspend fun execute(arguments: JetWhaleMcpArguments): String { - * return widgets.describeAsJson(id = arguments[widgetId], verbose = arguments[verbose] ?: false) + * override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { + * return JetWhaleMcpResult.json(widgets.describe(id = arguments[widgetId], verbose = arguments[verbose] ?: false)) * } * } * ``` @@ -43,9 +43,13 @@ import kotlin.reflect.KProperty * [stringList] / [stringMap] cover the common flat containers, and [jsonObject] / [jsonArray] hand * back the raw [JsonElement] for payloads whose shape is not known ahead of time. * + * [execute] answers with a [JetWhaleMcpResult] — text, structured JSON, an image, or a failure. + * A command that only ever answers with text can extend [JetWhaleMcpTextCommand] instead and + * return the string directly. + * * Expose commands through [JetWhaleMcpCapablePlugin]. A [JetWhaleMcpArgumentException] (thrown - * by the argument accessors, or by [execute] directly for domain-level caller mistakes) is - * rendered as an `{"error": ...}` payload instead of failing the MCP server. + * by the argument accessors, or by [execute] directly for domain-level caller mistakes) becomes a + * failed [JetWhaleMcpResult] the agent can read and correct, instead of failing the MCP server. * * @param json Format used to decode [serializable] arguments and to derive their schema; also * available to [execute] for encoding results. Defaults to [DefaultArgumentJson]. Pass a custom @@ -76,9 +80,9 @@ public abstract class JetWhaleMcpCommand( /** * Executes the tool. * - * @return A result string (plain text or JSON). + * @return What the AI agent receives — build it with the [JetWhaleMcpResult] factories. */ - public abstract suspend fun execute(arguments: JetWhaleMcpArguments): String + public abstract suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult public fun toDescriptor(): JetWhaleMcpToolDescriptor { parametersSealed = true @@ -281,6 +285,35 @@ public abstract class JetWhaleMcpCommand( } } +/** + * A [JetWhaleMcpCommand] whose answer is always plain text, so it returns the string itself: + * ```kotlin + * class DescribeWidgetCommand(private val widgets: WidgetStore) : JetWhaleMcpTextCommand() { + * override val name = "com.example.myplugin.describeWidget" + * override val description = "Describe the selected widget" + * + * private val widgetId by string("The widget ID") + * + * override suspend fun executeText(arguments: JetWhaleMcpArguments): String = widgets.describe(arguments[widgetId]) + * } + * ``` + * Extend [JetWhaleMcpCommand] directly to report a failure, structured JSON, or an image. + * + * @param json Same meaning as on [JetWhaleMcpCommand], and defaulted the same way. + */ +@ExperimentalJetWhaleApi +public abstract class JetWhaleMcpTextCommand(json: Json = DefaultArgumentJson) : JetWhaleMcpCommand(json) { + final override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = JetWhaleMcpResult.text(executeText(arguments)) + + /** + * Executes the tool. + * + * @return The text handed to the AI agent. Throw [JetWhaleMcpArgumentException] to report a + * caller mistake. + */ + protected abstract suspend fun executeText(arguments: JetWhaleMcpArguments): String +} + /** * The right-hand side of a `by` parameter declaration on a [JetWhaleMcpCommand]. Registration * happens in [provideDelegate], so a parameter can only come into existence as a property diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult.kt new file mode 100644 index 000000000..2b338e495 --- /dev/null +++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult.kt @@ -0,0 +1,107 @@ +package com.kitakkun.jetwhale.host.sdk + +import kotlinx.serialization.json.JsonObject + +/** + * One block of a tool result, in the order the AI agent reads them. + * + * JetWhale owns this hierarchy instead of handing plugins the MCP library's own content types, so a + * plugin keeps compiling and loading when the host updates that library. + */ +@ExperimentalJetWhaleApi +public sealed interface JetWhaleMcpContent { + /** Text the agent reads verbatim: prose, or a JSON document that is already serialized. */ + public data class Text(val text: String) : JetWhaleMcpContent + + /** + * An image the agent can look at. + * + * @param base64Data The image bytes, Base64-encoded, with no `data:` URI prefix. + * @param mimeType The image's MIME type, e.g. `image/png`. + */ + public data class Image(val base64Data: String, val mimeType: String) : JetWhaleMcpContent +} + +/** + * What a [JetWhaleMcpCommand] hands back to the AI agent. + * + * Build one through the companion's factories rather than the constructor — that is what keeps a + * command source-compatible when the result gains a way to say something new: + * ```kotlin + * 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") + * ``` + * A command whose result is always plain text can extend [JetWhaleMcpTextCommand] and skip the + * wrapping entirely. + * + * @property content The blocks the agent reads, in order. + * @property structuredContent A machine-readable payload delivered next to [content]. Agents that + * understand it read it instead of parsing the text. + * @property isError Whether the call failed. A failed call is one the agent should correct + * and retry, not an answer — so it must be reported here rather than as text that happens to + * mention a problem. + */ +@ExperimentalJetWhaleApi +public class JetWhaleMcpResult internal constructor( + public val content: List, + public val structuredContent: JsonObject?, + public val isError: Boolean, +) { + override fun equals(other: Any?): Boolean = this === other || + ( + other is JetWhaleMcpResult && + content == other.content && + structuredContent == other.structuredContent && + isError == other.isError + ) + + override fun hashCode(): Int { + var result = content.hashCode() + result = 31 * result + structuredContent.hashCode() + result = 31 * result + isError.hashCode() + return result + } + + override fun toString(): String = "JetWhaleMcpResult(content=$content, structuredContent=$structuredContent, isError=$isError)" + + public companion object { + /** A successful result carrying [text] — prose, or JSON the command serialized itself. */ + public fun text(text: String): JetWhaleMcpResult = JetWhaleMcpResult( + content = listOf(JetWhaleMcpContent.Text(text)), + structuredContent = null, + isError = false, + ) + + /** + * A successful structured result. [json] is delivered as the call's structured content and + * repeated as a text block, so an agent that reads only text still gets the whole answer. + */ + public fun json(json: JsonObject): JetWhaleMcpResult = JetWhaleMcpResult( + content = listOf(JetWhaleMcpContent.Text(json.toString())), + structuredContent = json, + isError = false, + ) + + /** A successful result carrying a single image. @see JetWhaleMcpContent.Image */ + public fun image(base64Data: String, mimeType: String): JetWhaleMcpResult = JetWhaleMcpResult( + content = listOf(JetWhaleMcpContent.Image(base64Data = base64Data, mimeType = mimeType)), + structuredContent = null, + isError = false, + ) + + /** + * A failed call. [message] says what went wrong, and the result is flagged so the agent + * treats it as a failure to correct rather than as the tool's answer. + * + * Throwing [JetWhaleMcpArgumentException] produces the same thing, and is the shorter path + * when the mistake is detected deep inside the command. + */ + public fun error(message: String): JetWhaleMcpResult = JetWhaleMcpResult( + content = listOf(JetWhaleMcpContent.Text(message)), + structuredContent = null, + isError = true, + ) + } +} diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerService.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerService.kt index d69c2704c..0db1c7c7a 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerService.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerService.kt @@ -24,10 +24,10 @@ import io.ktor.server.sse.sse import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions import io.modelcontextprotocol.kotlin.sdk.server.SseServerTransport -import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -232,8 +232,10 @@ class DefaultMcpServerService( // Forward the arguments as raw JSON so structured (object/array) parameters keep // their shape; the command's parameter DSL decodes each value by its declared type. 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") } } } diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolExtensions.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolExtensions.kt index 2bb5f6c33..8edbc9ed8 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolExtensions.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolExtensions.kt @@ -1,7 +1,11 @@ package com.kitakkun.jetwhale.host.mcp +import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpContent +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpToolDescriptor import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.ImageContent import io.modelcontextprotocol.kotlin.sdk.types.TextContent import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import kotlinx.serialization.json.JsonObject @@ -28,6 +32,24 @@ fun JetWhaleMcpToolDescriptor.toToolSchema( required = leadingProperties.keys.toList() + parameters.filterValues { it.required }.keys, ) +/** + * Translates a plugin's result into the MCP wire type. + * + * Plugins are deliberately kept away from the MCP library's own types, so this is the single place + * where the SDK's vocabulary and the protocol's meet. + */ +@OptIn(ExperimentalJetWhaleApi::class) +fun JetWhaleMcpResult.toCallToolResult(): CallToolResult = CallToolResult( + content = content.map { block -> + when (block) { + is JetWhaleMcpContent.Text -> TextContent(block.text) + is JetWhaleMcpContent.Image -> ImageContent(data = block.base64Data, mimeType = block.mimeType) + } + }, + isError = isError, + structuredContent = structuredContent, +) + fun errorResult(message: String): CallToolResult = CallToolResult( content = listOf(TextContent(buildJsonObject { put("error", message) }.toString())), isError = true, diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt index 77de1d5aa..d876b6a7c 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt @@ -117,7 +117,8 @@ class McpToolRegistrar( * * A structured payload follows the blocks on its own line. It is the whole answer for a tool that * replies only in `structuredContent`, and it reads as the machine-readable detail behind the prose - * for a tool that sends both. + * for a tool that sends both — unless a text block already spells it out, which is what the protocol + * asks a structured tool to do for clients that read nothing else. */ private fun CallToolResult.renderForHistory(): String { val renderedBlocks = content.map { block -> @@ -126,5 +127,6 @@ private fun CallToolResult.renderForHistory(): String { else -> "<${block.type.value}>" } } - return (renderedBlocks + listOfNotNull(structuredContent?.toString())).joinToString(separator = "\n") + val structured = structuredContent?.toString()?.takeUnless { it in renderedBlocks } + return (renderedBlocks + listOfNotNull(structured)).joinToString(separator = "\n") } diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistry.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistry.kt index 5c43a2282..dd8d7f34a 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistry.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistry.kt @@ -7,14 +7,13 @@ import com.kitakkun.jetwhale.host.model.PluginInstanceService import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArgumentException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCapablePlugin +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpToolDescriptor import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put import java.util.concurrent.ConcurrentHashMap /** @@ -73,9 +72,9 @@ class McpToolRegistry(private val pluginInstanceService: PluginInstanceService) * The [arguments] map must contain a `sessionId` key that identifies the target session. * That key is stripped before forwarding to the plugin. * - * @return The result string, or null if not found or plugin returned null. + * @return The command's result, or null if the call could not be routed to a plugin instance. */ - suspend fun dispatch(toolName: String, arguments: Map): String? { + suspend fun dispatch(toolName: String, arguments: Map): JetWhaleMcpResult? { val sessionId = (arguments["sessionId"] as? JsonPrimitive)?.content ?: return null val entry = registrations[toolName] ?: return null val pluginId = entry.sessionToPlugin[sessionId] ?: return null @@ -87,9 +86,9 @@ class McpToolRegistry(private val pluginInstanceService: PluginInstanceService) return try { command.execute(JetWhaleMcpArguments(JsonObject(arguments - "sessionId"))) } catch (e: JetWhaleMcpArgumentException) { - // A caller mistake becomes a payload the AI agent can read and correct, instead of - // an MCP-level failure. - buildJsonObject { put("error", e.message.orEmpty()) }.toString() + // A caller mistake becomes a failed result the AI agent can read and correct, instead + // of an MCP-level failure. + JetWhaleMcpResult.error(e.message.orEmpty()) } } diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt index 7435e919f..3fa721464 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt @@ -5,10 +5,13 @@ import com.kitakkun.jetwhale.host.model.McpServerStatus import com.kitakkun.jetwhale.host.model.PluginInstanceEvent import com.kitakkun.jetwhale.host.model.PluginInstanceService import com.kitakkun.jetwhale.host.sdk.JetWhaleHostPlugin +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArgumentException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCapablePlugin import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpParameterDescriptor +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpTextCommand import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpToolDescriptor import dev.mokkery.answering.returns import dev.mokkery.every @@ -531,6 +534,31 @@ class DefaultMcpServerServiceTest { assertTrue("\"width\":120" in record.response, "Structured payload missing from ${record.response}") } + @Test + fun `a structured payload mirrored into text is recorded once`() = runBlocking { + val serviceWithTool = DefaultMcpServerService( + pluginInstanceService = pluginInstanceService, + mcpActivityRepository = mcpActivityRepository, + builtInTools = setOf(MirroredStructuredMcpTool("fake.mirrored")), + ) + val mirroredPort = java.net.ServerSocket(0).use { it.localPort } + serviceWithTool.start(host, mirroredPort) + // Stopping the server clears recorded activity, so the history has to be read while it runs. + val record = try { + val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$mirroredPort/sse") + try { + client.callTool("fake.mirrored", emptyMap()) + } finally { + client.close() + } + mcpActivityRepository.activityFlow.value.recentCalls.single() + } finally { + serviceWithTool.stop() + } + + assertEquals("""{"width":120}""", record.response) + } + @Test fun `a throwing tool call is recorded in history as a failure`() = runBlocking { val serviceWithTool = DefaultMcpServerService( @@ -593,6 +621,83 @@ class DefaultMcpServerServiceTest { assertEquals(testSessionId, invocation.sessionId) } + @OptIn(ExperimentalJetWhaleApi::class) + @Test + fun `a plugin error result reaches the agent flagged as an error`() = runBlocking { + val toolName = "com.example.test.fails" + val callResult = callPluginTool(FixedResultPlugin(toolName, JetWhaleMcpResult.error("no widget with id: 7")), toolName) + + assertEquals(true, callResult.isError) + assertEquals("no widget with id: 7", callResult.content.filterIsInstance().single().text) + } + + @OptIn(ExperimentalJetWhaleApi::class) + @Test + fun `a caller mistake a plugin throws reaches the agent flagged as an error`() = runBlocking { + val toolName = "com.example.test.rejects" + val callResult = callPluginTool(RejectingMcpCapablePlugin(toolName), toolName) + + // Thrown rather than returned, and it still has to arrive as a failure the agent can correct. + assertEquals(true, callResult.isError) + assertEquals("unknown widget id", callResult.content.filterIsInstance().single().text) + } + + @OptIn(ExperimentalJetWhaleApi::class) + @Test + fun `a plugin structured result arrives as structuredContent`() = runBlocking { + val toolName = "com.example.test.measures" + val payload = buildJsonObject { + put("width", 120) + put("height", 40) + } + val callResult = callPluginTool(FixedResultPlugin(toolName, JetWhaleMcpResult.json(payload)), toolName) + + assertEquals(payload, callResult.structuredContent) + assertEquals(false, callResult.isError) + // The same payload is repeated as text, for agents that read nothing else. + assertEquals(payload.toString(), callResult.content.filterIsInstance().single().text) + } + + @OptIn(ExperimentalJetWhaleApi::class) + @Test + fun `a plugin image result arrives as an image block`() = runBlocking { + val toolName = "com.example.test.captures" + val callResult = callPluginTool( + FixedResultPlugin(toolName, JetWhaleMcpResult.image(base64Data = "AAAA", mimeType = "image/png")), + toolName, + ) + + val image = callResult.content.filterIsInstance().single() + assertEquals("AAAA", image.data) + assertEquals("image/png", image.mimeType) + } + + /** + * Starts the service with [plugin] loaded for one session and calls [toolName] on it. Every + * result-shape test needs the same registration dance, and only the answer is interesting. + */ + @OptIn(ExperimentalJetWhaleApi::class) + private suspend fun callPluginTool(plugin: JetWhaleHostPlugin, toolName: String): CallToolResult { + val pluginId = "com.example.test" + val sessionId = "test-session-result" + every { pluginInstanceService.getLoadedPluginInstances() } returns listOf( + LoadedPluginInstance(pluginId, sessionId, plugin), + ) + every { pluginInstanceService.getPluginInstanceForSession(pluginId, sessionId) } returns plugin + + service.start(host, port) + return try { + val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$port/sse") + try { + client.callTool(toolName, mapOf("sessionId" to sessionId)) + } finally { + client.close() + } + } finally { + service.stop() + } + } + @Test fun `a connected client is counted until it disconnects`() = runBlocking { service.start(host, port) @@ -670,6 +775,20 @@ private class StructuredMcpTool(private val name: String) : JetWhaleMcpTool { } } +/** + * Repeats its structured payload as a text block, which is what the protocol asks a structured tool + * to do for clients that read nothing else — and what [JetWhaleMcpResult.json] produces. + */ +@OptIn(ExperimentalJetWhaleApi::class) +private class MirroredStructuredMcpTool(private val name: String) : JetWhaleMcpTool { + override fun register(registrar: McpToolRegistrar) { + val payload = buildJsonObject { put("width", 120) } + registrar.addTool(name = name, description = "Repeats its payload as text", inputSchema = ToolSchema()) { _ -> + CallToolResult(content = listOf(TextContent(payload.toString())), structuredContent = payload) + } + } +} + private class FailingMcpTool(private val name: String) : JetWhaleMcpTool { override fun register(registrar: McpToolRegistrar) { registrar.addTool(name = name, description = "Always throws", inputSchema = ToolSchema()) { _ -> @@ -683,13 +802,45 @@ private class FakeMcpCapablePlugin(private val toolName: String = "com.example.t JetWhaleMcpCapablePlugin { override val mcpCommands: List = listOf( - object : JetWhaleMcpCommand() { + object : JetWhaleMcpTextCommand() { override val name = toolName override val description = "Greet by name" private val greetName by string("Name to greet", name = "name") - override suspend fun execute(arguments: JetWhaleMcpArguments): String = "Hello, ${arguments[greetName]}!" + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = "Hello, ${arguments[greetName]}!" + }, + ) +} + +/** A plugin whose single tool always answers with [result], to check how it reaches the wire. */ +@OptIn(ExperimentalJetWhaleApi::class) +private class FixedResultPlugin(private val toolName: String, private val result: JetWhaleMcpResult) : + JetWhaleHostPlugin(), + JetWhaleMcpCapablePlugin { + + override val mcpCommands: List = listOf( + object : JetWhaleMcpCommand() { + override val name = toolName + override val description = "Answers with a fixed result" + + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = result + }, + ) +} + +/** A plugin whose single tool rejects every call the way a command reports a caller mistake. */ +@OptIn(ExperimentalJetWhaleApi::class) +private class RejectingMcpCapablePlugin(private val toolName: String) : + JetWhaleHostPlugin(), + JetWhaleMcpCapablePlugin { + + override val mcpCommands: List = listOf( + object : JetWhaleMcpTextCommand() { + override val name = toolName + override val description = "Always rejects the call" + + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = throw JetWhaleMcpArgumentException("unknown widget id") }, ) } diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistryTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistryTest.kt index 3e0754e76..70e157efd 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistryTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistryTest.kt @@ -3,17 +3,28 @@ package com.kitakkun.jetwhale.host.mcp import com.kitakkun.jetwhale.host.model.PluginInstanceService import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleHostPlugin +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArgumentException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCapablePlugin import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpContent +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpTextCommand +import dev.mokkery.answering.returns +import dev.mokkery.every import dev.mokkery.mock +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull @OptIn(ExperimentalJetWhaleApi::class) class McpToolRegistryTest { - private val registry = McpToolRegistry(mock()) + private val pluginInstanceService = mock() + + private val registry = McpToolRegistry(pluginInstanceService) @Test fun `no plugins are reported as MCP-capable before anything registers`() { @@ -75,6 +86,40 @@ class McpToolRegistryTest { assertEquals(null, registry.pluginIdFor("a.greet", "session-2")) assertEquals(null, registry.pluginIdFor("nope", "session-1")) } + + @Test + fun `dispatch hands back the command's own result`() = runBlocking { + val plugin = FakeTooledPlugin("a.greet") + registry.register("com.example.a", "session-1", plugin) + every { pluginInstanceService.getPluginInstanceForSession("com.example.a", "session-1") } returns plugin + + val result = registry.dispatch("a.greet", mapOf("sessionId" to JsonPrimitive("session-1"))) + + assertEquals(JetWhaleMcpResult.text("ok"), result) + } + + @Test + fun `dispatch turns a caller mistake into a failed result rather than throwing`() = runBlocking { + val plugin = RejectingPlugin("a.reject") + registry.register("com.example.a", "session-1", plugin) + every { pluginInstanceService.getPluginInstanceForSession("com.example.a", "session-1") } returns plugin + + val result = registry.dispatch("a.reject", mapOf("sessionId" to JsonPrimitive("session-1"))) + + assertEquals(true, result?.isError) + assertEquals(listOf(JetWhaleMcpContent.Text("no widget with id: 7")), result?.content) + } + + @Test + fun `dispatch reports an unroutable call as no result at all`() = runBlocking { + registry.register("com.example.a", "session-1", FakeTooledPlugin("a.greet")) + + // A tool nobody registered, and a session that does not have the tool: neither is a plugin + // failure, so neither may be answered with an error result the plugin never produced. + assertNull(registry.dispatch("a.missing", mapOf("sessionId" to JsonPrimitive("session-1")))) + assertNull(registry.dispatch("a.greet", mapOf("sessionId" to JsonPrimitive("session-2")))) + assertNull(registry.dispatch("a.greet", emptyMap())) + } } @OptIn(ExperimentalJetWhaleApi::class) @@ -83,10 +128,24 @@ private class FakeTooledPlugin(private vararg val toolNames: String) : JetWhaleMcpCapablePlugin { override val mcpCommands: List = toolNames.map { toolName -> - object : JetWhaleMcpCommand() { + object : JetWhaleMcpTextCommand() { override val name = toolName override val description = "Fake tool for testing" - override suspend fun execute(arguments: JetWhaleMcpArguments): String = "ok" + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = "ok" } } } + +@OptIn(ExperimentalJetWhaleApi::class) +private class RejectingPlugin(private val toolName: String) : + JetWhaleHostPlugin(), + JetWhaleMcpCapablePlugin { + + override val mcpCommands: List = listOf( + object : JetWhaleMcpCommand() { + override val name = toolName + override val description = "Always rejects the call" + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = throw JetWhaleMcpArgumentException("no widget with id: 7") + }, + ) +} diff --git a/jetwhale-plugins/example/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/example/host/ExampleHostPluginFactory.kt b/jetwhale-plugins/example/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/example/host/ExampleHostPluginFactory.kt index 085ce8217..8e7d1e4c7 100644 --- a/jetwhale-plugins/example/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/example/host/ExampleHostPluginFactory.kt +++ b/jetwhale-plugins/example/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/example/host/ExampleHostPluginFactory.kt @@ -10,6 +10,8 @@ import com.kitakkun.jetwhale.host.sdk.JetWhaleHostPluginUi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCapablePlugin import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpTextCommand import com.kitakkun.jetwhale.host.sdk.JetWhaleMessagingHostPlugin import com.kitakkun.jetwhale.plugins.example.protocol.ButtonClicked import com.kitakkun.jetwhale.plugins.example.protocol.Ping @@ -68,27 +70,23 @@ private class ExampleHostPlugin : override val mcpCommands: List = listOf( object : JetWhaleMcpCommand() { override val name = "com.kitakkun.jetwhale.example.sendPing" - override val description = "Sends a Ping request to the debuggee and returns whether a Pong reply was received." + override val description = "Sends a Ping request to the debuggee and reports whether a Pong reply came back." - override suspend fun execute(arguments: JetWhaleMcpArguments): String { - val pongReceived = try { - messenger.request(Ping) - true - } catch (e: JetWhaleMessagingException) { - false - } - return buildJsonObject { - put("pongReceived", pongReceived) - }.toString() + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = try { + messenger.request(Ping) + 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}") } }, - object : JetWhaleMcpCommand() { + object : JetWhaleMcpTextCommand() { override val name = "com.kitakkun.jetwhale.example.getEventLogs" override val description = "Returns the list of event log entries accumulated by the Example plugin." private val limit by intOrNull("Maximum number of log entries to return. Returns all entries if omitted.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun executeText(arguments: JetWhaleMcpArguments): String { val limit = arguments[this.limit] val logs = if (limit != null) eventLogs.takeLast(limit) else eventLogs.toList() return Json.encodeToJsonElement(logs).toString() diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/AddMockRuleCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/AddMockRuleCommand.kt index eadd44bdd..f5e48ec94 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/AddMockRuleCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/AddMockRuleCommand.kt @@ -3,6 +3,7 @@ package com.kitakkun.jetwhale.plugins.network.host import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.plugins.network.protocol.MockMatchType import com.kitakkun.jetwhale.plugins.network.protocol.MockMatcher import com.kitakkun.jetwhale.plugins.network.protocol.MockResponseSpec @@ -10,6 +11,7 @@ import com.kitakkun.jetwhale.plugins.network.protocol.MockRule import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessagingException import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import java.util.UUID @OptIn(ExperimentalJetWhaleApi::class) @@ -40,7 +42,7 @@ internal class AddMockRuleCommand( ) private val delayMs by longOrNull("Artificial delay before the mocked response is delivered, in milliseconds. Defaults to 0.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { val rule = MockRule( id = UUID.randomUUID().toString(), name = arguments[ruleName] ?: "", @@ -58,8 +60,8 @@ internal class AddMockRuleCommand( ), ) return when (val failure = syncMockRules(mockRules() + rule)) { - null -> Json.encodeToJsonElement(rule).toString() - else -> syncErrorJson(failure) + null -> JetWhaleMcpResult.json(Json.encodeToJsonElement(rule).jsonObject) + else -> syncErrorResult(failure) } } diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ClearTransactionsCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ClearTransactionsCommand.kt index cb430f907..8f317ae0c 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ClearTransactionsCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ClearTransactionsCommand.kt @@ -3,6 +3,7 @@ package com.kitakkun.jetwhale.plugins.network.host import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -13,5 +14,5 @@ internal class ClearTransactionsCommand( override val name = "$TOOL_PREFIX.clearTransactions" override val description = "Clears the captured HTTP transaction list." - override suspend fun execute(arguments: JetWhaleMcpArguments): String = buildJsonObject { put("clearedCount", clearTransactions()) }.toString() + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = JetWhaleMcpResult.json(buildJsonObject { put("clearedCount", clearTransactions()) }) } diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetMockConfigCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetMockConfigCommand.kt index ce204a2bc..ebca58573 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetMockConfigCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetMockConfigCommand.kt @@ -3,6 +3,7 @@ package com.kitakkun.jetwhale.plugins.network.host import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.plugins.network.protocol.MockRule import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject @@ -17,8 +18,10 @@ internal class GetMockConfigCommand( override val name = "$TOOL_PREFIX.getMockConfig" override val description = "Returns the current mock configuration: the global enabled flag and all mock rules." - override suspend fun execute(arguments: JetWhaleMcpArguments): String = buildJsonObject { - put("enabled", mockingEnabled()) - put("rules", Json.encodeToJsonElement(mockRules())) - }.toString() + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = JetWhaleMcpResult.json( + buildJsonObject { + put("enabled", mockingEnabled()) + put("rules", Json.encodeToJsonElement(mockRules())) + }, + ) } diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetTransactionCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetTransactionCommand.kt index 75bcc0119..97abff13e 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetTransactionCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetTransactionCommand.kt @@ -4,6 +4,7 @@ import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArgumentException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult @OptIn(ExperimentalJetWhaleApi::class) internal class GetTransactionCommand( @@ -15,10 +16,10 @@ internal class GetTransactionCommand( private val txId by string("The transaction id from listTransactions.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { val txId = arguments[this.txId] val transaction = transactions().firstOrNull { it.txId == txId } ?: throw JetWhaleMcpArgumentException("no transaction with txId: $txId") - return redactForMcp(transaction).toDetailJson().toString() + return JetWhaleMcpResult.json(redactForMcp(transaction).toDetailJson()) } } diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ListTransactionsCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ListTransactionsCommand.kt index 9aad5fd96..1cfe835f0 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ListTransactionsCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ListTransactionsCommand.kt @@ -4,6 +4,7 @@ import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArgumentException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -36,7 +37,7 @@ internal class ListTransactionsCommand( "Only include transactions with this HTTP method (case-insensitive).", ) - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { val urlContains = arguments[this.urlContains] val method = arguments[this.method] val limit = arguments[this.limit] @@ -72,9 +73,11 @@ internal class ListTransactionsCommand( else -> filtered.take(limit) } val nextCursor = page.lastOrNull()?.txId?.takeIf { afterTxId != null && page.size < filtered.size } - return buildJsonObject { - put("transactions", JsonArray(page.map { redactForMcp(it).toSummaryJson() })) - nextCursor?.let { put("nextCursor", it) } - }.toString() + return JetWhaleMcpResult.json( + buildJsonObject { + put("transactions", JsonArray(page.map { redactForMcp(it).toSummaryJson() })) + nextCursor?.let { put("nextCursor", it) } + }, + ) } } diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpCommands.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpCommands.kt index f15fdbfff..0ac6ef61b 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpCommands.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpCommands.kt @@ -1,13 +1,16 @@ package com.kitakkun.jetwhale.plugins.network.host +import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessagingException -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put // Shared by the network plugin's MCP command classes (one class per file in this package). internal const val TOOL_PREFIX = "com.kitakkun.jetwhale.network" -internal fun errorJson(message: String): String = buildJsonObject { put("error", message) }.toString() - -internal fun syncErrorJson(failure: JetWhaleMessagingException): String = errorJson("failed to apply on the debuggee: ${failure.message}") +/** + * The rules live on the debuggee, so a command that could not hand its change over there has not + * 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}") diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/RemoveMockRuleCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/RemoveMockRuleCommand.kt index 2a7d1df2b..d4b07493a 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/RemoveMockRuleCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/RemoveMockRuleCommand.kt @@ -4,6 +4,7 @@ import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArgumentException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.plugins.network.protocol.MockRule import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessagingException import kotlinx.serialization.json.buildJsonObject @@ -19,14 +20,14 @@ internal class RemoveMockRuleCommand( private val id by string("The rule id from getMockConfig or addMockRule.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { val id = arguments[this.id] val current = mockRules() val remaining = current.filterNot { it.id == id } if (remaining.size == current.size) throw JetWhaleMcpArgumentException("no mock rule with id: $id") return when (val failure = syncMockRules(remaining)) { - null -> buildJsonObject { put("removedId", id) }.toString() - else -> syncErrorJson(failure) + null -> JetWhaleMcpResult.json(buildJsonObject { put("removedId", id) }) + else -> syncErrorResult(failure) } } } diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockRulesCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockRulesCommand.kt index adeb0847c..499f30272 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockRulesCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockRulesCommand.kt @@ -3,6 +3,7 @@ package com.kitakkun.jetwhale.plugins.network.host import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.plugins.network.protocol.MockRule import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessagingException import kotlinx.serialization.json.Json @@ -21,11 +22,11 @@ internal class SetMockRulesCommand( private val rules by serializable>("The full list of mock rules to apply, replacing the current set.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { val newRules = arguments[rules] return when (val failure = syncMockRules(newRules)) { - null -> buildJsonObject { put("rules", Json.encodeToJsonElement(newRules)) }.toString() - else -> syncErrorJson(failure) + null -> JetWhaleMcpResult.json(buildJsonObject { put("rules", Json.encodeToJsonElement(newRules)) }) + else -> syncErrorResult(failure) } } } diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockingEnabledCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockingEnabledCommand.kt index 65c16209b..c3d9b4a61 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockingEnabledCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockingEnabledCommand.kt @@ -3,6 +3,7 @@ package com.kitakkun.jetwhale.plugins.network.host import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessagingException import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -16,11 +17,11 @@ internal class SetMockingEnabledCommand( private val enabled by boolean("true to enable mocking, false to disable.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { val enabled = arguments[this.enabled] return when (val failure = syncMockingEnabled(enabled)) { - null -> buildJsonObject { put("enabled", enabled) }.toString() - else -> syncErrorJson(failure) + null -> JetWhaleMcpResult.json(buildJsonObject { put("enabled", enabled) }) + else -> syncErrorResult(failure) } } } diff --git a/jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/McpParameterDslTest.kt b/jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/McpParameterDslTest.kt index 0534f09c1..9fb51a777 100644 --- a/jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/McpParameterDslTest.kt +++ b/jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/McpParameterDslTest.kt @@ -5,6 +5,8 @@ import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArgumentException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpContent +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpTextCommand import com.kitakkun.jetwhale.plugins.network.protocol.MockMatchType import com.kitakkun.jetwhale.plugins.network.protocol.MockMatcher import com.kitakkun.jetwhale.plugins.network.protocol.MockResponseSpec @@ -30,7 +32,10 @@ import kotlin.test.assertTrue @OptIn(ExperimentalJetWhaleApi::class, ExperimentalSerializationApi::class) class McpParameterDslTest { - private fun execute(command: JetWhaleMcpCommand, vararg args: Pair): String = runBlocking { command.execute(JetWhaleMcpArguments(JsonObject(args.toMap()))) } + private fun execute(command: JetWhaleMcpCommand, vararg args: Pair): String = runBlocking { + command.execute(JetWhaleMcpArguments(JsonObject(args.toMap()))) + .content.filterIsInstance().joinToString(separator = "\n") { it.text } + } private fun JetWhaleMcpCommand.schemaOf(parameter: String): JsonObject = toDescriptor().parameters.getValue(parameter).schema @@ -40,73 +45,73 @@ class McpParameterDslTest { private fun JsonObject.strings(key: String): List = (get(key) as JsonArray).map { (it as JsonPrimitive).content } - private class StringMapCommand : JetWhaleMcpCommand() { + private class StringMapCommand : JetWhaleMcpTextCommand() { override val name = "test.stringMap" override val description = "echoes a string map" val headers by stringMap("A string-to-string map.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String = arguments[headers].entries.joinToString(",") { "${it.key}=${it.value}" } + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = arguments[headers].entries.joinToString(",") { "${it.key}=${it.value}" } } - private class OptionalStringMapCommand : JetWhaleMcpCommand() { + private class OptionalStringMapCommand : JetWhaleMcpTextCommand() { override val name = "test.optionalStringMap" override val description = "echoes an optional string map" val headers by stringMapOrNull("An optional string-to-string map.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String = arguments[headers]?.size?.toString() ?: "absent" + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = arguments[headers]?.size?.toString() ?: "absent" } - private class StringListCommand : JetWhaleMcpCommand() { + private class StringListCommand : JetWhaleMcpTextCommand() { override val name = "test.stringList" override val description = "echoes a string list" val items by stringList("A list of strings.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String = arguments[items].joinToString(",") + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = arguments[items].joinToString(",") } - private class JsonObjectCommand : JetWhaleMcpCommand() { + private class JsonObjectCommand : JetWhaleMcpTextCommand() { override val name = "test.jsonObject" override val description = "echoes a raw json object" val payload by jsonObject("A raw JSON object.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String = arguments[payload].toString() + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = arguments[payload].toString() } - private class JsonArrayCommand : JetWhaleMcpCommand() { + private class JsonArrayCommand : JetWhaleMcpTextCommand() { override val name = "test.jsonArray" override val description = "echoes a raw json array" val payload by jsonArray("A raw JSON array.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String = arguments[payload].size.toString() + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = arguments[payload].size.toString() } - private class EnumCommand : JetWhaleMcpCommand() { + private class EnumCommand : JetWhaleMcpTextCommand() { override val name = "test.enum" override val description = "echoes an enum" val matchType by enum("How the pattern is compared.", MockMatchType.entries) - override suspend fun execute(arguments: JetWhaleMcpArguments): String = arguments[matchType].name + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = arguments[matchType].name } - private class SerializableCommand : JetWhaleMcpCommand() { + private class SerializableCommand : JetWhaleMcpTextCommand() { override val name = "test.serializable" override val description = "echoes serializable mock rules" val rules by serializable>("The mock rules to apply.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String = arguments[rules].joinToString(",") { "${it.id}:${it.matcher.matchType}" } + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = arguments[rules].joinToString(",") { "${it.id}:${it.matcher.matchType}" } } // Both the advertised schema and the decoder come from this format, so they cannot disagree. private class SnakeCaseCommand : - JetWhaleMcpCommand( + JetWhaleMcpTextCommand( Json(from = DefaultArgumentJson) { namingStrategy = JsonNamingStrategy.SnakeCase }, ) { override val name = "test.snakeCase" override val description = "echoes mock rules named in snake_case" val rules by serializable>("The mock rules to apply.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String = arguments[rules].single().matcher.urlPattern + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = arguments[rules].single().matcher.urlPattern } // PluginFrame is a sealed interface whose subclasses (including those of the nested sealed // Reply) kotlinx flattens into one set of leaves. - private class SealedCommand : JetWhaleMcpCommand() { + private class SealedCommand : JetWhaleMcpTextCommand() { override val name = "test.sealed" override val description = "echoes a plugin frame" val frame by serializable("A plugin frame.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String = arguments[frame].let { "${it::class.simpleName}:${it.pluginId}" } + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = arguments[frame].let { "${it::class.simpleName}:${it.pluginId}" } } @Test diff --git a/jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpCommandsTest.kt b/jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpCommandsTest.kt index 1a394b582..d5c95cb97 100644 --- a/jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpCommandsTest.kt +++ b/jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpCommandsTest.kt @@ -4,11 +4,15 @@ import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArgumentException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpContent +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpTextCommand import com.kitakkun.jetwhale.plugins.network.protocol.CapturedHttpRequest import com.kitakkun.jetwhale.plugins.network.protocol.MockMatchType import com.kitakkun.jetwhale.plugins.network.protocol.MockMatcher import com.kitakkun.jetwhale.plugins.network.protocol.MockResponseSpec import com.kitakkun.jetwhale.plugins.network.protocol.MockRule +import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessagingException import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -23,6 +27,8 @@ import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -36,15 +42,19 @@ class NetworkMcpCommandsTest { private fun listCommand(data: List = transactions) = ListTransactionsCommand(transactions = { data }, redactForMcp = { it }) - private fun execute(command: JetWhaleMcpCommand, vararg args: Pair): String = executeJson(command, *args.map { (key, value) -> key to JsonPrimitive(value) }.toTypedArray()) + private fun execute(command: JetWhaleMcpCommand, vararg args: Pair): JetWhaleMcpResult = executeJson(command, *args.map { (key, value) -> key to JsonPrimitive(value) }.toTypedArray()) - private fun executeJson(command: JetWhaleMcpCommand, vararg args: Pair): String = runBlocking { command.execute(JetWhaleMcpArguments(JsonObject(args.toMap()))) } + private fun executeJson(command: JetWhaleMcpCommand, vararg args: Pair): JetWhaleMcpResult = runBlocking { command.execute(JetWhaleMcpArguments(JsonObject(args.toMap()))) } - private fun txIdsOf(result: String): List = Json.parseToJsonElement(result).jsonObject + private val JetWhaleMcpResult.text: String get() = content.filterIsInstance().joinToString(separator = "\n") { it.text } + + private val JetWhaleMcpResult.json: JsonObject get() = assertNotNull(structuredContent, "Expected a structured payload in $this") + + private fun txIdsOf(result: JetWhaleMcpResult): List = result.json .getValue("transactions").jsonArray .map { it.jsonObject.getValue("txId").jsonPrimitive.content } - private fun nextCursorOf(result: String): String? = Json.parseToJsonElement(result).jsonObject["nextCursor"]?.jsonPrimitive?.content + private fun nextCursorOf(result: JetWhaleMcpResult): String? = result.json["nextCursor"]?.jsonPrimitive?.content @Test fun `listTransactions without arguments returns all oldest first`() { @@ -132,7 +142,7 @@ class NetworkMcpCommandsTest { ) val rule = synced!!.single() assertEquals(mapOf("Content-Type" to "application/json", "X-Trace" to "abc"), rule.response.headers) - assertTrue("application/json" in result, result) + assertTrue("application/json" in result.text, result.text) } @Test @@ -184,7 +194,42 @@ class NetworkMcpCommandsTest { ) val result = executeJson(command, "rules" to Json.encodeToJsonElement(edited)) assertEquals(edited, synced) - assertTrue("edited" in result, result) + assertTrue("edited" in result.text, result.text) + } + + @Test + fun `a change the debuggee did not accept is reported as a failed result`() { + val failure = JetWhaleMessagingException("connection closed") + + val setEnabled = execute(SetMockingEnabledCommand { failure }, "enabled" to "true") + assertTrue(setEnabled.isError, setEnabled.text) + assertTrue("connection closed" in setEnabled.text, setEnabled.text) + + val addRule = execute( + AddMockRuleCommand(mockRules = { emptyList() }, syncMockRules = { failure }), + "urlPattern" to "/x", + ) + assertTrue(addRule.isError, addRule.text) + + val setRules = executeJson(SetMockRulesCommand { failure }, "rules" to Json.parseToJsonElement("[]")) + assertTrue(setRules.isError, setRules.text) + + val removeRule = execute( + RemoveMockRuleCommand( + mockRules = { listOf(MockRule(id = "r1", matcher = MockMatcher(urlPattern = "/a"), response = MockResponseSpec())) }, + syncMockRules = { failure }, + ), + "id" to "r1", + ) + assertTrue(removeRule.isError, removeRule.text) + } + + @Test + fun `a change the debuggee accepted answers with a structured payload`() { + val result = execute(SetMockingEnabledCommand { null }, "enabled" to "true") + + assertFalse(result.isError) + assertEquals(buildJsonObject { put("enabled", true) }, result.structuredContent) } @Test @@ -201,11 +246,11 @@ class NetworkMcpCommandsTest { @Test fun `declaring a parameter after the schema was read fails fast`() { - val command = object : JetWhaleMcpCommand() { + val command = object : JetWhaleMcpTextCommand() { override val name = "test.late" override val description = "declares a parameter inside execute" - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun executeText(arguments: JetWhaleMcpArguments): String { val late by stringOrNull("declared too late") return late.name } @@ -218,14 +263,14 @@ class NetworkMcpCommandsTest { @Test fun `declaring the same parameter name twice fails fast`() { val exception = assertFailsWith { - object : JetWhaleMcpCommand() { + object : JetWhaleMcpTextCommand() { override val name = "test.dup" override val description = "declares the same name twice" private val first by stringOrNull("first declaration", name = "x") private val second by stringOrNull("second declaration", name = "x") - override suspend fun execute(arguments: JetWhaleMcpArguments): String = "unused" + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = "unused" } } assertTrue("declared twice" in exception.message!!, exception.message!!) From bb26f1b0784cc20b8f304c75faca3d9bdd95b97b Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Wed, 29 Jul 2026 06:33:54 +0900 Subject: [PATCH 2/4] feat(sdk): let an MCP tool declare the shape of its answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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()`, the mirror of the input DSL's `serializable()`: 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` 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. --- docs/guide/developing-plugins.md | 45 +++++++++ jetwhale-host-sdk/api/jetwhale-host-sdk.api | 17 +++- .../host/sdk/JetWhaleMcpCapablePlugin.kt | 11 ++- .../jetwhale/host/sdk/JetWhaleMcpCommand.kt | 87 ++++++++++++++++- .../host/mcp/DefaultMcpServerService.kt | 3 + .../jetwhale/host/mcp/McpToolExtensions.kt | 10 ++ .../jetwhale/host/mcp/McpToolRegistrar.kt | 9 ++ .../host/mcp/DefaultMcpServerServiceTest.kt | 89 +++++++++++++++++ .../network/host/build.gradle.kts | 1 + .../network/host/AddMockRuleCommand.kt | 7 +- .../network/host/ClearTransactionsCommand.kt | 6 +- .../network/host/GetMockConfigCommand.kt | 13 +-- .../plugins/network/host/NetworkMcpJson.kt | 7 +- .../plugins/network/host/NetworkMcpResults.kt | 40 ++++++++ .../network/host/RemoveMockRuleCommand.kt | 6 +- .../network/host/SetMockRulesCommand.kt | 8 +- .../network/host/SetMockingEnabledCommand.kt | 6 +- .../network/host/McpParameterDslTest.kt | 97 +++++++++++++++++++ 18 files changed, 421 insertions(+), 41 deletions(-) create mode 100644 jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpResults.kt diff --git a/docs/guide/developing-plugins.md b/docs/guide/developing-plugins.md index 739d378ff..a03835ab5 100644 --- a/docs/guide/developing-plugins.md +++ b/docs/guide/developing-plugins.md @@ -325,6 +325,51 @@ class DescribeWidgetCommand(private val widgets: WidgetStore) : JetWhaleMcpTextC } ``` +### Declaring what a tool returns + +A tool whose answer has a known shape declares it with `serializableOutput()`, the mirror image of +the `serializable()` parameter declarator. The declaration hands back the handle that builds the +result: + +```kotlin +@Serializable +data class WidgetDescription(val id: String, val label: String, val visible: Boolean = true) + +class InspectWidgetCommand(private val widgets: WidgetStore) : JetWhaleMcpCommand() { + override val name = "com.example.myplugin.inspectWidget" + override val description = "Inspect the selected widget" + + private val widgetId by string("The widget ID") + private val widget = serializableOutput() + + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { + val found = widgets.find(arguments[widgetId]) + ?: return JetWhaleMcpResult.error("no widget with id: ${arguments[widgetId]}") + return widget.result(WidgetDescription(id = found.id, label = found.label)) + } +} +``` + +The tool's `outputSchema` is derived from `T`'s serializer — the same rules as for a parameter, +including `@McpDescription` and "required means no default value" — and `result(...)` encodes with +the same format. The AI agent therefore knows the shape of the answer *before* it calls the tool, +instead of calling it once to find out, and what it is promised cannot drift from what it receives. + +Things to know: + +- **Declaring nothing is the default and stays valid.** A tool that declares no output advertises no + `outputSchema`, which is how it says its answer is prose for a human-like reader. Only declare an + output when the tool really does answer with one fixed structure. +- **MCP requires the output schema to describe an object**, so `T` must serialize to a JSON object. A + list or a sealed hierarchy has to be wrapped in a `@Serializable` class holding it; declaring one + directly fails at construction time rather than advertising a schema MCP rejects. +- **A failure is not the tool's answer.** A command that declares an output can still return + `JetWhaleMcpResult.error(...)` or throw `JetWhaleMcpArgumentException` — a failed call carries a + message, and the output schema does not apply to it. +- **Declare it as a property**, next to the parameters. Like a parameter, an output declared after + the schema was read (inside `execute`, say) throws rather than silently diverging from the schema + the agent was already shown, and a command has a single output. + ### Structured parameters Beyond scalars (`string`, `int`, `long`, `boolean`, `enum`), a parameter can take structured input. diff --git a/jetwhale-host-sdk/api/jetwhale-host-sdk.api b/jetwhale-host-sdk/api/jetwhale-host-sdk.api index b6b6c3c14..410becbd1 100644 --- a/jetwhale-host-sdk/api/jetwhale-host-sdk.api +++ b/jetwhale-host-sdk/api/jetwhale-host-sdk.api @@ -225,6 +225,7 @@ public abstract class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand { public static synthetic fun serializable$default (Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand;Lkotlinx/serialization/KSerializer;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpParameterDeclaration; protected final fun serializableOrNull (Lkotlinx/serialization/KSerializer;Ljava/lang/String;Ljava/lang/String;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpParameterDeclaration; public static synthetic fun serializableOrNull$default (Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand;Lkotlinx/serialization/KSerializer;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpParameterDeclaration; + protected final fun serializableOutput (Lkotlinx/serialization/KSerializer;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpOutput; protected final fun string (Ljava/lang/String;Ljava/lang/String;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpParameterDeclaration; public static synthetic fun string$default (Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpParameterDeclaration; protected final fun stringList (Ljava/lang/String;Ljava/lang/String;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpParameterDeclaration; @@ -269,6 +270,12 @@ public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent$Text : com/ public fun toString ()Ljava/lang/String; } +public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpOutput { + public static final field $stable I + public final fun getSchema ()Lkotlinx/serialization/json/JsonObject; + public final fun result (Ljava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult; +} + public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpParameter { public static final field $stable I public final fun getDescription ()Ljava/lang/String; @@ -329,16 +336,18 @@ public abstract class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpTextCommand : co public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpToolDescriptor { public static final field $stable I - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlinx/serialization/json/JsonObject;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlinx/serialization/json/JsonObject;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Ljava/util/Map; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpToolDescriptor; - public static synthetic fun copy$default (Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpToolDescriptor;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpToolDescriptor; + public final fun component4 ()Lkotlinx/serialization/json/JsonObject; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlinx/serialization/json/JsonObject;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpToolDescriptor; + public static synthetic fun copy$default (Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpToolDescriptor;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlinx/serialization/json/JsonObject;ILjava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleMcpToolDescriptor; public fun equals (Ljava/lang/Object;)Z public final fun getDescription ()Ljava/lang/String; public final fun getName ()Ljava/lang/String; + public final fun getOutputSchema ()Lkotlinx/serialization/json/JsonObject; public final fun getParameters ()Ljava/util/Map; public fun hashCode ()I public fun toString ()Ljava/lang/String; diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCapablePlugin.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCapablePlugin.kt index 066d8eb6b..27ccc7963 100644 --- a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCapablePlugin.kt +++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCapablePlugin.kt @@ -34,15 +34,20 @@ public interface JetWhaleMcpCapablePlugin { /** * Describes a single MCP tool contributed by a plugin. * - * @param name Unique tool name (no spaces; use dots as separators). - * @param description Human-readable description shown to the AI agent. - * @param parameters Parameter descriptors keyed by parameter name. + * @param name Unique tool name (no spaces; use dots as separators). + * @param description Human-readable description shown to the AI agent. + * @param parameters Parameter descriptors keyed by parameter name. + * @param outputSchema JSON Schema of the structured content the tool answers with, always an + * `object` schema as MCP requires. Null when the command declares no output, + * which is how a tool says it answers with unstructured text; both defaults + * describe the tool that declares nothing beyond its name and description. */ @ExperimentalJetWhaleApi public data class JetWhaleMcpToolDescriptor( val name: String, val description: String, val parameters: Map = emptyMap(), + val outputSchema: JsonObject? = null, ) /** diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt index 14068ec37..4af65b6e4 100644 --- a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt +++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt @@ -31,9 +31,10 @@ import kotlin.reflect.KProperty * * private val widgetId by string("The widget ID") * private val verbose by booleanOrNull("Include layout details.") + * private val widget = serializableOutput() * * override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { - * return JetWhaleMcpResult.json(widgets.describe(id = arguments[widgetId], verbose = arguments[verbose] ?: false)) + * return widget.result(widgets.describe(id = arguments[widgetId], verbose = arguments[verbose] ?: false)) * } * } * ``` @@ -47,6 +48,11 @@ import kotlin.reflect.KProperty * A command that only ever answers with text can extend [JetWhaleMcpTextCommand] instead and * return the string directly. * + * A command whose answer has a known shape declares it once with [serializableOutput], which derives + * the tool's output schema from the type and hands back the [JetWhaleMcpOutput] that builds the + * matching result. Declaring nothing means the tool answers with unstructured text and advertises no + * output schema. + * * Expose commands through [JetWhaleMcpCapablePlugin]. A [JetWhaleMcpArgumentException] (thrown * by the argument accessors, or by [execute] directly for domain-level caller mistakes) becomes a * failed [JetWhaleMcpResult] the agent can read and correct, instead of failing the MCP server. @@ -71,21 +77,24 @@ public abstract class JetWhaleMcpCommand( private val declaredParameters = mutableListOf>() + private var declaredOutput: JetWhaleMcpOutput<*>? = null + // Set once the schema has been produced (and may have been shown to a caller); late // declarations would silently diverge from it, so they throw instead. The declarations are // deliberately not readable any other way: an accidental read during construction would // observe a half-built list. - private var parametersSealed = false + private var declarationsSealed = false /** * Executes the tool. * - * @return What the AI agent receives — build it with the [JetWhaleMcpResult] factories. + * @return What the AI agent receives — build it with the [JetWhaleMcpResult] factories, or with + * [JetWhaleMcpOutput.result] when the command declares an output. */ public abstract suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult public fun toDescriptor(): JetWhaleMcpToolDescriptor { - parametersSealed = true + declarationsSealed = true return JetWhaleMcpToolDescriptor( name = name, description = description, @@ -96,6 +105,7 @@ public abstract class JetWhaleMcpCommand( required = parameter.required, ) }, + outputSchema = declaredOutput?.schema, ) } @@ -176,6 +186,39 @@ public abstract class JetWhaleMcpCommand( /** @see jsonArray */ protected fun jsonArrayOrNull(description: String, name: String? = null): JetWhaleMcpParameterDeclaration = optionalStructured(name, ARRAY_SCHEMA, description, parse = ::parseJsonArray) + // -- Output declaration ------------------------------------------------------------------- + + /** + * Declares that this command answers with the `@Serializable` type [T], and hands back the + * handle that turns a [T] into the tool's result: + * ```kotlin + * private val mockConfig = serializableOutput() + * + * override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = + * mockConfig.result(MockConfig(enabled = true, rules = rules)) + * ``` + * The tool's output schema is derived from [T]'s serializer exactly as [serializable] derives a + * parameter's, and [JetWhaleMcpOutput.result] encodes with the same [json] — so what the agent is + * promised and what it receives come from one declaration and cannot drift. + * + * MCP requires a tool's output schema to describe an object, so [T] must serialize to a JSON + * object; a list or a sealed hierarchy has to be wrapped in a class holding it. + * + * Declare this only as a property of the command, next to its parameters. Leave it out entirely + * when the tool answers with unstructured text — a tool that declares no output advertises no + * output schema, which is what an agent reading prose expects. + */ + protected inline fun serializableOutput(): JetWhaleMcpOutput = serializableOutput(serializer()) + + /** Explicit-serializer form of [serializableOutput], for types whose serializer cannot be resolved from the type argument. */ + protected fun serializableOutput(serializer: KSerializer): JetWhaleMcpOutput { + val schema = serializer.descriptor.toJsonSchema(json) + check((schema["type"] as? JsonPrimitive)?.content == "object") { + "Output type ${serializer.descriptor.serialName} of '$name' does not serialize to a JSON object, which MCP requires of a tool's output schema. Wrap it in a @Serializable class." + } + return declareOutput(JetWhaleMcpOutput(schema = schema, json = json, serializer = serializer)) + } + // -- Declaration builders ----------------------------------------------------------------- private fun requiredScalar(name: String?, schema: JsonObject, description: String, parse: (String, String) -> T): JetWhaleMcpParameterDeclaration = requiredStructured(name, schema, description) { paramName, element -> @@ -209,7 +252,7 @@ public abstract class JetWhaleMcpCommand( } internal fun declare(parameter: JetWhaleMcpParameter): JetWhaleMcpParameter { - check(!parametersSealed) { + check(!declarationsSealed) { "Parameter '${parameter.name}' was declared after the parameter list of '$name' was read. Declare parameters only as property declarations on the command, never inside execute()." } check(declaredParameters.none { it.name == parameter.name }) { @@ -219,6 +262,17 @@ public abstract class JetWhaleMcpCommand( return parameter } + private fun declareOutput(output: JetWhaleMcpOutput): JetWhaleMcpOutput { + check(!declarationsSealed) { + "The output of '$name' was declared after its schema was read. Declare the output only as a property declaration on the command, never inside execute()." + } + check(declaredOutput == null) { + "'$name' declares more than one output; a tool has a single output schema." + } + declaredOutput = output + return output + } + private fun scalarContent(name: String, element: JsonElement): String = (element as? JsonPrimitive)?.content ?: throw JetWhaleMcpArgumentException("invalid $name: expected a scalar value") @@ -359,6 +413,29 @@ public class JetWhaleMcpParameter internal constructor( internal fun extractFrom(raw: JsonObject): T = extract(raw) } +/** + * The declared output of a [JetWhaleMcpCommand]: the JSON Schema the tool advertises, and the only + * way to build a result that satisfies it. Obtained from + * [JetWhaleMcpCommand.serializableOutput]. + */ +@ExperimentalJetWhaleApi +public class JetWhaleMcpOutput internal constructor( + // JSON Schema of the tool's structured content; an object schema, as MCP requires. + public val schema: JsonObject, + private val json: Json, + private val serializer: KSerializer, +) { + /** + * A successful result carrying [value], encoded with the command's format and delivered as the + * call's structured content. + * + * A command that declares an output can still report a failure with [JetWhaleMcpResult.error] or + * a [JetWhaleMcpArgumentException]: a failed call carries a message, not the tool's answer, so + * the output schema does not apply to it. + */ + public fun result(value: T): JetWhaleMcpResult = JetWhaleMcpResult.json(json.encodeToJsonElement(serializer, value) as JsonObject) +} + /** A caller mistake in a tool invocation (missing/invalid argument, unknown id, ...). */ @ExperimentalJetWhaleApi public class JetWhaleMcpArgumentException(message: String) : Exception(message) diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerService.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerService.kt index 0db1c7c7a..ff9fb9c85 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerService.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerService.kt @@ -227,6 +227,9 @@ class DefaultMcpServerService( name = toolName, description = descriptor.description, inputSchema = inputSchema, + // A command that declares no output shape advertises none, so an agent keeps reading + // that tool's answer as text. + outputSchema = descriptor.outputSchema?.toToolSchema(), resolvePluginIdForSession = { sessionId -> toolRegistry.pluginIdFor(toolName, sessionId) }, ) { request -> // Forward the arguments as raw JSON so structured (object/array) parameters keep diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolExtensions.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolExtensions.kt index 8edbc9ed8..7a1e3b8a2 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolExtensions.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolExtensions.kt @@ -8,6 +8,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.ImageContent import io.modelcontextprotocol.kotlin.sdk.types.TextContent import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject @@ -50,6 +51,15 @@ fun JetWhaleMcpResult.toCallToolResult(): CallToolResult = CallToolResult( structuredContent = structuredContent, ) +/** + * Narrows a derived object schema onto MCP's [ToolSchema], which pins `type` to `"object"` and + * carries only the property schemas and the required list. + */ +fun JsonObject.toToolSchema(): ToolSchema = ToolSchema( + properties = this["properties"] as? JsonObject, + required = (this["required"] as? JsonArray)?.mapNotNull { it.jsonContent }, +) + fun errorResult(message: String): CallToolResult = CallToolResult( content = listOf(TextContent(buildJsonObject { put("error", message) }.toString())), isError = true, diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt index d876b6a7c..32af7e850 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistrar.kt @@ -32,6 +32,8 @@ class McpToolRegistrar( name = name, description = description, inputSchema = inputSchema, + // Built-in tools describe their answer in prose rather than as structured content. + outputSchema = null, // Tools that drive a plugin UI declare this argument; the rest report no target. resolvePluginId = { request -> request.arguments?.get("pluginId")?.jsonContent }, handler = handler, @@ -44,11 +46,15 @@ class McpToolRegistrar( * Plugin tool schemas only carry `sessionId` — the owning plugin is an implementation detail the * agent never names — so attribution has to be resolved from the session instead of read off the * arguments. Without this the plugin's own tools would be the only ones the UI cannot attribute. + * + * [outputSchema] is null for a tool whose command declares no output shape, which is how it says + * it answers with unstructured text. */ fun addPluginTool( name: String, description: String, inputSchema: ToolSchema, + outputSchema: ToolSchema?, resolvePluginIdForSession: (sessionId: String) -> String?, handler: suspend ClientConnection.(CallToolRequest) -> CallToolResult, ) { @@ -56,6 +62,7 @@ class McpToolRegistrar( name = name, description = description, inputSchema = inputSchema, + outputSchema = outputSchema, resolvePluginId = { request -> request.arguments?.get("sessionId")?.jsonContent?.let(resolvePluginIdForSession) }, @@ -67,6 +74,7 @@ class McpToolRegistrar( name: String, description: String, inputSchema: ToolSchema, + outputSchema: ToolSchema?, resolvePluginId: (CallToolRequest) -> String?, handler: suspend ClientConnection.(CallToolRequest) -> CallToolResult, ) { @@ -74,6 +82,7 @@ class McpToolRegistrar( name = name, description = description, inputSchema = inputSchema, + outputSchema = outputSchema, ) { request -> val invocationId = activityRepository.toolInvocationStarted( toolName = name, diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt index 3fa721464..b21925d0e 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt @@ -23,6 +23,7 @@ import io.modelcontextprotocol.kotlin.sdk.client.mcpSse import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.ImageContent import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.Tool import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay @@ -30,12 +31,14 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.serialization.Serializable import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -672,6 +675,71 @@ class DefaultMcpServerServiceTest { assertEquals("image/png", image.mimeType) } + @OptIn(ExperimentalJetWhaleApi::class) + @Test + fun `a declared output schema reaches the agent on the listed tool`() = runBlocking { + val toolName = "com.example.test.measures" + val tool = describePluginTool(DeclaredOutputPlugin(toolName), toolName) + + val outputSchema = assertNotNull(tool.outputSchema, "Expected an output schema on $tool") + assertEquals("object", outputSchema.type) + assertEquals(listOf("width", "height", "label"), outputSchema.properties?.keys?.toList()) + // Only the properties without a default have to be present in the answer. + assertEquals(listOf("width", "height"), outputSchema.required) + } + + @OptIn(ExperimentalJetWhaleApi::class) + @Test + fun `a command that declares no output advertises none`() = runBlocking { + val toolName = "com.example.test.greet" + val tool = describePluginTool(FakeMcpCapablePlugin(toolName), toolName) + + // A text-only tool must keep saying nothing about its output rather than promising a shape. + assertNull(tool.outputSchema) + } + + @OptIn(ExperimentalJetWhaleApi::class) + @Test + fun `a declared output answers with structured content shaped like its schema`() = runBlocking { + val toolName = "com.example.test.measures" + val callResult = callPluginTool(DeclaredOutputPlugin(toolName), toolName) + + assertEquals(false, callResult.isError) + assertEquals( + buildJsonObject { + put("width", 120) + put("height", 40) + }, + callResult.structuredContent, + ) + } + + /** + * Starts the service with [plugin] loaded for one session and reads [toolName] off the tool + * list, which is where an agent learns what the tool takes and answers with. + */ + @OptIn(ExperimentalJetWhaleApi::class) + private suspend fun describePluginTool(plugin: JetWhaleHostPlugin, toolName: String): Tool { + val pluginId = "com.example.test" + val sessionId = "test-session-schema" + every { pluginInstanceService.getLoadedPluginInstances() } returns listOf( + LoadedPluginInstance(pluginId, sessionId, plugin), + ) + every { pluginInstanceService.getPluginInstanceForSession(pluginId, sessionId) } returns plugin + + service.start(host, port) + return try { + val client = HttpClient(CIO) { install(SSE) }.mcpSse("http://$host:$port/sse") + try { + client.listTools().tools.single { it.name == toolName } + } finally { + client.close() + } + } finally { + service.stop() + } + } + /** * Starts the service with [plugin] loaded for one session and calls [toolName] on it. Every * result-shape test needs the same registration dance, and only the answer is interesting. @@ -829,6 +897,27 @@ private class FixedResultPlugin(private val toolName: String, private val result ) } +@Serializable +private data class WidgetMeasurement(val width: Int, val height: Int, val label: String = "") + +/** A plugin whose single tool declares the `@Serializable` shape of its answer. */ +@OptIn(ExperimentalJetWhaleApi::class) +private class DeclaredOutputPlugin(private val toolName: String) : + JetWhaleHostPlugin(), + JetWhaleMcpCapablePlugin { + + override val mcpCommands: List = listOf( + object : JetWhaleMcpCommand() { + override val name = toolName + override val description = "Measures the selected widget" + + private val measurement = serializableOutput() + + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = measurement.result(WidgetMeasurement(width = 120, height = 40)) + }, + ) +} + /** A plugin whose single tool rejects every call the way a command reports a caller mistake. */ @OptIn(ExperimentalJetWhaleApi::class) private class RejectingMcpCapablePlugin(private val toolName: String) : diff --git a/jetwhale-plugins/network/host/build.gradle.kts b/jetwhale-plugins/network/host/build.gradle.kts index ae2a4394b..39713a2c6 100644 --- a/jetwhale-plugins/network/host/build.gradle.kts +++ b/jetwhale-plugins/network/host/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.jvm) + alias(libs.plugins.kotlinxSerialization) alias(libs.plugins.composeCompiler) alias(libs.plugins.jetbrainsCompose) // Provides packagePlugin / installPlugin / stageDevPlugin / runJetWhale / runJetWhaleHot (published). diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/AddMockRuleCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/AddMockRuleCommand.kt index f5e48ec94..b42de77b1 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/AddMockRuleCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/AddMockRuleCommand.kt @@ -9,9 +9,6 @@ import com.kitakkun.jetwhale.plugins.network.protocol.MockMatcher import com.kitakkun.jetwhale.plugins.network.protocol.MockResponseSpec import com.kitakkun.jetwhale.plugins.network.protocol.MockRule import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessagingException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.encodeToJsonElement -import kotlinx.serialization.json.jsonObject import java.util.UUID @OptIn(ExperimentalJetWhaleApi::class) @@ -42,6 +39,8 @@ internal class AddMockRuleCommand( ) private val delayMs by longOrNull("Artificial delay before the mocked response is delivered, in milliseconds. Defaults to 0.") + private val createdRule = serializableOutput() + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { val rule = MockRule( id = UUID.randomUUID().toString(), @@ -60,7 +59,7 @@ internal class AddMockRuleCommand( ), ) return when (val failure = syncMockRules(mockRules() + rule)) { - null -> JetWhaleMcpResult.json(Json.encodeToJsonElement(rule).jsonObject) + null -> createdRule.result(rule) else -> syncErrorResult(failure) } } diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ClearTransactionsCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ClearTransactionsCommand.kt index 8f317ae0c..aa6b3064c 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ClearTransactionsCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/ClearTransactionsCommand.kt @@ -4,8 +4,6 @@ import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put @OptIn(ExperimentalJetWhaleApi::class) internal class ClearTransactionsCommand( @@ -14,5 +12,7 @@ internal class ClearTransactionsCommand( override val name = "$TOOL_PREFIX.clearTransactions" override val description = "Clears the captured HTTP transaction list." - override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = JetWhaleMcpResult.json(buildJsonObject { put("clearedCount", clearTransactions()) }) + private val cleared = serializableOutput() + + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = cleared.result(ClearedTransactionsResult(clearedCount = clearTransactions())) } diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetMockConfigCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetMockConfigCommand.kt index ebca58573..09a1694f6 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetMockConfigCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/GetMockConfigCommand.kt @@ -5,10 +5,6 @@ import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.plugins.network.protocol.MockRule -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.encodeToJsonElement -import kotlinx.serialization.json.put @OptIn(ExperimentalJetWhaleApi::class) internal class GetMockConfigCommand( @@ -18,10 +14,9 @@ internal class GetMockConfigCommand( override val name = "$TOOL_PREFIX.getMockConfig" override val description = "Returns the current mock configuration: the global enabled flag and all mock rules." - override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = JetWhaleMcpResult.json( - buildJsonObject { - put("enabled", mockingEnabled()) - put("rules", Json.encodeToJsonElement(mockRules())) - }, + private val mockConfig = serializableOutput() + + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = mockConfig.result( + MockConfigResult(enabled = mockingEnabled(), rules = mockRules()), ) } diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpJson.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpJson.kt index 95033618a..4899d772a 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpJson.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpJson.kt @@ -11,8 +11,11 @@ import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject -// This module compiles without the kotlinx-serialization compiler plugin, so MCP results are -// assembled with the JsonObject builders instead of @Serializable DTOs. +// The transaction views stay hand-assembled rather than declared as `@Serializable` result types: +// their keys are conditional (a transaction carries a response, a failure, or neither), which a +// data class would have to flatten into nullable properties that are advertised on every +// transaction and written even when they do not apply. The mock-configuration tools, whose answers +// have one fixed shape, declare theirs — see NetworkMcpResults.kt. /** Compact one-line-per-transaction view for listTransactions. */ internal fun HttpTransaction.toSummaryJson(): JsonObject = buildJsonObject { diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpResults.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpResults.kt new file mode 100644 index 000000000..742926104 --- /dev/null +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/NetworkMcpResults.kt @@ -0,0 +1,40 @@ +package com.kitakkun.jetwhale.plugins.network.host + +import com.kitakkun.jetwhale.annotations.McpDescription +import com.kitakkun.jetwhale.plugins.network.protocol.MockRule +import kotlinx.serialization.Serializable + +// Answer shapes of the mock-configuration tools. Each is declared as a command's output, so the +// schema an AI agent reads and the payload it receives are derived from the same type. + +@Serializable +internal data class MockConfigResult( + @McpDescription("Whether response mocking is enabled globally on the debuggee.") + val enabled: Boolean, + @McpDescription("Every configured mock rule, in the order they are matched.") + val rules: List, +) + +@Serializable +internal data class MockRulesResult( + @McpDescription("The mock rules now in effect on the debuggee.") + val rules: List, +) + +@Serializable +internal data class MockingEnabledResult( + @McpDescription("Whether response mocking is now enabled globally on the debuggee.") + val enabled: Boolean, +) + +@Serializable +internal data class RemovedMockRuleResult( + @McpDescription("Id of the mock rule that was removed.") + val removedId: String, +) + +@Serializable +internal data class ClearedTransactionsResult( + @McpDescription("How many captured transactions were discarded.") + val clearedCount: Int, +) diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/RemoveMockRuleCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/RemoveMockRuleCommand.kt index d4b07493a..9d227b738 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/RemoveMockRuleCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/RemoveMockRuleCommand.kt @@ -7,8 +7,6 @@ import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.plugins.network.protocol.MockRule import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessagingException -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put @OptIn(ExperimentalJetWhaleApi::class) internal class RemoveMockRuleCommand( @@ -20,13 +18,15 @@ internal class RemoveMockRuleCommand( private val id by string("The rule id from getMockConfig or addMockRule.") + private val removed = serializableOutput() + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { val id = arguments[this.id] val current = mockRules() val remaining = current.filterNot { it.id == id } if (remaining.size == current.size) throw JetWhaleMcpArgumentException("no mock rule with id: $id") return when (val failure = syncMockRules(remaining)) { - null -> JetWhaleMcpResult.json(buildJsonObject { put("removedId", id) }) + null -> removed.result(RemovedMockRuleResult(removedId = id)) else -> syncErrorResult(failure) } } diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockRulesCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockRulesCommand.kt index 499f30272..08434dc54 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockRulesCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockRulesCommand.kt @@ -6,10 +6,6 @@ import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.plugins.network.protocol.MockRule import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessagingException -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.encodeToJsonElement -import kotlinx.serialization.json.put @OptIn(ExperimentalJetWhaleApi::class) internal class SetMockRulesCommand( @@ -22,10 +18,12 @@ internal class SetMockRulesCommand( private val rules by serializable>("The full list of mock rules to apply, replacing the current set.") + private val appliedRules = serializableOutput() + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { val newRules = arguments[rules] return when (val failure = syncMockRules(newRules)) { - null -> JetWhaleMcpResult.json(buildJsonObject { put("rules", Json.encodeToJsonElement(newRules)) }) + null -> appliedRules.result(MockRulesResult(rules = newRules)) else -> syncErrorResult(failure) } } diff --git a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockingEnabledCommand.kt b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockingEnabledCommand.kt index c3d9b4a61..cdd63f919 100644 --- a/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockingEnabledCommand.kt +++ b/jetwhale-plugins/network/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/network/host/SetMockingEnabledCommand.kt @@ -5,8 +5,6 @@ import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessagingException -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put @OptIn(ExperimentalJetWhaleApi::class) internal class SetMockingEnabledCommand( @@ -17,10 +15,12 @@ internal class SetMockingEnabledCommand( private val enabled by boolean("true to enable mocking, false to disable.") + private val mockingEnabled = serializableOutput() + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult { val enabled = arguments[this.enabled] return when (val failure = syncMockingEnabled(enabled)) { - null -> JetWhaleMcpResult.json(buildJsonObject { put("enabled", enabled) }) + null -> mockingEnabled.result(MockingEnabledResult(enabled = enabled)) else -> syncErrorResult(failure) } } diff --git a/jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/McpParameterDslTest.kt b/jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/McpParameterDslTest.kt index 9fb51a777..c5802898d 100644 --- a/jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/McpParameterDslTest.kt +++ b/jetwhale-plugins/network/host/src/test/kotlin/com/kitakkun/jetwhale/plugins/network/host/McpParameterDslTest.kt @@ -6,6 +6,7 @@ import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArgumentException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpContent +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpTextCommand import com.kitakkun.jetwhale.plugins.network.protocol.MockMatchType import com.kitakkun.jetwhale.plugins.network.protocol.MockMatcher @@ -27,6 +28,7 @@ import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -324,4 +326,99 @@ class McpParameterDslTest { } assertTrue("invalid rules" in exception.message!!, exception.message!!) } + + // -- Output declaration --------------------------------------------------------------------- + + private class OutputCommand : JetWhaleMcpCommand() { + override val name = "test.output" + override val description = "answers with a mock configuration" + private val mockConfig = serializableOutput() + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = mockConfig.result( + MockConfigResult(enabled = true, rules = listOf(MockRule(id = "r1", matcher = MockMatcher(urlPattern = "/api"), response = MockResponseSpec()))), + ) + } + + // The output schema follows the command's format for the same reason a parameter's does. + private class SnakeCaseOutputCommand : JetWhaleMcpCommand(Json(from = DefaultArgumentJson) { namingStrategy = JsonNamingStrategy.SnakeCase }) { + override val name = "test.snakeCaseOutput" + override val description = "answers with a snake_case mock configuration" + private val mockConfig = serializableOutput() + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = mockConfig.result(MockConfigResult(enabled = false, rules = emptyList())) + } + + @Test + fun `the derived output schema advertises the properties and the ones without defaults`() { + val schema = assertNotNull(OutputCommand().toDescriptor().outputSchema) + assertEquals("object", (schema.getValue("type") as JsonPrimitive).content) + assertEquals(listOf("enabled", "rules"), schema.obj("properties").keys.toList()) + assertEquals(listOf("enabled", "rules"), schema.strings("required")) + + val rule = schema.property("rules").obj("items") + assertEquals(listOf("id", "matcher", "response"), rule.strings("required")) + assertEquals( + "Whether response mocking is enabled globally on the debuggee.", + (schema.property("enabled").getValue("description") as JsonPrimitive).content, + ) + } + + @Test + fun `a declared output answers with structured content matching its schema`() { + val result = runBlocking { OutputCommand().execute(JetWhaleMcpArguments(JsonObject(emptyMap()))) } + val payload = assertNotNull(result.structuredContent) + assertEquals(setOf("enabled", "rules"), payload.keys) + assertTrue(payload.getValue("enabled") is JsonPrimitive) + // The same payload is repeated as text, which is what JetWhaleMcpResult.json produces. + assertEquals(payload.toString(), execute(OutputCommand())) + } + + @Test + fun `a command that declares no output advertises none`() { + assertNull(StringMapCommand().toDescriptor().outputSchema) + } + + @Test + fun `a custom format drives both the advertised output names and the encoding`() { + val command = SnakeCaseOutputCommand() + val rule = assertNotNull(command.toDescriptor().outputSchema).property("rules").obj("items") + assertEquals(listOf("method", "url_pattern", "match_type"), rule.property("matcher").obj("properties").keys.toList()) + } + + @Test + fun `an output type that is not a JSON object fails fast`() { + val exception = assertFailsWith { + object : JetWhaleMcpCommand() { + override val name = "test.listOutput" + override val description = "tries to answer with a bare list" + private val rules = serializableOutput>() + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = rules.result(emptyList()) + } + } + assertTrue("does not serialize to a JSON object" in exception.message!!, exception.message!!) + } + + @Test + fun `declaring a second output fails fast`() { + val exception = assertFailsWith { + object : JetWhaleMcpCommand() { + override val name = "test.twoOutputs" + override val description = "declares two outputs" + val config = serializableOutput() + val rules = serializableOutput() + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = config.result(MockConfigResult(enabled = true, rules = emptyList())) + } + } + assertTrue("more than one output" in exception.message!!, exception.message!!) + } + + @Test + fun `declaring an output after the schema was read fails fast`() { + val command = object : JetWhaleMcpCommand() { + override val name = "test.lateOutput" + override val description = "declares its output inside execute" + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = serializableOutput().result(MockRulesResult(rules = emptyList())) + } + command.toDescriptor() + val exception = assertFailsWith { execute(command) } + assertTrue("after its schema was read" in exception.message!!, exception.message!!) + } } From 04d8df88481aaefd3c8d8fb6c5be8302dc59b8f1 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:42:56 +0900 Subject: [PATCH 3/4] feat(sdk): give a tool a way to fail that is not about its arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/guide/developing-plugins.md | 13 +++++-- jetwhale-host-sdk/api/jetwhale-host-sdk.api | 7 +++- .../jetwhale/host/sdk/JetWhaleMcpCommand.kt | 37 ++++++++++++++----- .../jetwhale/host/sdk/JetWhaleMcpResult.kt | 6 ++- .../jetwhale/host/mcp/McpToolRegistry.kt | 9 +++-- .../jetwhale/host/mcp/McpToolRegistryTest.kt | 27 ++++++++++++++ 6 files changed, 79 insertions(+), 20 deletions(-) diff --git a/docs/guide/developing-plugins.md b/docs/guide/developing-plugins.md index a03835ab5..9467e89da 100644 --- a/docs/guide/developing-plugins.md +++ b/docs/guide/developing-plugins.md @@ -305,8 +305,10 @@ Things to know: Report failures with `error(...)` rather than returning text that merely mentions the problem — without the flag, the agent reads "the widget does not exist" as the tool's answer and carries on. -Throwing `JetWhaleMcpArgumentException` produces the same failed result and is the shorter path when -the mistake is spotted deep inside the command; it never fails the MCP server. +Throwing `JetWhaleMcpException` produces the same failed result and is the shorter path when the +failure is spotted deep inside the command; it never fails the MCP server. Throw its narrower +subclass `JetWhaleMcpArgumentException` when the arguments are what went wrong — the argument +accessors already do. JetWhale deliberately owns this type instead of exposing the MCP library's own result types, so your plugin does not have to track that library's versions. @@ -325,6 +327,9 @@ class DescribeWidgetCommand(private val widgets: WidgetStore) : JetWhaleMcpTextC } ``` +Whatever `executeText` returns is reported as a success, so it has no way to say "this failed" — +throw `JetWhaleMcpException` for that. + ### Declaring what a tool returns A tool whose answer has a known shape declares it with `serializableOutput()`, the mirror image of @@ -364,8 +369,8 @@ Things to know: list or a sealed hierarchy has to be wrapped in a `@Serializable` class holding it; declaring one directly fails at construction time rather than advertising a schema MCP rejects. - **A failure is not the tool's answer.** A command that declares an output can still return - `JetWhaleMcpResult.error(...)` or throw `JetWhaleMcpArgumentException` — a failed call carries a - message, and the output schema does not apply to it. + `JetWhaleMcpResult.error(...)` or throw `JetWhaleMcpException` — a failed call carries a message, + and the output schema does not apply to it. - **Declare it as a property**, next to the parameters. Like a parameter, an output declared after the schema was read (inside `execute`, say) throws rather than silently diverging from the schema the agent was already shown, and a command has a single output. diff --git a/jetwhale-host-sdk/api/jetwhale-host-sdk.api b/jetwhale-host-sdk/api/jetwhale-host-sdk.api index 410becbd1..9ca282158 100644 --- a/jetwhale-host-sdk/api/jetwhale-host-sdk.api +++ b/jetwhale-host-sdk/api/jetwhale-host-sdk.api @@ -173,7 +173,7 @@ public abstract interface class com/kitakkun/jetwhale/host/sdk/JetWhaleHostPlugi public abstract fun Content (Landroidx/compose/runtime/Composer;I)V } -public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpArgumentException : java/lang/Exception { +public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpArgumentException : com/kitakkun/jetwhale/host/sdk/JetWhaleMcpException { public static final field $stable I public fun (Ljava/lang/String;)V } @@ -270,6 +270,11 @@ public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpContent$Text : com/ public fun toString ()Ljava/lang/String; } +public class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpException : java/lang/Exception { + public static final field $stable I + public fun (Ljava/lang/String;)V +} + public final class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpOutput { public static final field $stable I public final fun getSchema ()Lkotlinx/serialization/json/JsonObject; diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt index 4af65b6e4..2087a0628 100644 --- a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt +++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpCommand.kt @@ -53,9 +53,10 @@ import kotlin.reflect.KProperty * matching result. Declaring nothing means the tool answers with unstructured text and advertises no * output schema. * - * Expose commands through [JetWhaleMcpCapablePlugin]. A [JetWhaleMcpArgumentException] (thrown - * by the argument accessors, or by [execute] directly for domain-level caller mistakes) becomes a - * failed [JetWhaleMcpResult] the agent can read and correct, instead of failing the MCP server. + * Expose commands through [JetWhaleMcpCapablePlugin]. A [JetWhaleMcpException] (thrown by [execute] + * for a failure of any kind, or by the argument accessors as the narrower + * [JetWhaleMcpArgumentException]) becomes a failed [JetWhaleMcpResult] the agent can read and + * correct, instead of failing the MCP server. * * @param json Format used to decode [serializable] arguments and to derive their schema; also * available to [execute] for encoding results. Defaults to [DefaultArgumentJson]. Pass a custom @@ -362,8 +363,10 @@ public abstract class JetWhaleMcpTextCommand(json: Json = DefaultArgumentJson) : /** * Executes the tool. * - * @return The text handed to the AI agent. Throw [JetWhaleMcpArgumentException] to report a - * caller mistake. + * @return The text handed to the AI agent — always the tool's answer, never a failure. Whatever + * is returned is reported as a success, so an agent reading `"error: no such widget"` sees a + * tool that worked and answered that. Throw [JetWhaleMcpException] instead: the flag is what + * tells the agent to correct itself, not the wording. */ protected abstract suspend fun executeText(arguments: JetWhaleMcpArguments): String } @@ -430,15 +433,31 @@ public class JetWhaleMcpOutput internal constructor( * call's structured content. * * A command that declares an output can still report a failure with [JetWhaleMcpResult.error] or - * a [JetWhaleMcpArgumentException]: a failed call carries a message, not the tool's answer, so - * the output schema does not apply to it. + * a [JetWhaleMcpException]: a failed call carries a message, not the tool's answer, so the + * output schema does not apply to it. */ public fun result(value: T): JetWhaleMcpResult = JetWhaleMcpResult.json(json.encodeToJsonElement(serializer, value) as JsonObject) } -/** A caller mistake in a tool invocation (missing/invalid argument, unknown id, ...). */ +/** + * A tool call that failed: the device went away, the target no longer exists, the plugin cannot + * answer right now. [message] is handed to the AI agent as the failed call's text, so write it as + * something the agent can act on — what was wrong, and what would be right. + * + * This is the throwing counterpart of [JetWhaleMcpResult.error], and produces exactly that result. + * Throw when the failure is discovered deep inside the command; return when reporting it is the + * command's plain answer. + */ +@ExperimentalJetWhaleApi +public open class JetWhaleMcpException(message: String) : Exception(message) + +/** + * A tool call that failed because of the arguments it was given: one is missing, unparseable, or + * names something that does not exist. Thrown by the argument accessors, and worth throwing + * directly when a command's own lookup of an argument's value comes up empty. + */ @ExperimentalJetWhaleApi -public class JetWhaleMcpArgumentException(message: String) : Exception(message) +public class JetWhaleMcpArgumentException(message: String) : JetWhaleMcpException(message) /** * The raw arguments of an MCP tool call, read through the command's declared diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult.kt index 2b338e495..bffc4cf11 100644 --- a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult.kt +++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleMcpResult.kt @@ -44,6 +44,8 @@ public sealed interface JetWhaleMcpContent { * mention a problem. */ @ExperimentalJetWhaleApi +// Not a data class: the generated copy() and componentN would be public even though the constructor +// is internal, which would hand plugins the very bypass the factories exist to prevent. public class JetWhaleMcpResult internal constructor( public val content: List, public val structuredContent: JsonObject?, @@ -95,8 +97,8 @@ public class JetWhaleMcpResult internal constructor( * A failed call. [message] says what went wrong, and the result is flagged so the agent * treats it as a failure to correct rather than as the tool's answer. * - * Throwing [JetWhaleMcpArgumentException] produces the same thing, and is the shorter path - * when the mistake is detected deep inside the command. + * Throwing [JetWhaleMcpException] produces the same thing, and is the shorter path when the + * failure is discovered deep inside the command. */ public fun error(message: String): JetWhaleMcpResult = JetWhaleMcpResult( content = listOf(JetWhaleMcpContent.Text(message)), diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistry.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistry.kt index dd8d7f34a..5e1f9216d 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistry.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistry.kt @@ -4,9 +4,10 @@ import com.kitakkun.jetwhale.host.model.McpCapablePlugins import com.kitakkun.jetwhale.host.model.McpToolParameterSummary import com.kitakkun.jetwhale.host.model.McpToolSummary import com.kitakkun.jetwhale.host.model.PluginInstanceService -import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArgumentException +import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCapablePlugin +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpToolDescriptor import kotlinx.coroutines.flow.MutableStateFlow @@ -85,9 +86,9 @@ class McpToolRegistry(private val pluginInstanceService: PluginInstanceService) val command = plugin.mcpCommands.firstOrNull { it.name == toolName } ?: return null return try { command.execute(JetWhaleMcpArguments(JsonObject(arguments - "sessionId"))) - } catch (e: JetWhaleMcpArgumentException) { - // A caller mistake becomes a failed result the AI agent can read and correct, instead - // of an MCP-level failure. + } catch (e: JetWhaleMcpException) { + // A failure the command chose to report becomes a failed result the AI agent can read + // and correct, instead of an MCP-level failure. JetWhaleMcpResult.error(e.message.orEmpty()) } } diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistryTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistryTest.kt index 70e157efd..7a0abca31 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistryTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/McpToolRegistryTest.kt @@ -8,6 +8,7 @@ import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCapablePlugin import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpContent +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpTextCommand import dev.mokkery.answering.returns @@ -110,6 +111,18 @@ class McpToolRegistryTest { assertEquals(listOf(JetWhaleMcpContent.Text("no widget with id: 7")), result?.content) } + @Test + fun `dispatch turns a failure that is not about the arguments into a failed result`() = runBlocking { + val plugin = FailingPlugin("a.fail") + registry.register("com.example.a", "session-1", plugin) + every { pluginInstanceService.getPluginInstanceForSession("com.example.a", "session-1") } returns plugin + + val result = registry.dispatch("a.fail", mapOf("sessionId" to JsonPrimitive("session-1"))) + + assertEquals(true, result?.isError) + assertEquals(listOf(JetWhaleMcpContent.Text("the device disconnected")), result?.content) + } + @Test fun `dispatch reports an unroutable call as no result at all`() = runBlocking { registry.register("com.example.a", "session-1", FakeTooledPlugin("a.greet")) @@ -149,3 +162,17 @@ private class RejectingPlugin(private val toolName: String) : }, ) } + +@OptIn(ExperimentalJetWhaleApi::class) +private class FailingPlugin(private val toolName: String) : + JetWhaleHostPlugin(), + JetWhaleMcpCapablePlugin { + + override val mcpCommands: List = listOf( + object : JetWhaleMcpCommand() { + override val name = toolName + override val description = "Always fails for a reason unrelated to its arguments" + override suspend fun execute(arguments: JetWhaleMcpArguments): JetWhaleMcpResult = throw JetWhaleMcpException("the device disconnected") + }, + ) +} From f74b5475e9cd8487ad1962376769b6c6547bef17 Mon Sep 17 00:00:00 2001 From: kitakkun <48154936+kitakkun@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:53:24 +0900 Subject: [PATCH 4/4] refactor(mcp): move host-scoped commands onto the typed tool result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- .../jetwhale/host/mcp/HostMcpCommand.kt | 13 +++++------ .../host/mcp/tools/host/HostLogCommands.kt | 4 ++-- .../mcp/tools/host/HostNavigationCommand.kt | 2 +- .../host/mcp/tools/host/HostPluginCommands.kt | 6 ++--- .../mcp/tools/host/HostSettingsCommands.kt | 4 ++-- .../host/mcp/tools/host/HostStatusCommand.kt | 2 +- .../host/mcp/DefaultMcpServerServiceTest.kt | 2 ++ .../jetwhale/host/mcp/HostMcpCommandTest.kt | 4 ++-- .../mcp/tools/host/HostCommandTestSupport.kt | 19 ++++++++++++++++ .../mcp/tools/host/HostLogCommandsTest.kt | 14 ++++++------ .../tools/host/HostNavigationCommandTest.kt | 18 +++++++-------- .../mcp/tools/host/HostPluginCommandsTest.kt | 22 +++++++++---------- .../tools/host/HostSettingsCommandsTest.kt | 22 +++++++++---------- .../mcp/tools/host/HostStatusCommandTest.kt | 14 ++++++------ 14 files changed, 82 insertions(+), 64 deletions(-) create mode 100644 jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostCommandTestSupport.kt diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/HostMcpCommand.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/HostMcpCommand.kt index 13f45e5ae..ac2ac62b2 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/HostMcpCommand.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/HostMcpCommand.kt @@ -1,10 +1,8 @@ package com.kitakkun.jetwhale.host.mcp -import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArgumentException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments -import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpCommand -import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult -import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpException +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpTextCommand import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.JsonObject @@ -28,7 +26,7 @@ import kotlinx.serialization.json.JsonObject * ``` */ abstract class HostMcpCommand : - JetWhaleMcpCommand(), + JetWhaleMcpTextCommand(), JetWhaleMcpTool { // Lazy, never an eager property: base-class initializers run before the subclass declares its @@ -43,11 +41,10 @@ abstract class HostMcpCommand : inputSchema = descriptor.toToolSchema(), ) { request -> try { - val result = execute(JetWhaleMcpArguments(JsonObject(request.arguments ?: emptyMap()))) - CallToolResult(content = listOf(TextContent(result))) + execute(JetWhaleMcpArguments(JsonObject(request.arguments ?: emptyMap()))).toCallToolResult() } catch (e: CancellationException) { throw e - } catch (e: JetWhaleMcpArgumentException) { + } catch (e: JetWhaleMcpException) { errorResult(e.message.orEmpty()) } catch (e: Exception) { // Host tools do real I/O — downloads, socket binds, plugin loading. A failure has to diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostLogCommands.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostLogCommands.kt index b546827e9..f6a6a023b 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostLogCommands.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostLogCommands.kt @@ -32,7 +32,7 @@ class GetLogsCommand( private val level by enumOrNull("Only return entries logged at this level.", LogLevel.entries) private val contains by stringOrNull("Only return entries whose message contains this substring, case-insensitively.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun executeText(arguments: JetWhaleMcpArguments): String { val requestedLimit = arguments[limit] ?: DEFAULT_LIMIT if (requestedLimit !in 1..MAX_LIMIT) { throw JetWhaleMcpArgumentException("invalid limit: expected 1..$MAX_LIMIT but was $requestedLimit") @@ -65,7 +65,7 @@ class ClearLogsCommand( override val description: String = "Host-wide: discards every captured host log entry. Clear before reproducing an issue so that jetwhale.getLogs afterwards shows only what the reproduction produced." - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun executeText(arguments: JetWhaleMcpArguments): String { val cleared = logCaptureService.logs.value.size logCaptureService.clearLogs() return Json.encodeToString(ClearLogsResult(cleared = cleared)) diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostNavigationCommand.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostNavigationCommand.kt index 7302aaf9e..a48ee9f5e 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostNavigationCommand.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostNavigationCommand.kt @@ -47,7 +47,7 @@ class HostNavigationCommand( private val sessionId by stringOrNull("Only for PLUGIN. Defaults to the session already selected in the drawer.") private val settingsSection by enumOrNull("Only for SETTINGS. Defaults to GENERAL.", HostSettingsSection.entries) - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun executeText(arguments: JetWhaleMcpArguments): String { val request = arguments.toRequest() hostNavigationService.navigate(request) diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostPluginCommands.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostPluginCommands.kt index c174928fa..329b22163 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostPluginCommands.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostPluginCommands.kt @@ -41,7 +41,7 @@ class ListInstalledPluginsCommand( override val description: String = "Host-wide: lists every plugin installed into the debug tool and whether it is enabled, plus the official plugins that could still be installed and any jar that failed to load or is awaiting trust. Use jetwhale.listPlugins instead to see what a particular debug session advertises." - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun executeText(arguments: JetWhaleMcpArguments): String { val loaded = pluginFactoryRepository.loadedPlugins val enabledPluginIds = enabledPluginsRepository.enabledPluginIdsFlow.first() @@ -91,7 +91,7 @@ class SetPluginEnabledCommand( private val pluginId by string("The plugin to toggle; from jetwhale.listInstalledPlugins.") private val enabled by boolean("True to enable the plugin, false to disable it.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun executeText(arguments: JetWhaleMcpArguments): String { val targetPluginId = arguments[pluginId] if (targetPluginId !in pluginFactoryRepository.loadedPlugins) { throw JetWhaleMcpArgumentException("invalid pluginId: '$targetPluginId' is not installed. See jetwhale.listInstalledPlugins.") @@ -140,7 +140,7 @@ class InstallOfficialPluginCommand( private val pluginId by string("The official plugin to install; from the availableOfficial list of jetwhale.listInstalledPlugins.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun executeText(arguments: JetWhaleMcpArguments): String { if (!settingsRepository.mcpPluginInstallAllowedFlow.value) { // Listed but refused on purpose: an agent that can read the reason can tell the user // which switch to flip, where a hidden tool would only produce confusion. diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostSettingsCommands.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostSettingsCommands.kt index e96958b83..17fa222e3 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostSettingsCommands.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostSettingsCommands.kt @@ -36,7 +36,7 @@ class UpdateSettingsCommand( private val persistData by booleanOrNull("Whether captured debug data survives a host restart.") private val restartDebugServer by booleanOrNull("Whether to restart the debug server so ws/wss changes take effect now. Defaults to true when a ws/wss setting changed.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun executeText(arguments: JetWhaleMcpArguments): String { // Validate every port before writing any of them, so a bad argument late in the list cannot // leave the settings half-applied. val newServerPort = arguments[serverPort]?.requireValidPort("serverPort") @@ -121,7 +121,7 @@ class RestartDebugServerCommand( override val description: String = "Host-wide: stops and restarts the debug WebSocket server that agents connect to. This disconnects every session — every sessionId you hold becomes invalid and each app has to reconnect." - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun executeText(arguments: JetWhaleMcpArguments): String { restartDebugServer(settingsRepository, debugWebSocketServer) val status = debugWebSocketServer.statusFlow.value.toJson() return Json.encodeToString( diff --git a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostStatusCommand.kt b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostStatusCommand.kt index f40efad00..15feef195 100644 --- a/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostStatusCommand.kt +++ b/jetwhale-host/core/mcp/src/main/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostStatusCommand.kt @@ -44,7 +44,7 @@ class HostStatusCommand( override val description: String = "Host-wide: one snapshot of the debug tool — its version, both servers, how many sessions and plugins are live, and the current settings. Call this first to orient yourself before any other jetwhale tool." - override suspend fun execute(arguments: JetWhaleMcpArguments): String { + override suspend fun executeText(arguments: JetWhaleMcpArguments): String { val sessions = debugSessionRepository.debugSessionsFlow.firstOrNull().orEmpty() return Json.encodeToString( diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt index b21925d0e..06b88539b 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/DefaultMcpServerServiceTest.kt @@ -4,6 +4,7 @@ import com.kitakkun.jetwhale.host.model.LoadedPluginInstance import com.kitakkun.jetwhale.host.model.McpServerStatus import com.kitakkun.jetwhale.host.model.PluginInstanceEvent import com.kitakkun.jetwhale.host.model.PluginInstanceService +import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi import com.kitakkun.jetwhale.host.sdk.JetWhaleHostPlugin import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArgumentException import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments @@ -543,6 +544,7 @@ class DefaultMcpServerServiceTest { pluginInstanceService = pluginInstanceService, mcpActivityRepository = mcpActivityRepository, builtInTools = setOf(MirroredStructuredMcpTool("fake.mirrored")), + statusHolder = McpServerStatusHolder(), ) val mirroredPort = java.net.ServerSocket(0).use { it.localPort } serviceWithTool.start(host, mirroredPort) diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/HostMcpCommandTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/HostMcpCommandTest.kt index 3f9edbccb..3843ccd94 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/HostMcpCommandTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/HostMcpCommandTest.kt @@ -120,12 +120,12 @@ private class EchoHostCommand : HostMcpCommand() { private val text by string("Text to echo back.") private val times by intOrNull("How many times to repeat it.") - override suspend fun execute(arguments: JetWhaleMcpArguments): String = arguments[text].repeat(arguments[times] ?: 1) + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = arguments[text].repeat(arguments[times] ?: 1) } private class ExplodingHostCommand : HostMcpCommand() { override val name: String = "jetwhale.test.explode" override val description: String = "Always fails." - override suspend fun execute(arguments: JetWhaleMcpArguments): String = throw IllegalStateException("boom") + override suspend fun executeText(arguments: JetWhaleMcpArguments): String = throw IllegalStateException("boom") } diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostCommandTestSupport.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostCommandTestSupport.kt new file mode 100644 index 000000000..9a95c8196 --- /dev/null +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostCommandTestSupport.kt @@ -0,0 +1,19 @@ +package com.kitakkun.jetwhale.host.mcp.tools.host + +import com.kitakkun.jetwhale.host.mcp.HostMcpCommand +import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpArguments +import com.kitakkun.jetwhale.host.sdk.JetWhaleMcpContent + +/** + * Runs a host command and returns the text it answered with. + * + * Host commands all answer with text, but they say so through a [com.kitakkun.jetwhale.host.sdk.JetWhaleMcpResult], + * which is the shape every tool result shares. Tests assert on the text, so this unwraps it in one + * place rather than at every call site. + */ +@OptIn(ExperimentalJetWhaleApi::class) +internal suspend fun HostMcpCommand.executeForText(arguments: JetWhaleMcpArguments): String = execute(arguments) + .content + .filterIsInstance() + .joinToString(separator = "") { it.text } diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostLogCommandsTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostLogCommandsTest.kt index a0a499c4b..b2f3bc526 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostLogCommandsTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostLogCommandsTest.kt @@ -36,7 +36,7 @@ class HostLogCommandsTest { @Test fun `getLogs returns the newest entries last`() = runBlocking { - val result = GetLogsCommand(logCaptureService).execute(arguments()).decodeLogs() + val result = GetLogsCommand(logCaptureService).executeForText(arguments()).decodeLogs() assertEquals(listOf("first info", "second boom", "third info"), result.logs.map { it.message }) assertEquals(3, result.returned) @@ -45,7 +45,7 @@ class HostLogCommandsTest { @Test fun `getLogs keeps only the most recent entries when a limit is given`() = runBlocking { - val result = GetLogsCommand(logCaptureService).execute(arguments("limit" to JsonPrimitive(2))).decodeLogs() + val result = GetLogsCommand(logCaptureService).executeForText(arguments("limit" to JsonPrimitive(2))).decodeLogs() assertEquals(listOf("second boom", "third info"), result.logs.map { it.message }) assertEquals(2, result.returned) @@ -54,7 +54,7 @@ class HostLogCommandsTest { @Test fun `getLogs filters by level`() = runBlocking { - val result = GetLogsCommand(logCaptureService).execute(arguments("level" to JsonPrimitive("ERROR"))).decodeLogs() + val result = GetLogsCommand(logCaptureService).executeForText(arguments("level" to JsonPrimitive("ERROR"))).decodeLogs() assertEquals(listOf("second boom"), result.logs.map { it.message }) assertEquals(1, result.total) @@ -62,7 +62,7 @@ class HostLogCommandsTest { @Test fun `getLogs filters by substring case-insensitively`() = runBlocking { - val result = GetLogsCommand(logCaptureService).execute(arguments("contains" to JsonPrimitive("BOOM"))).decodeLogs() + val result = GetLogsCommand(logCaptureService).executeForText(arguments("contains" to JsonPrimitive("BOOM"))).decodeLogs() assertEquals(listOf("second boom"), result.logs.map { it.message }) } @@ -70,7 +70,7 @@ class HostLogCommandsTest { @Test fun `getLogs rejects a limit above the hard cap`(): Unit = runBlocking { assertFailsWith { - GetLogsCommand(logCaptureService).execute(arguments("limit" to JsonPrimitive(1001))) + GetLogsCommand(logCaptureService).executeForText(arguments("limit" to JsonPrimitive(1001))) } } @@ -78,7 +78,7 @@ class HostLogCommandsTest { fun `getLogs truncates an oversized message`() = runBlocking { logs.value = listOf(logEntry("x".repeat(3000), LogLevel.INFO)) - val message = GetLogsCommand(logCaptureService).execute(arguments()).decodeLogs().logs.single().message + val message = GetLogsCommand(logCaptureService).executeForText(arguments()).decodeLogs().logs.single().message assertEquals(2000 + "…(truncated)".length, message.length) assertTrue(message.endsWith("…(truncated)")) @@ -86,7 +86,7 @@ class HostLogCommandsTest { @Test fun `clearLogs reports how many entries were dropped`() = runBlocking { - val json = ClearLogsCommand(logCaptureService).execute(arguments()) + val json = ClearLogsCommand(logCaptureService).executeForText(arguments()) assertEquals(3, Json.decodeFromString(json).cleared) verify { logCaptureService.clearLogs() } diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostNavigationCommandTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostNavigationCommandTest.kt index 0c5cc0521..a14d095f9 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostNavigationCommandTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostNavigationCommandTest.kt @@ -85,7 +85,7 @@ class HostNavigationCommandTest { ) val result = command - .execute(arguments("destination" to JsonPrimitive("SETTINGS"), "settingsSection" to JsonPrimitive("SERVER"))) + .executeForText(arguments("destination" to JsonPrimitive("SETTINGS"), "settingsSection" to JsonPrimitive("SERVER"))) .decode() assertTrue(result.applied) @@ -100,7 +100,7 @@ class HostNavigationCommandTest { ) val result = command - .execute(arguments("destination" to JsonPrimitive("SETTINGS"), "settingsSection" to JsonPrimitive("PLUGINS"))) + .executeForText(arguments("destination" to JsonPrimitive("SETTINGS"), "settingsSection" to JsonPrimitive("PLUGINS"))) .decode() assertFalse(result.applied) @@ -108,7 +108,7 @@ class HostNavigationCommandTest { @Test fun `navigate reports not-applied when the host window never confirms`() = runBlocking { - val result = command.execute(arguments("destination" to JsonPrimitive("INFO"))).decode() + val result = command.executeForText(arguments("destination" to JsonPrimitive("INFO"))).decode() assertFalse(result.applied) assertContains(result.reason.orEmpty(), "did not report") @@ -126,7 +126,7 @@ class HostNavigationCommandTest { ) val result = command - .execute(arguments("destination" to JsonPrimitive("PLUGIN"), "pluginId" to JsonPrimitive("com.example.agent"))) + .executeForText(arguments("destination" to JsonPrimitive("PLUGIN"), "pluginId" to JsonPrimitive("com.example.agent"))) .decode() assertTrue(result.applied) @@ -135,14 +135,14 @@ class HostNavigationCommandTest { @Test fun `navigate requires a pluginId when the destination is PLUGIN`(): Unit = runBlocking { - val error = assertFailsWithArgumentException { command.execute(arguments("destination" to JsonPrimitive("PLUGIN"))) } + val error = assertFailsWithArgumentException { command.executeForText(arguments("destination" to JsonPrimitive("PLUGIN"))) } assertContains(error, "pluginId is required") } @Test fun `navigate rejects a pluginId that is not installed`(): Unit = runBlocking { val error = assertFailsWithArgumentException { - command.execute(arguments("destination" to JsonPrimitive("PLUGIN"), "pluginId" to JsonPrimitive("com.example.missing"))) + command.executeForText(arguments("destination" to JsonPrimitive("PLUGIN"), "pluginId" to JsonPrimitive("com.example.missing"))) } assertContains(error, "is not installed") } @@ -150,7 +150,7 @@ class HostNavigationCommandTest { @Test fun `navigate rejects a plugin that is installed but disabled`(): Unit = runBlocking { val error = assertFailsWithArgumentException { - command.execute(arguments("destination" to JsonPrimitive("PLUGIN"), "pluginId" to JsonPrimitive("com.example.disabled"))) + command.executeForText(arguments("destination" to JsonPrimitive("PLUGIN"), "pluginId" to JsonPrimitive("com.example.disabled"))) } assertContains(error, "disabled") } @@ -158,7 +158,7 @@ class HostNavigationCommandTest { @Test fun `navigate rejects a session that does not exist`(): Unit = runBlocking { val error = assertFailsWithArgumentException { - command.execute( + command.executeForText( arguments( "destination" to JsonPrimitive("PLUGIN"), "pluginId" to JsonPrimitive("com.example.agent"), @@ -174,7 +174,7 @@ class HostNavigationCommandTest { every { reconciliationService.requiresAgent("com.example.hostonly") } returns true val error = assertFailsWithArgumentException { - command.execute( + command.executeForText( arguments( "destination" to JsonPrimitive("PLUGIN"), "pluginId" to JsonPrimitive("com.example.hostonly"), diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostPluginCommandsTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostPluginCommandsTest.kt index caf13cbe2..7cafea970 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostPluginCommandsTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostPluginCommandsTest.kt @@ -87,7 +87,7 @@ class HostPluginCommandsTest { @Test fun `listInstalledPlugins reports each plugin's enabled state`() = runBlocking { - val result = listInstalledPlugins.execute(arguments()).decodeList() + val result = listInstalledPlugins.executeForText(arguments()).decodeList() val plugin = result.installed.single() assertEquals("com.example.local", plugin.pluginId) @@ -98,7 +98,7 @@ class HostPluginCommandsTest { @Test fun `listInstalledPlugins marks an official plugin that is not installed`() = runBlocking { - val official = listInstalledPlugins.execute(arguments()).decodeList().availableOfficial.single { it.pluginId == officialPluginId } + val official = listInstalledPlugins.executeForText(arguments()).decodeList().availableOfficial.single { it.pluginId == officialPluginId } assertFalse(official.installed) } @@ -107,14 +107,14 @@ class HostPluginCommandsTest { fun `listInstalledPlugins marks an official plugin that is already installed`() = runBlocking { loadedPlugins[officialPluginId] = loadedPlugin(officialPluginId, "Network Inspector", requiresAgent = true) - val official = listInstalledPlugins.execute(arguments()).decodeList().availableOfficial.single { it.pluginId == officialPluginId } + val official = listInstalledPlugins.executeForText(arguments()).decodeList().availableOfficial.single { it.pluginId == officialPluginId } assertTrue(official.installed) } @Test fun `listInstalledPlugins reports failed and untrusted jars`() = runBlocking { - val result = listInstalledPlugins.execute(arguments()).decodeList() + val result = listInstalledPlugins.executeForText(arguments()).decodeList() assertEquals("/plugins/broken.jar", result.failedJars.single().jarPath) assertEquals("/plugins/unknown.jar", result.untrustedJars.single()) @@ -123,7 +123,7 @@ class HostPluginCommandsTest { @Test fun `setPluginEnabled rejects a pluginId that is not installed`(): Unit = runBlocking { val error = assertFailsWith { - setPluginEnabled.execute(arguments("pluginId" to JsonPrimitive("com.example.missing"), "enabled" to JsonPrimitive(true))) + setPluginEnabled.executeForText(arguments("pluginId" to JsonPrimitive("com.example.missing"), "enabled" to JsonPrimitive(true))) } assertContains(error.message.orEmpty(), "is not installed") } @@ -131,7 +131,7 @@ class HostPluginCommandsTest { @Test fun `setPluginEnabled disables a plugin without waiting for instantiation`() = runBlocking { val result = setPluginEnabled - .execute(arguments("pluginId" to JsonPrimitive("com.example.local"), "enabled" to JsonPrimitive(false))) + .executeForText(arguments("pluginId" to JsonPrimitive("com.example.local"), "enabled" to JsonPrimitive(false))) .let { Json.decodeFromString(it) } assertFalse(result.enabled) @@ -143,7 +143,7 @@ class HostPluginCommandsTest { @Test fun `installOfficialPlugin is refused when the setting is disabled`(): Unit = runBlocking { val error = assertFailsWith { - installOfficialPlugin.execute(arguments("pluginId" to JsonPrimitive(officialPluginId))) + installOfficialPlugin.executeForText(arguments("pluginId" to JsonPrimitive(officialPluginId))) } assertContains(error.message.orEmpty(), "Settings → Server → MCP Server") } @@ -153,7 +153,7 @@ class HostPluginCommandsTest { installAllowed.value = true val error = assertFailsWith { - installOfficialPlugin.execute(arguments("pluginId" to JsonPrimitive("com.evil.backdoor"))) + installOfficialPlugin.executeForText(arguments("pluginId" to JsonPrimitive("com.evil.backdoor"))) } assertContains(error.message.orEmpty(), "is not an official plugin") } @@ -164,7 +164,7 @@ class HostPluginCommandsTest { installProgress.value = PluginInstallProgress.DownloadingPlugin val error = assertFailsWith { - installOfficialPlugin.execute(arguments("pluginId" to JsonPrimitive(officialPluginId))) + installOfficialPlugin.executeForText(arguments("pluginId" to JsonPrimitive(officialPluginId))) } assertContains(error.message.orEmpty(), "already in progress") } @@ -174,7 +174,7 @@ class HostPluginCommandsTest { installAllowed.value = true val result = installOfficialPlugin - .execute(arguments("pluginId" to JsonPrimitive(officialPluginId))) + .executeForText(arguments("pluginId" to JsonPrimitive(officialPluginId))) .let { Json.decodeFromString(it) } assertTrue(result.installed) @@ -191,7 +191,7 @@ class HostPluginCommandsTest { loadedPlugins[officialPluginId] = loadedPlugin(officialPluginId, "Network Inspector", requiresAgent = true) val result = installOfficialPlugin - .execute(arguments("pluginId" to JsonPrimitive(officialPluginId))) + .executeForText(arguments("pluginId" to JsonPrimitive(officialPluginId))) .let { Json.decodeFromString(it) } assertTrue(result.alreadyInstalled) diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostSettingsCommandsTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostSettingsCommandsTest.kt index 40bfb241b..503324e43 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostSettingsCommandsTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostSettingsCommandsTest.kt @@ -43,7 +43,7 @@ class HostSettingsCommandsTest { @Test fun `updateSettings applies only the arguments that were supplied`() = runBlocking { - val result = updateSettings.execute(arguments("persistData" to JsonPrimitive(true))).decodeSettings() + val result = updateSettings.executeForText(arguments("persistData" to JsonPrimitive(true))).decodeSettings() assertEquals(mapOf("persistData" to "true"), result.applied) verifySuspend { settingsRepository.updatePersistData(true) } @@ -52,7 +52,7 @@ class HostSettingsCommandsTest { @Test fun `updateSettings persists the mcp port without restarting the mcp server`() = runBlocking { - val result = updateSettings.execute(arguments("mcpServerPort" to JsonPrimitive(7100))).decodeSettings() + val result = updateSettings.executeForText(arguments("mcpServerPort" to JsonPrimitive(7100))).decodeSettings() assertFalse(result.mcpServerRestarted) assertFalse(result.debugServerRestarted) @@ -64,7 +64,7 @@ class HostSettingsCommandsTest { @Test fun `updateSettings restarts the debug server when the ws port changed`() = runBlocking { serverPortFlow.value = 5090 - val result = updateSettings.execute(arguments("serverPort" to JsonPrimitive(5090))).decodeSettings() + val result = updateSettings.executeForText(arguments("serverPort" to JsonPrimitive(5090))).decodeSettings() assertTrue(result.debugServerRestarted) verifySuspend { debugWebSocketServer.stop() } @@ -74,7 +74,7 @@ class HostSettingsCommandsTest { @Test fun `updateSettings restarts the debug server when adb auto port mapping changed`() = runBlocking { // The setting is read when the server starts, so it is inert until the server restarts. - val result = updateSettings.execute(arguments("adbAutoPortMappingEnabled" to JsonPrimitive(true))).decodeSettings() + val result = updateSettings.executeForText(arguments("adbAutoPortMappingEnabled" to JsonPrimitive(true))).decodeSettings() assertTrue(result.debugServerRestarted) verifySuspend { debugWebSocketServer.stop() } @@ -82,14 +82,14 @@ class HostSettingsCommandsTest { @Test fun `updateSettings does not claim a change is pending once it has restarted the server`() = runBlocking { - val result = updateSettings.execute(arguments("adbAutoPortMappingEnabled" to JsonPrimitive(true))).decodeSettings() + val result = updateSettings.executeForText(arguments("adbAutoPortMappingEnabled" to JsonPrimitive(true))).decodeSettings() assertFalse(result.notes.any { "still running" in it }) } @Test fun `updateSettings can persist a ws change without restarting when asked`() = runBlocking { - val result = updateSettings.execute( + val result = updateSettings.executeForText( arguments( "serverPort" to JsonPrimitive(5090), "restartDebugServer" to JsonPrimitive(false), @@ -104,7 +104,7 @@ class HostSettingsCommandsTest { @Test fun `updateSettings rejects an out-of-range port before writing anything`(): Unit = runBlocking { assertFailsWith { - updateSettings.execute( + updateSettings.executeForText( arguments( "persistData" to JsonPrimitive(true), "serverPort" to JsonPrimitive(70000), @@ -116,12 +116,12 @@ class HostSettingsCommandsTest { @Test fun `updateSettings rejects a call that changes nothing`(): Unit = runBlocking { - assertFailsWith { updateSettings.execute(arguments()) } + assertFailsWith { updateSettings.executeForText(arguments()) } } @Test fun `restartDebugServer starts with the configured wss port when wss is enabled`() = runBlocking { - RestartDebugServerCommand(settingsRepository, debugWebSocketServer).execute(arguments()) + RestartDebugServerCommand(settingsRepository, debugWebSocketServer).executeForText(arguments()) verifySuspend { debugWebSocketServer.stop() } verifySuspend { debugWebSocketServer.start("localhost", 5080, 5443) } @@ -132,7 +132,7 @@ class HostSettingsCommandsTest { wssEnabledFlow.value = false val result = RestartDebugServerCommand(settingsRepository, debugWebSocketServer) - .execute(arguments()) + .executeForText(arguments()) .let { Json.decodeFromString(it) } verifySuspend { debugWebSocketServer.start("localhost", 5080, null) } @@ -142,7 +142,7 @@ class HostSettingsCommandsTest { @Test fun `restartDebugServer reports the state the server ended up in`() = runBlocking { val result = RestartDebugServerCommand(settingsRepository, debugWebSocketServer) - .execute(arguments()) + .executeForText(arguments()) .let { Json.decodeFromString(it) } assertEquals("Started", result.state) diff --git a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostStatusCommandTest.kt b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostStatusCommandTest.kt index 9c8d00c5c..9cc86b641 100644 --- a/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostStatusCommandTest.kt +++ b/jetwhale-host/core/mcp/src/test/kotlin/com/kitakkun/jetwhale/host/mcp/tools/host/HostStatusCommandTest.kt @@ -79,7 +79,7 @@ class HostStatusCommandTest { @Test fun `getStatus reports the debug server and mcp server endpoints`() = runBlocking { - val status = command.execute(arguments()).decode() + val status = command.executeForText(arguments()).decode() assertEquals("Started", status.debugServer.state) assertEquals(5080, status.debugServer.port) @@ -90,7 +90,7 @@ class HostStatusCommandTest { @Test fun `getStatus reports the host version and whether it is a snapshot`() = runBlocking { - val status = command.execute(arguments()).decode() + val status = command.executeForText(arguments()).decode() assertEquals("1.2.3-SNAPSHOT", status.host.version) assertTrue(status.host.isSnapshot) @@ -98,7 +98,7 @@ class HostStatusCommandTest { @Test fun `getStatus counts sessions by whether they are still connected`() = runBlocking { - val status = command.execute(arguments()).decode() + val status = command.executeForText(arguments()).decode() assertEquals(2, status.sessions.total) assertEquals(1, status.sessions.active) @@ -106,7 +106,7 @@ class HostStatusCommandTest { @Test fun `getStatus reports a null ui block before the host window has composed`() = runBlocking { - assertNull(command.execute(arguments()).decode().ui) + assertNull(command.executeForText(arguments()).decode().ui) } @Test @@ -117,7 +117,7 @@ class HostStatusCommandTest { selectedPluginId = "com.example", ) - val ui = requireNotNull(command.execute(arguments()).decode().ui) + val ui = requireNotNull(command.executeForText(arguments()).decode().ui) assertEquals("PLUGIN", ui.destination) assertEquals("com.example", ui.pluginId) assertEquals("s1", ui.selectedSessionId) @@ -125,13 +125,13 @@ class HostStatusCommandTest { @Test fun `getStatus reports that no install is in flight`() = runBlocking { - assertFalse(command.execute(arguments()).decode().plugins.installInProgress) + assertFalse(command.executeForText(arguments()).decode().plugins.installInProgress) } @Test fun `getStatus reports whether installing plugins over MCP is allowed`() = runBlocking { // Without this an agent could only discover the gate by attempting an install and being refused. - assertFalse(command.execute(arguments()).decode().settings.mcpPluginInstallAllowed) + assertFalse(command.executeForText(arguments()).decode().settings.mcpPluginInstallAllowed) } }