mcp_client

Package VersionHex DocsLicenseTarget

MCP (Model Context Protocol) client for Gleam — connect to any MCP server via JSON-RPC 2.0 over STDIO.


What is MCP?

The Model Context Protocol (MCP) is an open standard introduced by Anthropic in late 2024 that defines how AI applications connect to external tools and data sources. A host (e.g. an LLM application) runs one or more clients, each connected to an MCP server that exposes capabilities — tools, resources, and prompts — over a JSON-RPC 2.0 interface. Servers can be local processes launched over STDIO, or remote services reachable over HTTP/SSE. mcp_client implements the STDIO transport, which covers the vast majority of real-world MCP servers available today.


Why mcp_client?

Before this package, there was no MCP client library in the Gleam or BEAM ecosystem. Developers who wanted to integrate MCP servers into a Gleam application had three unsatisfying choices:

  1. Write raw JSON-RPC 2.0 strings and manage Erlang ports by hand — error-prone, not reusable.
  2. Shell out to a Node.js or Python MCP SDK wrapper — adds runtime overhead and a process-management burden.
  3. Skip MCP entirely and implement proprietary tool APIs — loses the growing ecosystem of ready-made MCP servers (GitHub, filesystem, search, databases, …).

mcp_client fills this gap with a pure-Gleam/BEAM solution: lightweight OTP-supervised actors, no Node.js runtime required, and a three-layer architecture that keeps each concern cleanly separated.


Quick start

Add the dependency to gleam.toml:

[dependencies]
mcp_client = ">= 0.1.0 and < 2.0.0"

Then:

import mcp_client
import gleam/dict
import gleam/io

pub fn main() {
  let assert Ok(client) = mcp_client.new()

  let assert Ok(Nil) = mcp_client.register(client, mcp_client.ServerConfig(
    name: "filesystem",
    command: "npx",
    args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
    env: [],
    retry: mcp_client.retry(max_attempts: 3, base_delay_ms: 500),
  ))

  // Call a tool
  let assert Ok(result) = mcp_client.call(
    client, "filesystem/list_directory", "{\"path\":\"/tmp\"}",
  )
  io.println(result)

  // Read a resource
  let assert Ok(content) = mcp_client.read(client, "filesystem", "file:///tmp/hello.txt")
  io.println(content)

  mcp_client.stop(client)
}

API reference

Types

Type Description
Client Opaque handle to a running MCP client
ServerConfig Server config: name, command, args, env, retry
RetryPolicyNoRetry or Retry(max_attempts, base_delay_ms)
ToolSpec Tool name + description
Tool Discovered tool: spec, server_name, original_name
Resource Discovered resource: uri, name, description, server_name
PromptArg Prompt argument definition: name, description, required
Prompt Discovered prompt template: name, description, server_name, arguments

Functions

Function Signature Description
new() -> Result(Client, _) Start a new MCP client
stop(Client) -> Nil Shutdown client and all server processes
register(Client, ServerConfig) -> Result(Nil, String) Connect to a server; discovers tools, resources, and prompts
unregister(Client, String) -> Result(Nil, String) Disconnect from a server; removes all its capabilities
servers(Client) -> List(String) Names of all registered servers
tools(Client) -> List(Tool) All discovered tools across all servers
call(Client, String, String) -> Result(String, String) Execute a tool by qualified name ("server/tool") with JSON args
resources(Client) -> List(Resource) All discovered resources across all servers
read(Client, String, String) -> Result(String, String) Read a resource by server name + URI
prompts(Client) -> List(Prompt) All discovered prompt templates across all servers
prompt(Client, String, String, Dict(String,String)) -> Result(String, String) Render a prompt by server name + prompt name + args
no_retryRetryPolicy Constant — evict server on crash, no reconnection
retry(Int, Int) -> RetryPolicy Retry with exponential backoff: retry(max_attempts, base_delay_ms)

Return values

All three call functions (call, read, prompt) return Result(String, String) where the Ok value is the raw JSON string of the result field from the JSON-RPC 2.0 response. Callers parse it however they need; gleam_json is the natural choice.

Tool name convention

After register, every tool name is qualified as "server_name/tool_name". This prevents collisions when multiple servers expose tools with the same bare name. The original_name field on Tool holds the bare name as declared by the server.


Retry and reconnection

When an MCP server crashes, the default behaviour (NoRetry) is to evict it from state — subsequent calls to its tools return Error. With a Retry policy, the manager automatically tries to reconnect with exponential backoff before evicting:

// Retry up to 3 times: sleeps 500ms, 1000ms, 2000ms between attempts
retry: mcp_client.retry(max_attempts: 3, base_delay_ms: 500)

// No reconnection — evict immediately on crash
retry: mcp_client.no_retry

The specific call that triggered the crash still returns Error (the response was lost). Subsequent calls on the same server succeed once reconnection completes. If all attempts fail, the server is evicted and the manager remains operational for other servers.


Architecture

┌─────────────────────────────────────────────────────────────────┐
│  Application code                                               │
│    mcp_client.new() / register() / call() / read() / prompt()   │
└──────────────────────────┬──────────────────────────────────────┘
                           │  thin wrappers
┌──────────────────────────▼──────────────────────────────────────┐
│  Facade  —  mcp_client.gleam                                     │
│  Exports: Client  ServerConfig  RetryPolicy                     │
│           Tool  Resource  Prompt  PromptArg                     │
└──────────────────────────┬──────────────────────────────────────┘
                           │  OTP actor (gen_server semantics)
┌──────────────────────────▼──────────────────────────────────────┐
│  Manager  —  mcp_client/manager.gleam                            │
│  • Dict(name, ServerConnection) + Dict(qualified, Tool)         │
│  • List(Resource) + List(Prompt) per registered server          │
│  • MCP initialize handshake + protocol version validation       │
│  • tools/list · resources/list · prompts/list on registration   │
│  • tools/call · resources/read · prompts/get routing            │
│  • Crash detection → eviction or exponential-backoff reconnect  │
└──────────────────────────┬──────────────────────────────────────┘
                           │  one StdioTransport actor per server
┌──────────────────────────▼──────────────────────────────────────┐
│  Transport  —  mcp_client/transport.gleam                        │
│  • Erlang port (spawn_executable + {line, 1 MB} + exit_status)  │
│  • send_and_receive / send_only                                 │
│  • Backed by mcp_client_ffi.erl                                  │
└─────────────────────────────────────────────────────────────────┘

Each layer has a single responsibility. The transport knows nothing about MCP semantics — it only moves bytes. The manager speaks MCP but knows nothing about how the application uses the results. The facade hides internal types and presents a stable public API.


Protocol compliance

mcp_client implements the MCP 2024-11-05 specification.

Feature Status
Transport STDIO (newline-delimited JSON-RPC 2.0)
initialize / notifications/initialized
Protocol version validation ✅ Strict — rejects unsupported versions
tools/list + tools/call
resources/list + resources/read
prompts/list + prompts/get
Auto-reconnection with exponential backoff
HTTP/SSE transport (MCP 2025-03-26) Not implemented — planned for v0.3.0
Server-sent notifications Not implemented

Design decisions

Qualified tool names (server_name/tool_name)

Multiple MCP servers frequently expose tools with identical bare names (read_file, search, list). Qualifying every name with the server prefix at discovery time means the caller never has to think about which server a tool came from. original_name is preserved so the manager can use it in tools/call.

Isolated stderr

Erlang ports opened with spawn_executable only capture stdout. MCP servers commonly write logs to stderr. By not capturing stderr, those messages reach the OS without polluting the JSON-RPC response stream — the root cause of intermittent parse failures in early prototypes.

1 MB line buffer ({line, 1048576})

The MCP protocol sends each JSON-RPC message as a single newline-terminated line. Real-world tool responses (filesystem listings, search results) can exceed tens of kilobytes. Erlang's default {line, 1024} would silently truncate them. The big_data integration test verifies 8 KB responses are not truncated.

Dead-server eviction / reconnection

If a server process dies, the next call returns an error containing "exited" or equivalent. With NoRetry the server is evicted immediately; with Retry the manager sleeps and re-runs the full connection sequence (initialize → tools/list → resources/list → prompts/list). Reconnection is synchronous inside the manager actor, so the client is briefly paused during backoff. The specific call that triggered the crash still returns Error; subsequent calls succeed on the restored connection.

attempt_connection as the single connection entry point

Both initial registration and reconnection go through attempt_connection/1, which starts the transport, runs the initialize handshake, and discovers all three capability types. This guarantees consistent state after reconnection — tools, resources, and prompts are always re-fetched together.

Three-layer separation

Transport, manager, and facade are separate modules with clear contracts. Adding HTTP/SSE transport in v0.3.0 will not require touching manager or facade code. Transport tests use raw JSON-RPC strings; manager tests run the full MCP handshake; facade tests verify delegation only.


Tested against real MCP servers

The following production MCP servers have been used with this client:

Server Package Notes
GitHub MCP @modelcontextprotocol/server-github Tool discovery + search_repositories, create_issue
Filesystem MCP @modelcontextprotocol/server-filesystemread_file, list_directory, write_file
Shell Server (fastmcp) mcp-server-shell via fastmcp Custom tool execution via shell commands
Brave Search MCP @modelcontextprotocol/server-brave-searchbrave_web_search with API key via env

Extraction origin

mcp_client was extracted from Supernova, a Gleam-based AI assistant runtime. The MCP client layer was originally written as supernova/adapters/mcp/stdio, supernova/adapters/mcp_manager, and supernova_mcp_ffi.erl. It was promoted to a standalone package to make it reusable by any Gleam project that needs MCP connectivity.


License

Apache-2.0. See LICENSE.