Skip to content

Commit 19a5ed4

Browse files
Add tool descriptor metadata support (#3495)
Adds support for tool descriptor metadata to misk-mcp. This is required by then OpenAI Apps SDK: https://developers.openai.com/apps-sdk/reference This is a temporary solution until modelcontextprotocol/kotlin-sdk#339 can be pulled in.
1 parent 32e7163 commit 19a5ed4

File tree

5 files changed

+235
-67
lines changed

5 files changed

+235
-67
lines changed

misk-mcp/api/misk-mcp.api

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ public abstract class misk/mcp/McpTool {
7676
public abstract fun getDescription ()Ljava/lang/String;
7777
public fun getDestructiveHint ()Z
7878
public fun getIdempotentHint ()Z
79+
public fun getMetadata ()Lkotlinx/serialization/json/JsonObject;
7980
public abstract fun getName ()Ljava/lang/String;
8081
public fun getOpenWorldHint ()Z
8182
public fun getReadOnlyHint ()Z

misk-mcp/src/main/kotlin/misk/mcp/McpServerModule.kt

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,24 +37,24 @@ import kotlin.reflect.KClass
3737

3838
/**
3939
* Guice module for configuring MCP (Model Context Protocol) server functionality.
40-
*
40+
*
4141
* This module sets up the necessary bindings for MCP tools, resources, prompts,
42-
* and transport mechanisms (WebSocket and Server-Sent Events). It provides factory
42+
* and transport mechanisms (WebSocket and Server-Sent Events). It provides factory
4343
* methods for creating configured MCP server instances with different transport options
4444
* and optional tool grouping via annotations.
45-
*
45+
*
4646
* The module automatically configures:
4747
* - Multi-binders for tools, resources, and prompts
4848
* - Transport layer factories for HTTP streaming and WebSocket connections
4949
* - JSON RPC message unmarshalling
5050
* - Optional session handling
5151
* - Metrics collection
52-
*
52+
*
5353
* Example usage:
5454
* ```kotlin
5555
* install(McpServerModule.create("my-server", mcpConfig))
5656
* ```
57-
*
57+
*
5858
* @see MiskMcpServer
5959
* @see McpConfig
6060
* @see McpTool
@@ -108,7 +108,8 @@ class McpServerModule private constructor(
108108
MiskStreamableHttpServerTransport(
109109
call = httpCall.get(),
110110
mcpSessionHandler = mcpSessionHandlerProvider.get().getOrNull(),
111-
sendChannel = sendChannel
111+
sendChannel = sendChannel,
112+
mcpTools = toolsProvider.get(),
112113
)
113114
}
114115
}
@@ -164,7 +165,7 @@ class McpServerModule private constructor(
164165
companion object {
165166
/**
166167
* Create an [McpServerModule] for the given [McpConfig] with an optional tool [groupAnnotation].
167-
*
168+
*
168169
* @param name The name of the MCP server configuration to use from the config
169170
* @param config The MCP configuration containing server settings
170171
* @param instructionsProvider Optional provider for server instructions/documentation
@@ -180,9 +181,9 @@ class McpServerModule private constructor(
180181

181182
/**
182183
* Create an [McpServerModule] with a reified group annotation type.
183-
*
184+
*
184185
* @param name The name of the MCP server configuration to use from the config
185-
* @param config The MCP configuration containing server settings
186+
* @param config The MCP configuration containing server settings
186187
* @param instructionsProvider Optional provider for server instructions/documentation
187188
* @return A configured McpServerModule instance with the specified group annotation
188189
*/
@@ -195,7 +196,7 @@ class McpServerModule private constructor(
195196

196197
/**
197198
* Create an [McpServerModule] without any group annotation.
198-
*
199+
*
199200
* @param name The name of the MCP server configuration to use from the config
200201
* @param config The MCP configuration containing server settings
201202
* @param instructionsProvider Optional provider for server instructions/documentation

misk-mcp/src/main/kotlin/misk/mcp/McpTool.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,14 @@ abstract class McpTool<I : Any> {
202202
*/
203203
open val openWorldHint: Boolean = true
204204

205+
/**
206+
* Metadata included in the tool descriptor. Not officially part of the MCP spec, but relied
207+
* upon by some clients (e.g., OpenAI Apps SDK).
208+
*
209+
* This field type is expected to have a breaking change in the near future.
210+
*/
211+
open val metadata: JsonObject? = null
212+
205213
internal val inputSchema: Tool.Input by lazy {
206214
val schema = inputClass.generateJsonSchema()
207215
Tool.Input(

misk-mcp/src/main/kotlin/misk/mcp/internal/MiskStreamableHttpServerTransport.kt

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,24 @@ import io.modelcontextprotocol.kotlin.sdk.JSONRPCMessage
44
import io.modelcontextprotocol.kotlin.sdk.JSONRPCNotification
55
import io.modelcontextprotocol.kotlin.sdk.JSONRPCRequest
66
import io.modelcontextprotocol.kotlin.sdk.JSONRPCResponse
7+
import io.modelcontextprotocol.kotlin.sdk.ListToolsResult
78
import io.modelcontextprotocol.kotlin.sdk.Method
89
import jakarta.inject.Inject
910
import kotlinx.coroutines.channels.SendChannel
11+
import kotlinx.serialization.json.JsonObject
12+
import kotlinx.serialization.json.encodeToJsonElement
13+
import kotlinx.serialization.json.jsonObject
14+
import kotlinx.serialization.json.buildJsonObject
15+
import kotlinx.serialization.json.buildJsonArray
16+
import kotlinx.serialization.json.jsonArray
17+
import kotlinx.serialization.json.jsonPrimitive
18+
import misk.annotation.ExperimentalMiskApi
1019
import misk.exceptions.BadRequestException
1120
import misk.exceptions.NotFoundException
1221
import misk.exceptions.WebActionException
1322
import misk.logging.getLogger
1423
import misk.mcp.McpSessionHandler
24+
import misk.mcp.McpTool
1525
import misk.mcp.action.SESSION_ID_HEADER
1626
import misk.web.HttpCall
1727
import misk.web.sse.ServerSentEvent
@@ -31,11 +41,13 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi
3141
* @param mcpSessionHandler Optional session handler for managing client sessions
3242
* @param sendChannel Channel for sending SSE events to the client
3343
*/
34-
@OptIn(ExperimentalAtomicApi::class)
35-
internal class MiskStreamableHttpServerTransport @Inject constructor(
44+
@OptIn(ExperimentalAtomicApi::class, ExperimentalMiskApi::class)
45+
internal class MiskStreamableHttpServerTransport
46+
@Inject constructor(
3647
override val call: HttpCall,
3748
private val mcpSessionHandler: McpSessionHandler?,
3849
private val sendChannel: SendChannel<ServerSentEvent>,
50+
private val mcpTools: Set<McpTool<*>>,
3951
) : MiskServerTransport() {
4052

4153
private val initialized: AtomicBoolean = AtomicBoolean(false)
@@ -57,15 +69,79 @@ internal class MiskStreamableHttpServerTransport @Inject constructor(
5769
error("Not connected")
5870
}
5971

72+
val jsonObject: JsonObject = McpJson.encodeToJsonElement(message).jsonObject
73+
74+
val jsonObjectWithMetadata = if (message is JSONRPCResponse && message.result is ListToolsResult) {
75+
transformJsonObjectWithToolMetadata(jsonObject)
76+
} else {
77+
jsonObject
78+
}
79+
6080
val event = ServerSentEvent(
6181
event = "message",
62-
data = McpJson.encodeToString(message)
82+
data = McpJson.encodeToString(jsonObjectWithMetadata)
6383
)
6484

6585
logger.trace { "Sending SSE: $event" }
6686
sendChannel.send(event)
6787
}
6888

89+
private fun transformJsonObjectWithToolMetadata(jsonObject: JsonObject): JsonObject {
90+
// Extract tools from the result.tools array
91+
val result = jsonObject["result"] as? JsonObject ?: return jsonObject
92+
val toolsArray = result["tools"] ?: return jsonObject
93+
94+
if (!toolsArray.jsonArray.isEmpty()) {
95+
val transformedTools = toolsArray.jsonArray.map { toolElement ->
96+
val toolObject = toolElement.jsonObject
97+
val toolName = toolObject["name"]?.jsonPrimitive?.content
98+
99+
// Find the matching McpTool by name
100+
val matchingTool = mcpTools.find { it.name == toolName }
101+
102+
if (matchingTool != null) {
103+
// Add metadata from the McpTool to the tool JSON
104+
buildJsonObject {
105+
// Copy all existing properties
106+
toolObject.forEach { (key, value) ->
107+
put(key, value)
108+
}
109+
110+
// Add metadata if available
111+
matchingTool.metadata?.let { metadata ->
112+
put("_meta", metadata)
113+
}
114+
}
115+
} else {
116+
toolElement
117+
}
118+
}
119+
120+
// Rebuild the JSON object with the transformed tools
121+
return buildJsonObject {
122+
jsonObject.forEach { (key, value) ->
123+
if (key == "result") {
124+
put(key, buildJsonObject {
125+
result.forEach { (resultKey, resultValue) ->
126+
if (resultKey == "tools") {
127+
put(resultKey, buildJsonArray {
128+
transformedTools.forEach { add(it) }
129+
})
130+
} else {
131+
put(resultKey, resultValue)
132+
}
133+
}
134+
})
135+
} else {
136+
put(key, value)
137+
}
138+
}
139+
}
140+
}
141+
142+
return jsonObject
143+
}
144+
69145
override suspend fun close() {
70146
if (initialized.compareAndSet(expectedValue = true, newValue = false)) {
71147
sendChannel.close()

0 commit comments

Comments
 (0)