Skip to content

Commit 9a0db0b

Browse files
wow-mileyclaude
andauthored
AMPR-155 #464: add plugin MCP client manifest extension (#472)
Extends PluginManifest with mcpServers, adds a plugin-facing McpClient facade plus credential binding, and introduces PluginContext + ExecuteStep so MCP tools dispatched from plugins flow through PluginPermissionGate as the single enforcement path. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 48de537 commit 9a0db0b

12 files changed

Lines changed: 1124 additions & 2 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package link.socket.ampere.mcp
2+
3+
import kotlinx.coroutines.sync.Mutex
4+
import kotlinx.coroutines.sync.withLock
5+
import kotlinx.serialization.json.JsonElement
6+
import link.socket.ampere.agents.tools.mcp.McpProtocol
7+
import link.socket.ampere.agents.tools.mcp.McpServerConfiguration
8+
import link.socket.ampere.agents.tools.mcp.connection.HttpClientHandler
9+
import link.socket.ampere.agents.tools.mcp.connection.HttpMcpConnection
10+
import link.socket.ampere.agents.tools.mcp.connection.McpServerConnection
11+
import link.socket.ampere.agents.tools.mcp.protocol.McpToolDescriptor
12+
import link.socket.ampere.agents.tools.mcp.protocol.ToolCallResult
13+
import link.socket.ampere.plugin.McpServerDependency
14+
15+
/**
16+
* Plugin-facing facade over an MCP server connection.
17+
*
18+
* Wraps an [McpServerConnection] (default [HttpMcpConnection]) with the
19+
* dependency declaration plus credential resolution, so plugin call sites
20+
* stay free of transport details. JSON-RPC framing remains in
21+
* [link.socket.ampere.agents.tools.mcp.protocol.McpClient]; this class
22+
* doesn't reimplement it.
23+
*
24+
* The [connectionFactory] seam exists so tests can substitute mock
25+
* connections without touching the production HTTP path.
26+
*
27+
* Lifecycle:
28+
* 1. [connect] — resolve credential, build the connection, run handshake.
29+
* 2. [listTools] — query tool descriptors from the server.
30+
* 3. [callTool] — invoke a remote tool.
31+
* 4. [close] — disconnect and release transport resources.
32+
*/
33+
class McpClient(
34+
private val dependency: McpServerDependency,
35+
private val credentialBinding: McpCredentialBinding,
36+
private val linkId: LinkId,
37+
private val connectionFactory: (McpServerDependency, McpCredential?) -> McpServerConnection =
38+
::defaultHttpConnection,
39+
) {
40+
private val mutex = Mutex()
41+
private var connection: McpServerConnection? = null
42+
43+
val isConnected: Boolean
44+
get() = connection?.isConnected == true
45+
46+
suspend fun connect(): Result<Unit> = mutex.withLock {
47+
val existing = connection
48+
if (existing != null && existing.isConnected) {
49+
return Result.success(Unit)
50+
}
51+
52+
val credentialResult = credentialBinding.resolve(linkId, dependency.uri)
53+
val credential = credentialResult.getOrElse { return Result.failure(it) }
54+
55+
val newConnection = connectionFactory(dependency, credential)
56+
connection = newConnection
57+
58+
return newConnection.connect()
59+
.mapCatching {
60+
newConnection.initialize().getOrThrow()
61+
Unit
62+
}
63+
}
64+
65+
suspend fun listTools(): Result<List<McpToolDescriptor>> {
66+
val active = connection
67+
?: return Result.failure(IllegalStateException("McpClient must connect() before listTools()"))
68+
return active.listTools()
69+
}
70+
71+
suspend fun callTool(name: String, arguments: JsonElement?): Result<ToolCallResult> {
72+
val active = connection
73+
?: return Result.failure(IllegalStateException("McpClient must connect() before callTool()"))
74+
return active.invokeTool(name, arguments)
75+
}
76+
77+
suspend fun close(): Result<Unit> = mutex.withLock {
78+
val active = connection ?: return Result.success(Unit)
79+
return active.disconnect().also { connection = null }
80+
}
81+
}
82+
83+
/**
84+
* Default factory: builds an [HttpMcpConnection] from a dependency plus the
85+
* resolved credential. Used unless the caller injects a different factory
86+
* (typically for tests).
87+
*/
88+
fun defaultHttpConnection(
89+
dependency: McpServerDependency,
90+
credential: McpCredential?,
91+
): McpServerConnection {
92+
val config = McpServerConfiguration(
93+
id = dependency.name,
94+
displayName = dependency.name,
95+
protocol = McpProtocol.HTTP,
96+
endpoint = dependency.uri,
97+
authToken = credential?.authToken,
98+
)
99+
return HttpMcpConnection(config, HttpClientHandler())
100+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package link.socket.ampere.mcp
2+
3+
import kotlin.jvm.JvmInline
4+
import kotlinx.coroutines.sync.Mutex
5+
import kotlinx.coroutines.sync.withLock
6+
import kotlinx.serialization.Serializable
7+
8+
/**
9+
* Identifier for a Link — the user-bound authorization scope under which
10+
* an MCP credential is stored.
11+
*
12+
* The Link concept is upcoming work; modeling it as a value class lets the
13+
* eventual store interface match without a churn cycle.
14+
*/
15+
@JvmInline
16+
@Serializable
17+
value class LinkId(val value: String)
18+
19+
/**
20+
* Credential material a plugin's [McpClient] surfaces into its connection
21+
* layer when calling an MCP server.
22+
*
23+
* Today only [authToken] is captured; future fields (refresh token, OAuth
24+
* metadata) can be added without breaking the binding contract.
25+
*/
26+
@Serializable
27+
data class McpCredential(
28+
val authToken: String? = null,
29+
)
30+
31+
/**
32+
* Storage contract for credentials a plugin uses to talk to an MCP server.
33+
*
34+
* Implementations are scoped per Link so credentials revocations follow the
35+
* Link lifecycle. The interface intentionally avoids leaking implementation
36+
* concerns (transactionality, persistence, encryption) so the upcoming
37+
* Link-backed store can drop in.
38+
*/
39+
interface McpCredentialBinding {
40+
suspend fun bind(linkId: LinkId, mcpUri: String, credential: McpCredential): Result<Unit>
41+
42+
suspend fun resolve(linkId: LinkId, mcpUri: String): Result<McpCredential?>
43+
44+
suspend fun unbind(linkId: LinkId, mcpUri: String): Result<Unit>
45+
}
46+
47+
/**
48+
* In-memory [McpCredentialBinding] suitable for tests and single-process
49+
* environments. The persistent Link-backed implementation lands separately.
50+
*/
51+
class InMemoryMcpCredentialBinding : McpCredentialBinding {
52+
53+
private val mutex = Mutex()
54+
private val store = mutableMapOf<Pair<LinkId, String>, McpCredential>()
55+
56+
override suspend fun bind(
57+
linkId: LinkId,
58+
mcpUri: String,
59+
credential: McpCredential,
60+
): Result<Unit> = mutex.withLock {
61+
store[linkId to mcpUri] = credential
62+
Result.success(Unit)
63+
}
64+
65+
override suspend fun resolve(
66+
linkId: LinkId,
67+
mcpUri: String,
68+
): Result<McpCredential?> = mutex.withLock {
69+
Result.success(store[linkId to mcpUri])
70+
}
71+
72+
override suspend fun unbind(
73+
linkId: LinkId,
74+
mcpUri: String,
75+
): Result<Unit> = mutex.withLock {
76+
store.remove(linkId to mcpUri)
77+
Result.success(Unit)
78+
}
79+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package link.socket.ampere.plugin
2+
3+
import kotlinx.serialization.Serializable
4+
import link.socket.ampere.plugin.permission.PluginPermission
5+
6+
/**
7+
* Declares an MCP server that a plugin depends on at runtime.
8+
*
9+
* Each declared dependency must be matched by a corresponding
10+
* [PluginPermission.MCPServer] entry in [PluginManifest.requiredPermissions]
11+
* so the user grant flow can authorize the plugin's access to it.
12+
*
13+
* @property name Local handle used by the plugin to reference the server
14+
* (e.g., `"notion"`). Distinct from the connection [uri] so plugins can
15+
* reference servers symbolically.
16+
* @property uri The MCP server endpoint (e.g., `"mcp://..."` or
17+
* `"https://..."`). Used both to dial the server and to match the
18+
* [PluginPermission.MCPServer] grant.
19+
* @property requiredPermissions Permissions the plugin will exercise via
20+
* this server. Each must also appear in
21+
* [PluginManifest.requiredPermissions]; otherwise the manifest validator
22+
* surfaces a diagnostic so the plugin author can lift the permission to
23+
* the top-level grant scope.
24+
*/
25+
@Serializable
26+
data class McpServerDependency(
27+
val name: String,
28+
val uri: String,
29+
val requiredPermissions: List<PluginPermission> = emptyList(),
30+
)
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package link.socket.ampere.plugin
2+
3+
import link.socket.ampere.agents.config.AgentActionAutonomy
4+
import link.socket.ampere.agents.execution.tools.McpTool
5+
import link.socket.ampere.agents.execution.tools.Tool
6+
import link.socket.ampere.agents.tools.mcp.connection.McpServerConnection
7+
import link.socket.ampere.mcp.LinkId
8+
import link.socket.ampere.mcp.McpClient
9+
import link.socket.ampere.mcp.McpCredential
10+
import link.socket.ampere.mcp.McpCredentialBinding
11+
import link.socket.ampere.mcp.defaultHttpConnection
12+
13+
/**
14+
* Runtime context for an active plugin instance.
15+
*
16+
* Bundles a validated [PluginManifest] together with the plugin's native
17+
* tools and the [McpClient] instances opened for each declared
18+
* [McpServerDependency]. The MCP tools discovered from each server are
19+
* exposed through [availableTools] alongside any native tools, and each
20+
* carries the originating manifest so [PluginPermissionGate][link.socket.ampere.plugin.permission.PluginPermissionGate]
21+
* still gates dispatch.
22+
*
23+
* Construction goes through [create]. It validates the manifest, opens an
24+
* [McpClient] per dependency, runs the handshake, lists tools, and wraps
25+
* each descriptor as an [McpTool]. Server failures are surfaced per server
26+
* (mirroring the existing [link.socket.ampere.agents.tools.mcp.McpServerManager]
27+
* resilience pattern) so one bad server doesn't kill the plugin.
28+
*
29+
* Tools are dispatched through
30+
* [link.socket.ampere.propel.ExecuteStep], which resolves the right
31+
* [McpClient] via [mcpClientFor].
32+
*/
33+
class PluginContext private constructor(
34+
val manifest: PluginManifest,
35+
private val nativeTools: List<Tool<*>>,
36+
private val mcpClientsByUri: Map<String, McpClient>,
37+
private val mcpToolsByServerUri: Map<String, List<McpTool>>,
38+
val serverFailures: List<PluginContextServerFailure>,
39+
) {
40+
41+
fun availableTools(): List<Tool<*>> =
42+
nativeTools + mcpToolsByServerUri.values.flatten()
43+
44+
/**
45+
* Looks up the [McpClient] responsible for a tool's originating server.
46+
*
47+
* Returns null for tools without a matching server (e.g., a stale
48+
* [McpTool] referencing a server that failed to come up).
49+
*/
50+
fun mcpClientFor(tool: McpTool): McpClient? =
51+
mcpClientsByUri[tool.serverId]
52+
53+
suspend fun close(): Result<Unit> {
54+
val errors = mutableListOf<Throwable>()
55+
mcpClientsByUri.values.forEach { client ->
56+
client.close().onFailure { errors += it }
57+
}
58+
return if (errors.isEmpty()) {
59+
Result.success(Unit)
60+
} else {
61+
Result.failure(errors.first())
62+
}
63+
}
64+
65+
companion object {
66+
suspend fun create(
67+
manifest: PluginManifest,
68+
credentialBinding: McpCredentialBinding,
69+
linkId: LinkId,
70+
nativeTools: List<Tool<*>> = emptyList(),
71+
connectionFactory: (McpServerDependency, McpCredential?) -> McpServerConnection =
72+
::defaultHttpConnection,
73+
): Result<PluginContext> {
74+
val validation = PluginManifestValidator.validate(manifest)
75+
if (validation is ManifestValidationResult.Invalid) {
76+
return Result.failure(
77+
PluginManifestValidationException(validation.reasons),
78+
)
79+
}
80+
81+
val clientsByUri = mutableMapOf<String, McpClient>()
82+
val toolsByUri = mutableMapOf<String, List<McpTool>>()
83+
val failures = mutableListOf<PluginContextServerFailure>()
84+
85+
manifest.mcpServers.forEach { dependency ->
86+
val client = McpClient(
87+
dependency = dependency,
88+
credentialBinding = credentialBinding,
89+
linkId = linkId,
90+
connectionFactory = connectionFactory,
91+
)
92+
93+
val connectResult = client.connect()
94+
if (connectResult.isFailure) {
95+
failures += PluginContextServerFailure(
96+
dependency = dependency,
97+
cause = connectResult.exceptionOrNull(),
98+
)
99+
client.close()
100+
return@forEach
101+
}
102+
103+
val toolsResult = client.listTools()
104+
val descriptors = toolsResult.getOrElse { error ->
105+
failures += PluginContextServerFailure(
106+
dependency = dependency,
107+
cause = error,
108+
)
109+
client.close()
110+
return@forEach
111+
}
112+
113+
clientsByUri[dependency.uri] = client
114+
toolsByUri[dependency.uri] = descriptors.map { descriptor ->
115+
McpTool(
116+
id = "${dependency.name}:${descriptor.name}",
117+
name = descriptor.name,
118+
description = descriptor.description,
119+
requiredAgentAutonomy = AgentActionAutonomy.ACT_WITH_NOTIFICATION,
120+
pluginManifest = manifest,
121+
serverId = dependency.uri,
122+
remoteToolName = descriptor.name,
123+
inputSchema = descriptor.inputSchema,
124+
)
125+
}
126+
}
127+
128+
return Result.success(
129+
PluginContext(
130+
manifest = manifest,
131+
nativeTools = nativeTools,
132+
mcpClientsByUri = clientsByUri,
133+
mcpToolsByServerUri = toolsByUri,
134+
serverFailures = failures,
135+
),
136+
)
137+
}
138+
}
139+
}
140+
141+
/**
142+
* Captures a per-server failure encountered during [PluginContext.create].
143+
*
144+
* Surfaced rather than thrown so a single misbehaving MCP server doesn't
145+
* fail the rest of the plugin's tools.
146+
*/
147+
data class PluginContextServerFailure(
148+
val dependency: McpServerDependency,
149+
val cause: Throwable?,
150+
)
151+
152+
/**
153+
* Thrown when a manifest fails validation during [PluginContext.create].
154+
*/
155+
class PluginManifestValidationException(
156+
val reasons: List<ManifestValidationReason>,
157+
) : Exception("Plugin manifest validation failed: $reasons")

ampere-core/src/commonMain/kotlin/link/socket/ampere/plugin/PluginManifest.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import link.socket.ampere.plugin.permission.PluginPermission
66
/**
77
* Manifest metadata for a plugin and the permissions it requires at runtime.
88
*
9-
* [requiredPermissions] defaults to empty so manifests created before the
10-
* permission schema continue to decode unchanged.
9+
* [requiredPermissions] and [mcpServers] default to empty so manifests created
10+
* before each schema addition continue to decode unchanged.
1111
*/
1212
@Serializable
1313
data class PluginManifest(
@@ -17,4 +17,5 @@ data class PluginManifest(
1717
val description: String? = null,
1818
val entrypoint: String? = null,
1919
val requiredPermissions: List<PluginPermission> = emptyList(),
20+
val mcpServers: List<McpServerDependency> = emptyList(),
2021
)

0 commit comments

Comments
 (0)