Skip to content

Commit a6367aa

Browse files
Skobeltsynclaude
andcommitted
feat(#1734): McpServerInfo — immutable snapshot of an MCP server's full surface
Data classes covering everything MCP servers can expose: identity (name / title / version / protocolVersion / instructions), capabilities matrix (tools / resources / prompts / logging / completions / experimental), tools (name / title / description / inputSchema / outputSchema / annotations), resources, resource templates, prompts. McpClient now materializes `snapshot: McpServerInfo` after handshake + loadTools. Fields the client doesn't fetch yet (resources, prompts) stay null — they land as the client gains the corresponding RPC calls. Round-trip test uses the framework's own services as fixtures: build an agent → expose via McpServer.from(agent) → connect via McpClient → assert snapshot.{name, version, protocolVersion, capabilities.tools, tools[0]}. No transport stub; real loopback HTTP. If a future change drifts either end of the wire the test fires. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b9903dc commit a6367aa

3 files changed

Lines changed: 332 additions & 0 deletions

File tree

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@ class McpClient internal constructor(private val transport: McpTransport) : Auto
2424
var serverVersion: String? = null
2525
private set
2626

27+
/**
28+
* #1734 — pure-data view of everything we know about the connected server.
29+
* Populated after `handshake()` + `loadTools()` complete. The fields the
30+
* client doesn't currently fetch (resources, prompts, full capability
31+
* matrix) remain null/default; those land in follow-up issues as the
32+
* client gains new RPC calls. Consumers read off this snapshot rather
33+
* than the scattered `serverName` / `serverVersion` / private tools
34+
* accessors.
35+
*/
36+
var snapshot: McpServerInfo? = null
37+
private set
38+
39+
/** Server-reported capabilities map from the initialize handshake; raw shape so we can refine later without re-fetching. */
40+
private var rawServerCapabilities: Map<*, *> = emptyMap<Any?, Any?>()
41+
private var serverTitle: String? = null
42+
private var serverInstructions: String? = null
43+
2744
/**
2845
* Mint a [ToolDef] for each tool the server exposes.
2946
*
@@ -75,7 +92,11 @@ class McpClient internal constructor(private val transport: McpTransport) : Auto
7592
(result["serverInfo"] as? Map<*, *>)?.let { info ->
7693
serverName = info["name"] as? String
7794
serverVersion = info["version"] as? String
95+
serverTitle = info["title"] as? String
7896
}
97+
// #1734: capture capability matrix + instructions for the snapshot.
98+
rawServerCapabilities = result["capabilities"] as? Map<*, *> ?: emptyMap<Any?, Any?>()
99+
serverInstructions = result["instructions"] as? String
79100
}
80101

81102
transport.notify("""{"jsonrpc":"2.0","method":"notifications/initialized"}""")
@@ -94,8 +115,70 @@ class McpClient internal constructor(private val transport: McpTransport) : Auto
94115
name = m["name"] as? String ?: error("tool descriptor missing 'name': $m"),
95116
description = m["description"] as? String ?: "",
96117
inputSchema = m["inputSchema"] as? Map<*, *>,
118+
title = m["title"] as? String,
119+
outputSchema = m["outputSchema"] as? Map<*, *>,
120+
annotations = (m["annotations"] as? Map<*, *>)?.let { ann ->
121+
McpToolAnnotations(
122+
title = ann["title"] as? String,
123+
readOnlyHint = ann["readOnlyHint"] as? Boolean,
124+
destructiveHint = ann["destructiveHint"] as? Boolean,
125+
idempotentHint = ann["idempotentHint"] as? Boolean,
126+
openWorldHint = ann["openWorldHint"] as? Boolean,
127+
)
128+
},
129+
)
130+
}
131+
materializeSnapshot()
132+
}
133+
134+
/**
135+
* #1734 — build [snapshot] from the data the handshake + loadTools steps
136+
* have already gathered. Fields we don't fetch yet (resources, prompts)
137+
* stay null even when the capability matrix says the server supports
138+
* them — the snapshot reflects what THIS client knows, not what the
139+
* server could in principle return. Follow-up issues add resources /
140+
* prompts fetching and populate those fields.
141+
*/
142+
@Suppress("UNCHECKED_CAST")
143+
private fun materializeSnapshot() {
144+
val caps = McpCapabilities(
145+
tools = (rawServerCapabilities["tools"] as? Map<*, *>)?.let {
146+
McpToolsCapability(listChanged = it["listChanged"] as? Boolean ?: false)
147+
},
148+
resources = (rawServerCapabilities["resources"] as? Map<*, *>)?.let {
149+
McpResourcesCapability(
150+
listChanged = it["listChanged"] as? Boolean ?: false,
151+
subscribe = it["subscribe"] as? Boolean ?: false,
152+
)
153+
},
154+
prompts = (rawServerCapabilities["prompts"] as? Map<*, *>)?.let {
155+
McpPromptsCapability(listChanged = it["listChanged"] as? Boolean ?: false)
156+
},
157+
logging = rawServerCapabilities["logging"] != null,
158+
completions = rawServerCapabilities["completions"] != null,
159+
experimental = (rawServerCapabilities["experimental"] as? Map<String, Any?>) ?: emptyMap(),
160+
)
161+
val toolInfos = tools.map { t ->
162+
McpToolInfo(
163+
name = t.name,
164+
title = t.title,
165+
description = t.description.ifEmpty { null },
166+
inputSchema = (t.inputSchema as? Map<String, Any?>) ?: emptyMap(),
167+
outputSchema = t.outputSchema as? Map<String, Any?>,
168+
annotations = t.annotations,
97169
)
98170
}
171+
snapshot = McpServerInfo(
172+
name = serverName ?: error("snapshot before handshake — serverName is null"),
173+
title = serverTitle,
174+
version = serverVersion ?: "",
175+
protocolVersion = serverProtocolVersion ?: "",
176+
instructions = serverInstructions,
177+
capabilities = caps,
178+
// Per spec the tools listing is meaningful only when the server declares the capability.
179+
// Default to the listing we just fetched regardless — McpServer-from-agent always declares tools.
180+
tools = toolInfos,
181+
)
99182
}
100183

101184
private fun post(method: String, params: Any?): Any? {
@@ -161,4 +244,7 @@ internal data class McpToolDescriptor(
161244
val name: String,
162245
val description: String,
163246
val inputSchema: Map<*, *>?,
247+
val title: String? = null,
248+
val outputSchema: Map<*, *>? = null,
249+
val annotations: McpToolAnnotations? = null,
164250
)
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package agents_engine.mcp
2+
3+
// #1734 — immutable pure-data snapshot of an MCP server's full surface.
4+
// Materialized by McpClient after the initialize handshake + listings;
5+
// can also be constructed directly in tests, no transport stub needed.
6+
//
7+
// Forward-looking: this covers every field the MCP spec lets a server
8+
// expose (identity, capabilities, tools, resources, resource templates,
9+
// prompts). McpClient today populates only identity + tools (what it
10+
// already fetches); resources, resource templates, prompts, and the
11+
// capability matrix beyond what's needed for tool dispatch land in
12+
// follow-up issues. Consumers read off the same shape regardless of
13+
// which fields the live client has filled in.
14+
15+
data class McpServerInfo(
16+
/** Server-reported name (from `initialize.result.serverInfo.name`). */
17+
val name: String,
18+
/** Optional human-readable title (MCP spec extension over name). */
19+
val title: String? = null,
20+
/** Server-reported version. */
21+
val version: String,
22+
/** Negotiated protocol version. */
23+
val protocolVersion: String,
24+
/** Server-provided usage hints. Some servers send a system-prompt-style preamble here. */
25+
val instructions: String? = null,
26+
27+
/** Capability matrix — what the server says it can do. */
28+
val capabilities: McpCapabilities,
29+
30+
/**
31+
* Tools exposed via `tools/list`. Null when the server's capability
32+
* matrix declares no `tools` support; empty when supported but empty.
33+
*/
34+
val tools: List<McpToolInfo>? = null,
35+
/**
36+
* Resources exposed via `resources/list`. Null when no `resources`
37+
* capability; empty when supported but empty.
38+
*/
39+
val resources: List<McpResourceInfo>? = null,
40+
/**
41+
* RFC 6570 resource templates via `resources/templates/list`. Same
42+
* presence semantics as [resources].
43+
*/
44+
val resourceTemplates: List<McpResourceTemplateInfo>? = null,
45+
/**
46+
* Prompts via `prompts/list`. Null when no `prompts` capability.
47+
*/
48+
val prompts: List<McpPromptInfo>? = null,
49+
)
50+
51+
data class McpCapabilities(
52+
/** Tool listing + invocation. Null when the server doesn't expose tools at all. */
53+
val tools: McpToolsCapability? = null,
54+
/** Resource listing + reading. */
55+
val resources: McpResourcesCapability? = null,
56+
/** Prompt listing + retrieval. */
57+
val prompts: McpPromptsCapability? = null,
58+
/** Server can emit `logging/message` notifications. */
59+
val logging: Boolean = false,
60+
/** Server supports `completion/complete` for argument completion. */
61+
val completions: Boolean = false,
62+
/** Catch-all for capabilities not yet standardized in the MCP spec. */
63+
val experimental: Map<String, Any?> = emptyMap(),
64+
)
65+
66+
data class McpToolsCapability(
67+
/** Server emits `notifications/tools/list_changed` when its tool list mutates. */
68+
val listChanged: Boolean = false,
69+
)
70+
71+
data class McpResourcesCapability(
72+
/** Server emits `notifications/resources/list_changed`. */
73+
val listChanged: Boolean = false,
74+
/** Server supports `resources/subscribe` for per-resource update notifications. */
75+
val subscribe: Boolean = false,
76+
)
77+
78+
data class McpPromptsCapability(
79+
/** Server emits `notifications/prompts/list_changed`. */
80+
val listChanged: Boolean = false,
81+
)
82+
83+
data class McpToolInfo(
84+
val name: String,
85+
val title: String? = null,
86+
val description: String? = null,
87+
/** JSON Schema describing the tool's argument shape. */
88+
val inputSchema: Map<String, Any?>,
89+
/** Optional JSON Schema describing the tool's structured result shape. */
90+
val outputSchema: Map<String, Any?>? = null,
91+
val annotations: McpToolAnnotations? = null,
92+
)
93+
94+
/**
95+
* Server-provided hints about a tool's behavior. The MCP spec is explicit
96+
* that these are advisory — clients must NOT rely on them for safety
97+
* decisions; an LLM treating `destructiveHint = false` as proof of safety
98+
* is a security bug.
99+
*/
100+
data class McpToolAnnotations(
101+
val title: String? = null,
102+
/** Tool doesn't modify its environment. */
103+
val readOnlyHint: Boolean? = null,
104+
/** Tool may perform destructive updates. */
105+
val destructiveHint: Boolean? = null,
106+
/** Same args yield same result (no side effects worth re-checking). */
107+
val idempotentHint: Boolean? = null,
108+
/** Tool's effects extend beyond the local environment (e.g., calls external APIs). */
109+
val openWorldHint: Boolean? = null,
110+
)
111+
112+
data class McpResourceInfo(
113+
val uri: String,
114+
val name: String,
115+
val title: String? = null,
116+
val description: String? = null,
117+
val mimeType: String? = null,
118+
/** Size in bytes when known. */
119+
val size: Long? = null,
120+
val annotations: McpResourceAnnotations? = null,
121+
)
122+
123+
data class McpResourceAnnotations(
124+
/** Intended consumer(s): `"user"`, `"assistant"`, or both. */
125+
val audience: List<String>? = null,
126+
/** 0.0 (least important) to 1.0 (most important). */
127+
val priority: Double? = null,
128+
/** ISO 8601 timestamp of the resource's last modification. */
129+
val lastModified: String? = null,
130+
)
131+
132+
data class McpResourceTemplateInfo(
133+
/** RFC 6570 URI template (e.g., `file:///{path}`). */
134+
val uriTemplate: String,
135+
val name: String,
136+
val title: String? = null,
137+
val description: String? = null,
138+
val mimeType: String? = null,
139+
)
140+
141+
data class McpPromptInfo(
142+
val name: String,
143+
val title: String? = null,
144+
val description: String? = null,
145+
val arguments: List<McpPromptArgument> = emptyList(),
146+
)
147+
148+
data class McpPromptArgument(
149+
val name: String,
150+
val description: String? = null,
151+
val required: Boolean = false,
152+
)
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package agents_engine.mcp
2+
3+
import agents_engine.core.agent
4+
import kotlin.test.AfterTest
5+
import kotlin.test.Test
6+
import kotlin.test.assertEquals
7+
import kotlin.test.assertFalse
8+
import kotlin.test.assertNotNull
9+
import kotlin.test.assertNull
10+
import kotlin.test.assertTrue
11+
12+
// #1734 — round-trip test for McpServerInfo, no transport stub needed.
13+
//
14+
// We use the framework's own services as fixtures: an agent → exposed via
15+
// `McpServer.from(...)` → connected via `McpClient`. The wire is real
16+
// (loopback HTTP). What McpClient surfaces in its snapshot must match
17+
// what McpServer exposes, end-to-end. If a future change drifts either
18+
// end of the wire — server reports a new capability shape, client parser
19+
// misses a field — this test fires.
20+
21+
class McpServerInfoSnapshotTest {
22+
23+
private var mcpServer: McpServer? = null
24+
private var mcpClient: McpClient? = null
25+
26+
@AfterTest
27+
fun teardown() {
28+
mcpClient?.close()
29+
mcpServer?.stop()
30+
}
31+
32+
@Test
33+
fun `snapshot reflects agent-as-MCP server identity, capabilities, and tool listing`() {
34+
// The agent has a single non-agentic skill — that's what McpServer
35+
// currently exposes (agentic skills need server-side LLM access,
36+
// out of scope for McpServer.from in the current slice).
37+
val greeter = agent<String, String>("greeter") {
38+
skills {
39+
skill<String, String>("greet", "Returns a greeting for the provided name") {
40+
implementedBy { name -> "Hello, $name!" }
41+
}
42+
}
43+
}
44+
45+
val server = McpServer.from(greeter) {
46+
port = 0 // auto-assign
47+
expose("greet")
48+
}.start().also { mcpServer = it }
49+
50+
val client = McpClient.connect(server.url).also { mcpClient = it }
51+
52+
val info = client.snapshot
53+
assertNotNull(info, "snapshot must be populated after handshake + loadTools")
54+
55+
// ── Identity ───────────────────────────────────────────────
56+
assertEquals("agents-kt-mcp-server", info.name)
57+
assertEquals("0.1.3", info.version)
58+
assertEquals(MCP_PROTOCOL_VERSION, info.protocolVersion)
59+
// McpServer doesn't emit title or instructions today; assert their absence
60+
// so a future change that DOES emit them updates this test.
61+
assertNull(info.title, "McpServer doesn't emit serverInfo.title yet")
62+
assertNull(info.instructions, "McpServer doesn't emit initialize.instructions yet")
63+
64+
// ── Capabilities ───────────────────────────────────────────
65+
// McpServer reports `{ tools: { listChanged: false } }` and nothing else.
66+
val caps = info.capabilities
67+
assertNotNull(caps.tools, "server declared tools capability")
68+
assertFalse(caps.tools.listChanged, "server doesn't push tool-list-changed notifications")
69+
assertNull(caps.resources, "server doesn't declare resources capability today")
70+
assertNull(caps.prompts, "server doesn't declare prompts capability today")
71+
assertFalse(caps.logging)
72+
assertFalse(caps.completions)
73+
assertTrue(caps.experimental.isEmpty())
74+
75+
// ── Tools ──────────────────────────────────────────────────
76+
val tools = info.tools
77+
assertNotNull(tools, "tools listing populated when the server declares the capability")
78+
assertEquals(1, tools.size, "exactly one skill was exposed")
79+
val tool = tools.single()
80+
assertEquals("greet", tool.name)
81+
assertEquals("Returns a greeting for the provided name", tool.description)
82+
// String-input skill yields the canonical String input schema:
83+
// { type: object, properties: { input: { type: string } }, required: [input] }
84+
assertEquals("object", tool.inputSchema["type"])
85+
@Suppress("UNCHECKED_CAST")
86+
val props = tool.inputSchema["properties"] as? Map<String, Any?>
87+
assertNotNull(props, "input schema should have properties: ${tool.inputSchema}")
88+
assertTrue("input" in props, "String-skill schema should expose `input` key: $props")
89+
// No annotations / outputSchema / title yet from McpServer.from — pin the absence.
90+
assertNull(tool.title)
91+
assertNull(tool.outputSchema)
92+
assertNull(tool.annotations)
93+
}
94+
}

0 commit comments

Comments
 (0)