Construindo um servidor MCP em Kotlin: o que você precisa saber sobre stdio e JSON-RPC
Se você programa em Kotlin e quer construir ferramentas para agentes de IA — aqueles que rodam no Cursor, Claude Code ou VS Code — a boa notícia é que você não precisa aprender Node.js nem Python. A stack que você já conhece fecha o ciclo inteiro.
Foi exatamente o que fez o engenheiro Joseph Sanjaya ao construir o gradle-opt-mcp, um servidor MCP que dá ao Claude acesso direto ao build Gradle: gráficos de dependência, saúde do cache, build scans e sumários de teste — tudo sem colar logs manualmente. E ele fez isso em Kotlin puro, usando o SDK oficial que é idiomático e ativamente mantido.
Mas há uma regra nova que você precisa aprender — e um instinto do mundo Android que pode matar sua sessão antes do primeiro tool call.
O transporte stdio: a única regra que muda tudo
No transporte stdio — que Cursor, Claude Desktop e Claude Code usam — stdout é o socket JSON-RPC. Cada byte que você escreve nele é protocolo.
Quando você roda um servidor MCP, o cliente (Cursor, Claude) executa java -jar seu-server.jar. Seu main inicia. Stdin vira o socket de entrada. Stdout vira o socket de saída. O processo pai segura ambas as pontas do pipe.
O handshake é simples: o cliente envia initialize, você responde com nome, versão e capabilities. O cliente confirma com notifications/initialized. Depois vem tools/list, você responde com o catálogo, e a partir daí é um fluxo de tools/call até o stdin fechar.
Quando o stdin fecha, você sai. Pendure uma thread e o editor fica preso com um processo que não responde e não morre.
O instinto que mata a sessão: println no stdout
Engenheiros Kotlin logam no stdout. Não é consciente — é só o que println faz. No Android, o Logback roteia tudo para o logcat e nada quebra. No transporte stdio, quebra imediatamente.
Sanjaya conta que estava dez minutos dentro de uma sessão funcional quando o Cursor ficou mudo. Sem erro, sem timeout. Ele rodou o JAR manualmente, pipeou um frame initialize e viu a resposta morrer no meio do parse. Uma linha acima do { de abertura estava uma mensagem de startup do Logback. Três caracteres de ruído de log foram suficientes para corromper o framing.
O fix são três linhas — mas a ordem importa mais que as linhas:
fun main() = runBlocking {
val mcpOutput = System.out // capture o wire primeiro
System.setOut(System.err) // redirecione tudo para stderr
val transport = StdioServerTransport(
input = System.`in`.asSource().buffered(),
output = mcpOutput.asSink().buffered()
)
// construa server, registre tools, createSession(transport)
}Inverta a ordem e você salva uma referência ao stderr, não ao stdout. Você protege o stream que envenenou. Depois desse swap, System.out é uma mentira controlada — qualquer biblioteca que logar “normalmente” vai parar no stderr, onde o cliente MCP ignora para framing. O wire real é o mcpOutput, entregue diretamente ao StdioServerTransport.
O shell do servidor deve permanecer entediante
Com o stdout seguro, o main tem um único trabalho: montar o esqueleto do protocolo e sair do caminho. Se o main sabe como parsear um erro de negócio, ele já está gordo demais.
val server = Server(
serverInfo = Implementation(name = "my-kotlin-mcp", version = "1.0.0"),
options = ServerOptions(
capabilities = ServerCapabilities(
tools = ServerCapabilities.Tools(listChanged = true)
)
)
)
registrars.forEach { it.register(server) }Cada registrar chama server.addTool(...) com nome, descrição, schema e um handler suspend. A lógica de domínio não pertence ao main.
Coroutines fecham o loop naturalmente
Handlers de ferramentas MCP são funções suspend. O SDK as chama dentro de um escopo de coroutine que ele gerencia. Isso significa que você pode chamar sua lógica de domínio suspend existente diretamente de um handler — sem adaptadores de callback, sem pool de threads, sem pontes reativas.
server.addTool(
name = "gradle_health",
description = "Relatório de saúde do projeto — módulos, versões, estrutura.",
inputSchema = toolSchema
) { args ->
val projectDir = args[projectDirKey]?.jsonPrimitive?.content
?: return@addTool CallToolResult(
content = listOf(TextContent("projectDir é obrigatório")),
isError = true
)
val result = gradleHealthAnalyzer.analyze(projectDir) // suspend fun existente
CallToolResult(content = listOf(TextContent(result.toSummary())))
}É a mesma função suspend que você chamaria de uma ViewModel. Você não escreveu uma versão nova. Não envelopou. Apenas chamou.
Data classes viram contratos de ferramenta
Agentes não leem seu README no meio de uma chamada. O que você retorna de um handler é o contrato — o modelo age baseado nisso, ou falha. JSON colado com string envelhece mal. Um campo renomeado e o agente começa a alucinar a resposta que esperava.
@Serializable
data class AnalysisResult(
val projectDir: String,
val summary: String,
val findings: List<String>,
val truncated: Boolean,
val failureReason: String? = null
)truncated: true diz ao modelo que a lista está incompleta. Um "..." solto em prosa faz o agente reportar que viu tudo quando não viu. failureReason distingue “scan falhou” de “filtro não encontrou nada” de “sem relatórios ainda” — três estados diferentes que parecem idênticos se você retorna 0 findings para todos.
Ciclo de vida: o formato do processo que você já conhece
No transporte stdio MCP, o lifetime do processo é exatamente uma sessão do editor. Stdin fecha, você sai. A implementação é com coroutines limpas:
val done = CompletableDeferred<Unit>()
server.onClose { done.complete(Unit) }
runCatching { server.createSession(transport) }
done.await()runBlocking em volta do main é suficiente. Você não está gerenciando um processo de longa duração com jobs de fundo. Você é um worker RPC de curta duração. O escopo é a sessão. Quando a sessão termina, você termina.
Prove no wire, não no teste unitário
Testes unitários não pegam o bug do stdout. A prova é uma sessão stdio real. Gere o fat JAR, pipeie frames JSON-RPC no processo rodando, capture stdout como saída de protocolo, stderr como log. Se uma linha do Logback aparecer no stdout antes do { de abertura, você falhou a regra novamente.
Sequência mínima para confiar no boot path: initialize (request id 1) → notifications/initialized → tools/call contra uma ferramenta confiável (request id 3). Se o passo 3 retornar um resultado framed e o stderr ficou limpo, o servidor está sólido.
Conclusão: a stack fecha, um hábito muda
O fat JAR que você já sabe construir é o processo que o Cursor spawna. As funções suspend que você já escreve são os tool handlers. As data classes @Serializable que você já usa são os contratos. O padrão CompletableDeferred que você já conhece é o lifecycle da sessão.
Cada peça já estava lá. A única coisa que mudou foi uma regra, aplicada uma vez, no topo do main: capture o wire antes que qualquer outra coisa possa tocá-lo. Se você já escreve Kotlin, você já tem a stack inteira. Só precisa desaprender o hábito de achar que o stdout é seu.
O projeto gradle-opt-mcp está no GitHub com 14 ferramentas, fat JAR e transporte stdio rodando em produção — uma referência completa para quem quiser ver cada padrão deste artigo executando contra um projeto Gradle real.
Descubra mais sobre noticiAI
Assine para receber nossas notícias mais recentes por e-mail.



