Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 87 additions & 4 deletions docs/guide/developing-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand All @@ -286,12 +288,93 @@ 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 `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.

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])
}
```

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<T>()`, the mirror image of
the `serializable<T>()` 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<WidgetDescription>()

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 `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.

### Structured parameters

Beyond scalars (`string`, `int`, `long`, `boolean`, `enum`), a parameter can take structured input.
Expand Down
80 changes: 75 additions & 5 deletions jetwhale-host-sdk/api/jetwhale-host-sdk.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (Ljava/lang/String;)V
}
Expand Down Expand Up @@ -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;
Expand All @@ -240,6 +241,46 @@ 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 <init> (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 <init> (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 class com/kitakkun/jetwhale/host/sdk/JetWhaleMcpException : java/lang/Exception {
public static final field $stable I
public fun <init> (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;
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;
Expand Down Expand Up @@ -271,18 +312,47 @@ 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 <init> ()V
public fun <init> (Lkotlinx/serialization/json/Json;)V
public synthetic fun <init> (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 <init> (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Lkotlinx/serialization/json/JsonObject;)V
public synthetic fun <init> (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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, JetWhaleMcpParameterDescriptor> = emptyMap(),
val outputSchema: JsonObject? = null,
)

/**
Expand Down
Loading
Loading