-
Notifications
You must be signed in to change notification settings - Fork 0
MCP Integration
Agents.KT speaks the Model Context Protocol in both directions:
- As an MCP client — your agent consumes tools exposed by external MCP servers (filesystem, GitHub, sqlite, Redmine, your own services, …).
-
As an MCP server — your agent's skills become tools that any MCP client (Claude Code, Cursor, our own
McpClient, …) can call.
Three transports work for both directions: Streamable HTTP, stdio (subprocess), and TCP. Zero extra dependencies — just JDK 21.
val coder = agent<String, String>("coder") {
mcp {
server("github") {
url = "https://api.github.com/mcp"
auth = McpAuth.Bearer(System.getenv("GITHUB_TOKEN"))
}
server("filesystem") {
command = listOf("npx", "@modelcontextprotocol/server-filesystem", "/src")
}
server("internal") {
host = "mcp.internal"; port = 9000
}
}
skills {
skill<String, String>("work", "Do the work, calling tools as needed") {
tools(/* names — see below */)
}
}
}Each server(name) { }:
- declares exactly one transport:
url=(HTTP) xorcommand=(stdio) xorhost=+port=(TCP) - optional
auth = McpAuth.Bearer(token)— HTTP only; validated at agent-build - connects at agent-build time and registers the server's tools into the agent's
toolMap - prefixes every tool name with the server name:
github.create_pull_request,filesystem.read_file,internal.foo
Tool collisions across servers can't happen — the prefix is the namespace.
skills {
skill<String, String>("work", "...") {
// MCP-discovered tool names — runtime, not compile-time refs.
// No user-declared `tool(...)` exists for these, so the string overload
// is the canonical way to allowlist them. (See: Typed tool refs)
tools("github.create_pull_request", "filesystem.read_file", "internal.foo")
}
}Why string form here? MCP tools are discovered when the agent connects to the remote server, not declared with
tool(...)in your agent block. There's no Kotlin handle to capture, so the typedtools(handle)overload doesn't apply. The string overload is intentionally retained for this case (and for built-in tools likeescalate,throwException,memory_*) — see Typed tool refs.
agent.mcpClients returns the connected clients so you can release them in tests or long-running shutdowns:
agent.mcpClients.forEach { it.close() }In short-lived processes (CLIs, scripts) connections close with the JVM.
| Transport | When to use | Example |
|---|---|---|
| HTTP (Streamable) | Hosted MCP servers, anything with a URL | url = "https://api.example.com/mcp" |
| stdio | Local CLI-launched MCP servers (filesystem, github, sqlite, …) | command = listOf("npx", "@modelcontextprotocol/server-filesystem", "/src") |
| TCP | Internal/private deployments where you want to skip HTTP overhead | host = "10.0.0.5"; port = 9000 |
Today: McpAuth.None (default) and McpAuth.Bearer(token). Stdio and TCP don't take auth — the connection itself is the auth (you launched the process or you can reach the host). Bearer is HTTP-only and the framework will refuse it on the wrong transport at agent-build.
OAuth 2.1 (with PKCE flow, refresh, secure storage) is on the roadmap.
The DSL is a thin wrapper. If you need fine-grained control:
val client = McpClient.connect("https://api.example.com/mcp", McpAuth.Bearer("…")) // HTTP
val client = McpClient.connectTcp("10.0.0.5", 9000) // TCP
val client = McpClient.connectStdio(listOf("npx", "@modelcontextprotocol/server-filesystem", "/src")) // stdio
client.toolDefs(prefix = "fs").forEach { /* register manually */ }
client.serverProtocolVersion // negotiated MCP version, e.g. "2025-03-26"
client.serverName // serverInfo.name from initialize
client.close()MCP_PROTOCOL_VERSION is the constant the client sends on initialize. Bump it in one place when upgrading.
McpServer.from(agent) turns any agent into an MCP server. Each exposed skill becomes a tool whose inputSchema is generated from the skill's IN type via @Generable reflection.
val greeter = agent<String, String>("greeter") {
skills {
skill<String, String>("greet", "Greets a person by name") {
implementedBy { name -> "Hello, $name!" }
}
}
}
val server = McpServer.from(greeter) {
port = 8080 // 0 = auto-assigned, server.url tells you which
expose("greet")
}.start()
println(server.url) // http://localhost:8080/mcp
// later
server.stop()Skill IN type |
Generated inputSchema
|
Args expected |
|---|---|---|
String |
{type: object, properties: {input: {type: string}}, required: [input]} |
{"input": "..."} |
@Generable data class |
from KClass.jsonSchema() — full schema including @Guide descriptions |
the data class fields as a JSON object |
| anything else | rejected at start()
|
— |
Example with a typed input:
@Generable("A person being greeted")
data class GreetRequest(
@Guide("Name to greet") val name: String,
@Guide("Greeting language") val language: String = "en",
)
val a = agent<GreetRequest, String>("typed-greeter") {
skills {
skill<GreetRequest, String>("greet", "Typed greeting") {
implementedBy { req -> "[${req.language}] Hello, ${req.name}!" }
}
}
}
McpServer.from(a) { expose("greet") }.start()
// Schema clients see:
// {type: object, properties: {name: {type: string, description: "Name to greet"},
// language: {type: string, description: "Greeting language", default: "en"}}}Every result is sent as a single text content block. Strings go as-is; other types are stringified via toString(). Exceptions surface as isError: true with the message, so the calling LLM sees the failure rather than a transport error.
- HTTP transport only
- Non-agentic skills only — skills declared via
implementedBy { }. Agentic skills (tools(...)) need server-side LLM access, out of scope for now. - No incoming auth check on the server side. Put a reverse proxy in front if you need it.
When you consume external MCP servers via mcp { server() }, every discovered tool gets registered as a normal ToolDef and goes through the same per-skill allowlist check as any local tool — see Tool Authorization Model. A skill that doesn't list github.create_pull_request cannot call it, even if the GitHub MCP server is registered on the agent.
For agents you want to ship as runnable JARs (or Docker images, or GraalVM native binaries), McpRunner wraps McpServer with picocli-style ergonomics — your main becomes one line:
fun main(args: Array<String>) = exitProcess(McpRunner.serve(greeter, args) {
port = 8080 // overridden by --port
expose("greet") // overridden by --expose (repeatable)
})The runner:
- Parses CLI args (block defaults override-able by flags)
- Validates (port range, skill names exist)
- Calls
McpServer.from(agent) { ... }.start() - Prints
Listening on http://...:N/mcp+ session id - Registers a JVM shutdown hook for graceful
stop() - Blocks until SIGTERM / SIGINT
- Returns the process exit code
Flags:
| Flag | Purpose |
|---|---|
--port N |
Bind port (default 0 = OS-assigned) |
--expose NAME |
Skill to expose (repeatable; replaces block defaults if any --expose is passed) |
-h, --help |
Print usage and return 0 |
-V, --version |
Print version and return 0 |
Hand-rolled CLI parser — no picocli dependency, stays consistent with the project's "JDK 21 only, no extra deps" ethos.
For testing or programmatic introspection, use McpRunner.resolveConfig(args, configure) to get the parsed RunnerConfig without starting a server.
Once server.start() is running on, say, http://localhost:8080/mcp, anything that speaks MCP can call it:
val client = McpClient.connect(server.url)
client.toolDefs() // → [ToolDef(name="greet", ...)]
client.call("greet", mapOf("input" to "Kon")) // → "Hello, Kon!"Add an entry to ~/.claude.json:
{
"mcpServers": {
"my-agent": {
"type": "http",
"url": "http://localhost:8080/mcp"
}
}
}Restart Claude Code; the skill appears as a callable tool prefixed mcp__my-agent__greet.
Most MCP clients accept the same URL. Consult the client's docs for its config file location and the field name (url, serverUrl, etc.).
The server speaks standard JSON-RPC 2.0 over Streamable HTTP, protocol version 2025-03-26. Any conformant MCP client library works.
Two mock servers ship in test sources for hermetic unit tests:
// HTTP, in-process
val mock = MockMcpServer.start {
tool("ping") { respond { _ -> listOf(textBlock("pong")) } }
requireBearer("test-token") // optional: enforce auth
protocolVersion = "2024-11-05" // optional: simulate version drift
}
val client = McpClient.connect(mock.url, McpAuth.Bearer("test-token"))
// TCP, in-process
val tcpMock = MockTcpMcpServer.start { tool("ping") { … } }
val client = McpClient.connectTcp("127.0.0.1", tcpMock.port)
// stdio, in-process via piped streams (no subprocess needed)
val stdioMock = MockStdioMcpServer.start { tool("ping") { … } }
val client = stdioMock.connectClient()The assertions are the same regardless of transport — flip the wire, the protocol behavior is identical.
| Symbol | Direction | What it does |
|---|---|---|
Agent.mcp { server() } |
client | Declarative agent DSL — connects servers, namespaces tools |
Agent.mcpClients |
client | Connected clients for lifecycle control |
McpClient.connect(url, auth?) |
client | HTTP transport |
McpClient.connectTcp(host, port) |
client | TCP transport |
McpClient.connectStdio(command, env?, workingDir?, stderrSink?) |
client | Spawn a child process and speak stdio |
McpClient.connectStreams(input, output) |
client | Custom IPC channel |
McpClient.toolDefs(prefix?) |
client | Mint ToolDefs; prefix becomes ${prefix}.${toolName}
|
McpClient.serverProtocolVersion / serverName / serverVersion
|
client | Negotiated handshake info |
McpAuth.None / McpAuth.Bearer(token)
|
client | HTTP auth |
McpServer.from(agent) { port; expose(...) }.start() |
server | Expose agent skills as MCP tools |
McpRunner.serve(agent, args, configure) |
server | Picocli-style one-liner main: parses CLI args, starts server, blocks until shutdown |
MCP_PROTOCOL_VERSION |
both | Default protocol version ("2025-03-26") |
MockMcpServer / MockTcpMcpServer / MockStdioMcpServer
|
tests | In-process mocks for hermetic testing |
Getting Started
Core Concepts
Composition Operators
LLM Integration
- Model & Tool Calling
- MCP Integration
- Agent Deployment Modes
- Swarm
- Tool Error Recovery
- Skill Selection & Routing
- Budget Controls
- Observability Hooks
Guided Generation
Agent Memory
Reference
- API Quick Reference
- Type Algebra Cheat Sheet
- Glossary
- Best Practices
- Cookbook & Recipes
- Troubleshooting & FAQ
- Roadmap
Contributing