Skip to content

Commit d74bf9e

Browse files
Skobeltsynclaude
andcommitted
feat(#1810): McpClient.resourceSkills() + McpServer resource DSL
Final slice (3/3) of the v0.5.0 MCP-as-skills unification. MCP resources — URI-addressable data items — become Skills with the same shape as tool-skills and prompt-skills from earlier slices. Skill args are ignored at invocation; the resource URI is captured in the skill's closure. Server side (McpServer): - New RegisteredResource internal data class. - McpServer ctor gains registeredResources list. - McpExposeBuilder.resource(uri, name, description?, mimeType?, content: () -> String) DSL — registers a static resource. - handleInitialize declares {"resources": {"listChanged": false, "subscribe": false}} in capabilities when resources are registered. - New handlers: resources/list and resources/read. - McpServer.from factory's "must have at least one" requirement loosened to: at least one expose() OR prompt() OR resource(). Client side (McpClient): - listResources(): List<McpResourceInfo> — fetches resources/list. - readResource(uri): String — calls resources/read, joins text content blocks into one string. - resourceSkills(prefix): each resource wrapped as Skill<Map<String, Any?>, String> with implementedBy invoking readResource(uri). TDD red-first: McpResourcesAsSkillsTest registers a "precision-policy" resource on a loopback server; client fetches resourceSkills(); caller agent uses it as a primary skill; invocation returns the policy text verbatim. After this commit, all three MCP surfaces (tools, prompts, resources) are exposed as Skills uniformly. The v0.5.0 MCP-as-skills unification is complete: consumer code can build agents that are thin wrappers over MCP exposure with one DSL shape across all capability types. MCP suite stats post-merge: 7 tests, 0 failures, 0 skipped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4962a18 commit d74bf9e

3 files changed

Lines changed: 219 additions & 3 deletions

File tree

src/main/kotlin/agents_engine/mcp/McpClient.kt

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,68 @@ class McpClient internal constructor(private val transport: McpTransport) : Auto
157157
}
158158
}
159159

160+
/**
161+
* #1810 — fetch resource listings from the server (`resources/list`).
162+
* Returns the raw `McpResourceInfo` records. For the Skill view, use
163+
* [resourceSkills].
164+
*/
165+
fun listResources(): List<McpResourceInfo> {
166+
val result = post("resources/list", emptyMap<String, Any?>())
167+
val resultMap = result as? Map<*, *> ?: return emptyList()
168+
val resourcesList = resultMap["resources"] as? List<*> ?: return emptyList()
169+
return resourcesList.mapNotNull { raw ->
170+
val m = raw as? Map<*, *> ?: return@mapNotNull null
171+
val uri = m["uri"] as? String ?: return@mapNotNull null
172+
val name = m["name"] as? String ?: return@mapNotNull null
173+
McpResourceInfo(
174+
uri = uri,
175+
name = name,
176+
title = m["title"] as? String,
177+
description = m["description"] as? String,
178+
mimeType = m["mimeType"] as? String,
179+
size = (m["size"] as? Number)?.toLong(),
180+
)
181+
}
182+
}
183+
184+
/**
185+
* #1810 — read a resource's content (`resources/read`). Joins all
186+
* returned text content blocks into a single string. Binary
187+
* (base64-encoded) resources are out of scope for this slice —
188+
* extend when needed.
189+
*/
190+
fun readResource(uri: String): String {
191+
val result = post("resources/read", mapOf("uri" to uri))
192+
val resultMap = result as? Map<*, *>
193+
?: error("resources/read returned non-object: $result")
194+
val contents = resultMap["contents"] as? List<*> ?: emptyList<Any?>()
195+
return contents.mapNotNull { c ->
196+
val m = c as? Map<*, *> ?: return@mapNotNull null
197+
m["text"] as? String
198+
}.joinToString("\n")
199+
}
200+
201+
/**
202+
* #1810 — MCP-as-skills (3/3): expose every server-side resource as
203+
* a [Skill]. Skill `name` is the resource's display name (with
204+
* optional prefix); `implementedBy` invokes [readResource] with the
205+
* captured URI. Skill args are ignored — resources are addressed
206+
* by URI, not by call-time parameters.
207+
*/
208+
fun resourceSkills(prefix: String? = null): List<agents_engine.core.Skill<Map<String, Any?>, String>> {
209+
return listResources().map { info ->
210+
val displayName = if (prefix != null) "$prefix.${info.name}" else info.name
211+
agents_engine.core.Skill<Map<String, Any?>, String>(
212+
name = displayName,
213+
description = info.description ?: "MCP resource ${info.uri}",
214+
inType = Map::class,
215+
outType = String::class,
216+
).also { skill ->
217+
skill.implementedBy { _ -> readResource(info.uri) }
218+
}
219+
}
220+
}
221+
160222
fun call(toolName: String, args: Map<String, Any?>): Any? {
161223
val result = post("tools/call", mapOf("name" to toolName, "arguments" to args))
162224
val resultMap = result as? Map<*, *>

src/main/kotlin/agents_engine/mcp/McpServer.kt

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,27 @@ internal data class RegisteredPrompt(
4141
val render: (Map<String, Any?>) -> String,
4242
)
4343

44+
/**
45+
* #1810 — a server-side resource registration. Mirrors the MCP wire
46+
* shape for resources: URI (the addressable handle), display name,
47+
* optional description and MIME type, and a `read` closure invoked on
48+
* `resources/read` to produce the resource's text content.
49+
*/
50+
internal data class RegisteredResource(
51+
val uri: String,
52+
val name: String,
53+
val description: String?,
54+
val mimeType: String?,
55+
val read: () -> String,
56+
)
57+
4458
class McpServer private constructor(
4559
private val agent: Agent<*, *>,
4660
private val exposedSkills: List<ExposedSkill>,
4761
private val portRequest: Int,
4862
private val maxRequestBytes: Long = DEFAULT_MAX_REQUEST_BYTES,
4963
private val registeredPrompts: List<RegisteredPrompt> = emptyList(),
64+
private val registeredResources: List<RegisteredResource> = emptyList(),
5065
) {
5166
private var http: HttpServer? = null
5267
private val sessionId: String = java.util.UUID.randomUUID().toString()
@@ -118,6 +133,11 @@ class McpServer private constructor(
118133
"nextCursor" to null,
119134
))
120135
"prompts/get" -> handlePromptGet(id, request)
136+
"resources/list" -> jsonRpcResult(id, mapOf(
137+
"resources" to registeredResources.map { it.toMcpDescriptor() },
138+
"nextCursor" to null,
139+
))
140+
"resources/read" -> handleResourceRead(id, request)
121141
else -> jsonRpcError(id, -32601, "Method not found: $method")
122142
}
123143
respond(exchange, 200, response)
@@ -138,12 +158,15 @@ class McpServer private constructor(
138158
"Unsupported protocolVersion: \"$requested\". Server speaks: \"$MCP_PROTOCOL_VERSION\".",
139159
)
140160
}
141-
// #1796: declare prompts capability when prompts are registered.
161+
// #1796 / #1810: declare prompts and resources capabilities when registered.
142162
val capabilities = buildMap<String, Any?> {
143163
put("tools", mapOf("listChanged" to false))
144164
if (registeredPrompts.isNotEmpty()) {
145165
put("prompts", mapOf("listChanged" to false))
146166
}
167+
if (registeredResources.isNotEmpty()) {
168+
put("resources", mapOf("listChanged" to false, "subscribe" to false))
169+
}
147170
}
148171
return jsonRpcResult(id, mapOf(
149172
"protocolVersion" to MCP_PROTOCOL_VERSION,
@@ -190,6 +213,35 @@ class McpServer private constructor(
190213
}
191214
}
192215

216+
private fun handleResourceRead(id: Any?, request: Map<*, *>): String {
217+
val params = request["params"] as? Map<*, *> ?: emptyMap<Any?, Any?>()
218+
val uri = params["uri"] as? String
219+
?: return jsonRpcError(id, -32602, "Missing resource uri")
220+
val resource = registeredResources.firstOrNull { it.uri == uri }
221+
?: return jsonRpcError(id, -32601, "Unknown resource uri: $uri")
222+
return try {
223+
val content = resource.read()
224+
jsonRpcResult(id, mapOf(
225+
"contents" to listOf(
226+
buildMap<String, Any?> {
227+
put("uri", resource.uri)
228+
resource.mimeType?.let { put("mimeType", it) }
229+
put("text", content)
230+
},
231+
),
232+
))
233+
} catch (e: Exception) {
234+
jsonRpcError(id, -32603, "Resource '$uri' read failed: ${e.message ?: e.toString()}")
235+
}
236+
}
237+
238+
private fun RegisteredResource.toMcpDescriptor(): Map<String, Any?> = buildMap {
239+
put("uri", uri)
240+
put("name", name)
241+
description?.let { put("description", it) }
242+
mimeType?.let { put("mimeType", it) }
243+
}
244+
193245
private fun handleToolCall(id: Any?, request: Map<*, *>): String {
194246
val params = request["params"] as? Map<*, *> ?: emptyMap<Any?, Any?>()
195247
val name = params["name"] as? String
@@ -234,8 +286,12 @@ class McpServer private constructor(
234286

235287
fun from(agent: Agent<*, *>, block: McpExposeBuilder.() -> Unit): McpServer {
236288
val builder = McpExposeBuilder().apply(block)
237-
require(builder.exposedNames.isNotEmpty() || builder.prompts.isNotEmpty()) {
238-
"McpServer requires at least one expose(skillName) or prompt(...) registration."
289+
require(
290+
builder.exposedNames.isNotEmpty() ||
291+
builder.prompts.isNotEmpty() ||
292+
builder.resources.isNotEmpty()
293+
) {
294+
"McpServer requires at least one expose(skillName), prompt(...), or resource(...) registration."
239295
}
240296
val exposed = builder.exposedNames.map { name ->
241297
val skill = agent.skills[name]
@@ -253,6 +309,7 @@ class McpServer private constructor(
253309
portRequest = builder.port,
254310
maxRequestBytes = builder.maxRequestBytes,
255311
registeredPrompts = builder.prompts,
312+
registeredResources = builder.resources,
256313
)
257314
}
258315
}
@@ -283,6 +340,28 @@ class McpExposeBuilder internal constructor() {
283340
}
284341
prompts += RegisteredPrompt(name, description, arguments, render)
285342
}
343+
344+
internal val resources = mutableListOf<RegisteredResource>()
345+
346+
/**
347+
* #1810 — register a server-side resource. [content] is invoked
348+
* per `resources/read` call; its String return becomes the
349+
* resource's text content. Use a static return for static
350+
* resources; pass a closure that reads from disk/db/etc. for
351+
* dynamic content.
352+
*/
353+
fun resource(
354+
uri: String,
355+
name: String,
356+
description: String? = null,
357+
mimeType: String? = null,
358+
content: () -> String,
359+
) {
360+
require(resources.none { it.uri == uri }) {
361+
"Resource uri \"$uri\" already registered on this McpServer."
362+
}
363+
resources += RegisteredResource(uri, name, description, mimeType, content)
364+
}
286365
}
287366

288367
internal class ExposedSkill private constructor(
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package agents_engine.mcp
2+
3+
import agents_engine.core.agent
4+
import org.junit.jupiter.api.AfterEach
5+
import org.junit.jupiter.api.Tag
6+
import kotlin.test.Test
7+
import kotlin.test.assertEquals
8+
import kotlin.test.assertTrue
9+
10+
/**
11+
* #1810 — slice 3/3 of MCP-as-skills. MCP resources are URI-addressable
12+
* data items. `mcp.resourceSkills()` exposes each as a Skill whose
13+
* `implementedBy` reads the URI's content. Skill args are ignored —
14+
* the URI is captured in the skill's closure at fetch time.
15+
*/
16+
class McpResourcesAsSkillsTest {
17+
18+
private var mcpServer: McpServer? = null
19+
private var mcpClient: McpClient? = null
20+
21+
@AfterEach
22+
fun teardown() {
23+
mcpClient?.close()
24+
mcpServer?.stop()
25+
}
26+
27+
@Tag("live-mcp")
28+
@Test
29+
fun `mcp resourceSkills returns each MCP resource as a Skill that reads its URI content`() {
30+
val placeholderAgent = agent<String, String>("placeholder") {
31+
skills {
32+
skill<String, String>("noop", "Placeholder skill") {
33+
implementedBy { it }
34+
}
35+
}
36+
}
37+
38+
val server = McpServer.from(placeholderAgent) {
39+
port = 0
40+
expose("noop")
41+
resource(
42+
uri = "policy:///precision-policy.md",
43+
name = "precision-policy",
44+
description = "Internal policy for math problem precision",
45+
mimeType = "text/markdown",
46+
) {
47+
"Be precise. Cite sources. Round half-to-even."
48+
}
49+
}.start().also { mcpServer = it }
50+
51+
val mcp = McpClient.connect(server.url).also { mcpClient = it }
52+
53+
// Discovery: every MCP resource returned as a Skill.
54+
val resourceSkills = mcp.resourceSkills()
55+
assertEquals(1, resourceSkills.size, "expected one resource-skill; got: ${resourceSkills.map { it.name }}")
56+
val skill = resourceSkills.single()
57+
assertEquals("precision-policy", skill.name)
58+
assertTrue(
59+
skill.description.contains("precision", ignoreCase = true),
60+
"expected MCP resource description; got: ${skill.description}",
61+
)
62+
63+
// Use the MCP-resource-as-skill as a primary skill on a caller agent.
64+
val caller = agent<Map<String, Any?>, String>("caller") {
65+
skills { +skill }
66+
}
67+
68+
// Args are ignored — the resource URI is captured by the skill's closure.
69+
val output = caller(emptyMap())
70+
assertTrue(
71+
"precise" in output && "sources" in output,
72+
"expected resource content; got: \"$output\"",
73+
)
74+
}
75+
}

0 commit comments

Comments
 (0)