Skip to content

MCP Integration

skobeltsyn edited this page May 4, 2026 · 4 revisions

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.


Consuming MCP servers

The mcp { server() } agent DSL

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) xor command= (stdio) xor host=+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 typed tools(handle) overload doesn't apply. The string overload is intentionally retained for this case (and for built-in tools like escalate, throwException, memory_*) — see Typed tool refs.

Lifecycle

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.

Transports

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

Auth

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.

Lower-level: McpClient directly

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.


Exposing an agent as an MCP server

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 input shapes

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"}}}

Output

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.

Scope (current cut)

  • 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.

Tool authorization is enforced runtime-side

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.


Standalone server with McpRunner

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:

  1. Parses CLI args (block defaults override-able by flags)
  2. Validates (port range, skill names exist)
  3. Calls McpServer.from(agent) { ... }.start()
  4. Prints Listening on http://...:N/mcp + session id
  5. Registers a JVM shutdown hook for graceful stop()
  6. Blocks until SIGTERM / SIGINT
  7. 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.

How external clients consume your McpServer

Once server.start() is running on, say, http://localhost:8080/mcp, anything that speaks MCP can call it:

From our own McpClient (round-trip)

val client = McpClient.connect(server.url)
client.toolDefs()                              // → [ToolDef(name="greet", ...)]
client.call("greet", mapOf("input" to "Kon")) // → "Hello, Kon!"

From Claude Code

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.

From Cursor / other MCP-aware IDEs

Most MCP clients accept the same URL. Consult the client's docs for its config file location and the field name (url, serverUrl, etc.).

From any MCP client library

The server speaks standard JSON-RPC 2.0 over Streamable HTTP, protocol version 2025-03-26. Any conformant MCP client library works.


Testing

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.


Reference

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

Clone this wiki locally