ExMCP User Guide
View SourceA comprehensive guide to using ExMCP, the Elixir implementation of the Model Context Protocol.
Table of Contents
- Introduction
- Installation
- Quick Start
- Core Concepts
- Building MCP Servers (DSL)
- Using the Native Service Dispatcher
- Building MCP Clients
- Transport Layers
- Advanced Features
- Best Practices
- Troubleshooting
Related Documentation
- Configuration Guide - Complete configuration reference
- Transport Guide - Detailed transport selection and optimization
- Security Guide - Security best practices and authentication
- Development Guide - Setup, testing, and contributing
- Phoenix Integration Guide - Phoenix/Plug integration details
Introduction
ExMCP is a complete Elixir implementation of the Model Context Protocol (MCP), enabling AI models to securely interact with local and remote resources through a standardized protocol. It supports all major MCP features including tools, resources, prompts, sampling, and the latest roots and subscription capabilities.
Key Features
- Full Protocol Support: Implements MCP specification version 2025-03-26
- Multiple Transports: stdio, Streamable HTTP (with Server-Sent Events)
- Both Client and Server: Build MCP servers or connect to existing ones
- OTP Integration: Built on Elixir's OTP principles for reliability
- Type Safety: Comprehensive type specifications throughout
- Extensible: Easy to add custom tools, resources, and prompts
Installation
Add ex_mcp to your list of dependencies in mix.exs:
def deps do
[
{:ex_mcp, "~> 1.0.0-rc.0"}
]
endThen run:
mix deps.get
Quick Start
Creating a Simple MCP Server
defmodule MyServer do
use ExMCP.Server.Handler
use ExMCP.Server.DSL, name: "my-server", version: "1.0.0"
tool "hello", "Says hello to someone" do
param :name, :string, required: true, description: "Name to greet"
run fn %{name: name}, state ->
{:ok, "Hello, #{name}!", state}
end
end
end
# Start the server
{:ok, server} = MyServer.start_link(transport: :stdio)Creating a Simple MCP Client
# Connect to a server
{:ok, client} = ExMCP.Client.start_link(
transport: :stdio,
command: ["node", "some-mcp-server.js"]
)
# List available tools
{:ok, tools} = ExMCP.Client.list_tools(client)
# Call a tool
{:ok, result} = ExMCP.Client.call_tool(client, "hello", %{name: "World"})Core Concepts
The Model Context Protocol
MCP enables AI models to interact with external systems through:
- Tools: Functions that can be called with parameters
- Resources: Data sources that can be read
- Prompts: Templates for generating model interactions
- Sampling: Direct LLM integration for response generation
- Roots: URI-based boundaries for organizing resources
- Subscriptions: Monitor resources for changes
Client-Server Architecture
MCP follows a client-server model:
- Clients (typically AI assistants) connect to servers to access functionality
- Servers expose tools, resources, and prompts to clients
- Communication happens over various transport layers
Request-Response Pattern
All MCP interactions follow JSON-RPC 2.0:
// Request
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {"name": "hello", "arguments": {"name": "World"}},
"id": 1
}
// Response
{
"jsonrpc": "2.0",
"result": {"content": [{"type": "text", "text": "Hello, World!"}]},
"id": 1
}Building MCP Servers
ExMCP provides a declarative DSL for building servers. Combine use ExMCP.Server.Handler with use ExMCP.Server.DSL to declare capabilities next to their handlers.
Implementing Tools
Tools allow clients to execute functions. Declare them with tool and a colocated run handler:
defmodule FileSearchServer do
use ExMCP.Server.Handler
use ExMCP.Server.DSL
tool "file_search", "Search for files by pattern" do
param :pattern, :string, required: true, description: "Glob pattern"
param :path, :string, default: ".", description: "Search path"
annotations readOnlyHint: true, destructiveHint: false
run fn %{pattern: pattern, path: path}, state ->
files = Path.wildcard(Path.join(path, pattern))
{:ok, "Found #{length(files)} files:\n#{Enum.join(files, "\n")}", state}
end
end
endImplementing Resources
Resources provide data that clients can read. Declare with resource and a colocated read handler:
defmodule ConfigServer do
use ExMCP.Server.Handler
use ExMCP.Server.DSL
resource "file:///config.json", "Application configuration" do
title "Configuration"
mime_type "application/json"
read fn _params, state ->
{:ok, %{text: Jason.encode!(Jason.decode!(File.read!("config.json")))}, state}
end
end
resource "file:///status", "Current server status" do
title "Server Status"
mime_type "text/plain"
read fn _params, state ->
{:ok, "Server is running", state}
end
end
endImplementing Prompts
Prompts are templates for model interactions. Declare with prompt and a colocated render handler:
defmodule CodeReviewServer do
use ExMCP.Server.Handler
use ExMCP.Server.DSL
prompt "code_review", "Review code for best practices" do
title "Code Review"
arg :language, required: true, description: "Programming language"
arg :focus, description: "Area to focus on"
render fn %{language: lang} = args, state ->
focus = Map.get(args, :focus, "general best practices")
{:ok,
%{
messages: [
%{role: "system", content: %{type: "text", text: "You are an expert #{lang} code reviewer focusing on #{focus}."}},
%{role: "user", content: %{type: "text", text: "Please review the following #{lang} code:"}}
]
}, state}
end
end
endCombining Tools, Resources, and Prompts
A single server can declare any combination:
defmodule MyFullServer do
use ExMCP.Server.Handler
use ExMCP.Server.DSL
@impl true
def init(_args), do: {:ok, %{request_count: 0}}
tool "greet", "Greets a person" do
param :name, :string, required: true
run fn %{name: name}, state ->
new_state = %{state | request_count: state.request_count + 1}
{:ok, "Hello, #{name}!", new_state}
end
end
resource "app://stats", "Request statistics" do
title "Server Stats"
mime_type "application/json"
read fn _params, state ->
{:ok, %{text: Jason.encode!(%{requests: state.request_count})}, state}
end
end
prompt "summarize", "Summarize text content" do
title "Summarize"
arg :style, description: "Summary style"
render fn args, state ->
style = Map.get(args, :style, "brief")
{:ok, "Provide a #{style} summary of the content the user shares.", state}
end
end
end
# Start with any transport
{:ok, _} = MyFullServer.start_link(transport: :stdio)Content Responses
Tool handlers can return strings, %{text: text}, or full MCP result maps.
Resource handlers can return strings, %{text: text}, %{blob: blob}, or full
resource content maps. Prompt handlers can return strings, message lists, or
full %{messages: messages} maps.
Resource Subscriptions
Allow clients to monitor resource changes:
@impl true
def handle_resource_subscribe(uri, state) do
subscriptions = MapSet.put(state.subscriptions, uri)
{:ok, %{state | subscriptions: subscriptions}}
end
# When a resource changes, notify subscribers
ExMCP.Server.notify_resource_update(server, "file:///config.json")Progress Notifications
For long-running operations:
@impl true
def handle_call_tool("long_task", %{"_progressToken" => token}, state) do
Task.start(fn ->
for i <- 1..100 do
ExMCP.Server.notify_progress(self(), token, i, 100)
Process.sleep(100)
end
end)
{:ok, %{content: [%{type: "text", text: "Task started"}]}, state}
endServer-to-Client Requests
Servers can make requests back to clients:
# Ping the client
{:ok, _} = MyServer.ping(server)
# Request client's roots
{:ok, roots} = MyServer.list_roots(server)
# Ask client to sample an LLM
{:ok, response} = MyServer.create_message(server, %{
"messages" => [
%{"role" => "user", "content" => "What is the weather?"}
],
"modelPreferences" => %{
"hints" => ["gpt-4", "claude-3"],
"temperature" => 0.7
}
})Low-Level Handler API
For cases where you need full control without the DSL, implement the ExMCP.Server.Handler behaviour directly. This gives you manual control over capability negotiation and tool/resource listing:
defmodule MyLowLevelServer do
use ExMCP.Server.Handler
@impl true
def init(_args), do: {:ok, %{}}
@impl true
def handle_initialize(_params, state) do
{:ok, %{
name: "my-server",
version: "1.0.0",
capabilities: %{tools: %{}}
}, state}
end
@impl true
def handle_list_tools(_cursor, state) do
tools = [
%{
name: "echo",
description: "Echoes input back",
input_schema: %{
type: "object",
properties: %{
message: %{type: "string"}
},
required: ["message"]
}
}
]
{:ok, tools, nil, state}
end
@impl true
def handle_call_tool("echo", %{"message" => msg}, state) do
{:ok, [%{type: "text", text: msg}], state}
end
end
{:ok, _} =
ExMCP.Server.HandlerServer.start_link(
handler: MyLowLevelServer,
transport: :test
)The DSL approach (
use ExMCP.Server.Handlerplususe ExMCP.Server.DSL) is recommended for most use cases. Use hand-written handler callbacks when you need dynamic capability negotiation or non-standard request handling.
Using the Native Service Dispatcher
The Native Service Dispatcher provides ultra-fast communication for trusted Elixir services within the same cluster using the ExMCP.Service macro.
Service Implementation
Services are created using the ExMCP.Service macro:
defmodule MyToolService do
use ExMCP.Service, name: :my_tools
@impl true
def init(_args) do
{:ok, %{cache: %{}, stats: %{calls: 0}}}
end
@impl true
def handle_mcp_request("list_tools", _params, state) do
tools = [
%{
"name" => "calculator",
"description" => "Perform mathematical calculations",
"inputSchema" => %{
"type" => "object",
"properties" => %{
"expression" => %{"type" => "string", "description" => "Math expression"}
},
"required" => ["expression"]
}
}
]
{:ok, %{"tools" => tools}, state}
end
@impl true
def handle_mcp_request("tools/call", %{"name" => "calculator", "arguments" => args}, state) do
expression = args["expression"]
try do
# Safe expression evaluation (implement your own)
result = safe_eval(expression)
content = [%{"type" => "text", "text" => "Result: #{result}"}]
new_state = update_in(state.stats.calls, &(&1 + 1))
{:ok, %{"content" => content}, new_state}
rescue
error ->
{:error, %{"code" => -32603, "message" => "Calculation error: #{inspect(error)}"}, state}
end
end
def handle_mcp_request(method, _params, state) do
{:error, %{"code" => -32601, "message" => "Method not found: #{method}"}, state}
end
# Optional: Handle notifications (fire-and-forget)
@impl true
def handle_mcp_notification("clear_cache", _params, state) do
{:noreply, %{state | cache: %{}}}
end
# Optional: Custom lifecycle management
@impl true
def terminate(_reason, _state) do
# Cleanup resources
:ok
end
endService Registration and Discovery
Services automatically register when started and can be discovered across the cluster:
# Start your service (automatically registers with ExMCP.Native)
{:ok, pid} = MyToolService.start_link()
# Check if service is available
ExMCP.Native.service_available?(:my_tools)
#=> true
# List all services across the cluster
services = ExMCP.Native.list_services()
#=> [
#=> {:my_tools, #PID<0.123.0>, %{registered_at: ~U[2024-01-01 10:00:00Z]}},
#=> {:data_processor, #PID<0.124.0>, %{registered_at: ~U[2024-01-01 10:01:00Z]}}
#=> ]Direct Service Communication
Call services directly without MCP client overhead:
# Simple call
{:ok, tools} = ExMCP.Native.call(:my_tools, "list_tools", %{})
# Call with metadata and progress tracking
{:ok, result} = ExMCP.Native.call(
:my_tools,
"tools/call",
%{"name" => "calculator", "arguments" => %{"expression" => "2 + 2"}},
timeout: 10_000,
meta: %{"user_id" => "user123", "trace_id" => "abc"},
progress_token: "calc-001"
)
# Fire-and-forget notifications
:ok = ExMCP.Native.notify(:my_tools, "clear_cache", %{})Cross-Node Communication
Services work seamlessly across BEAM cluster nodes:
# Connect nodes (if not already connected)
Node.connect(:"worker@cluster.local")
# Call service on specific node
{:ok, result} = ExMCP.Native.call(
{:data_processor, :"worker@cluster.local"},
"tools/call",
%{"name" => "process_dataset", "arguments" => %{"dataset_id" => "abc123"}}
)
# The pluggable registry discovers services across nodes (requires Horde adapter)
available? = ExMCP.Native.service_available?(:data_processor)
#=> true (even if on remote node)Resilience Patterns
Add optional resilience for unreliable services:
# Retry with exponential backoff
{:ok, result} = ExMCP.Resilience.call_with_retry(
:flaky_service,
"process_data",
%{"input" => "data"},
max_attempts: 3,
backoff: :exponential,
initial_delay: 100
)
# Fallback for unavailable services
result = ExMCP.Resilience.call_with_fallback(
:primary_service,
"get_data",
%{},
fallback: fn ->
{:ok, %{"data" => "cached_value", "source" => "cache"}}
end
)
# Circuit breaker for failing services
{:ok, result} = ExMCP.Resilience.call_with_circuit_breaker(
:unstable_service,
"risky_operation",
%{},
failure_threshold: 5,
timeout: 60_000
)Performance Optimization
BEAM-local services are optimized for performance:
# Measure performance
:timer.tc(fn ->
ExMCP.Native.call(:my_service, "fast_operation", %{})
end)
#=> {15, {:ok, result}} # ~15 microseconds!
# Batch operations for efficiency
results = Enum.map(1..1000, fn i ->
ExMCP.Native.call(:calculator, "tools/call", %{
"name" => "add",
"arguments" => %{"a" => i, "b" => i * 2}
})
end)
# Completes in milliseconds due to zero serialization overheadAdvanced Service Patterns
Resource-like Services
Services can expose resource-like interfaces:
defmodule DatabaseService do
use ExMCP.Service, name: :database
@impl true
def handle_mcp_request("list_resources", _params, state) do
resources = [
%{
"uri" => "db://users/table",
"name" => "Users Table",
"description" => "User data",
"mimeType" => "application/json"
}
]
{:ok, %{"resources" => resources}, state}
end
@impl true
def handle_mcp_request("resources/read", %{"uri" => "db://users/table"}, state) do
users = Database.all_users()
content = %{
"uri" => "db://users/table",
"mimeType" => "application/json",
"text" => Jason.encode!(users)
}
{:ok, %{"contents" => [content]}, state}
end
endEvent-Driven Services
Services can publish and subscribe to events:
defmodule EventService do
use ExMCP.Service, name: :events
@impl true
def handle_mcp_request("resources/subscribe", %{"uri" => uri}, state) do
# Track subscription
subscriptions = MapSet.put(state.subscriptions, uri)
{:ok, %{}, %{state | subscriptions: subscriptions}}
end
# Publish events to subscribers
def publish_event(uri, event_data) do
ExMCP.Native.notify(:events, "resource_updated", %{
"uri" => uri,
"data" => event_data
})
end
endBuilding MCP Clients
Connecting to Servers
# Connect to a stdio server
{:ok, client} = ExMCP.Client.start_link(
transport: :stdio,
command: ["python", "mcp_server.py"],
args: ["--config", "prod.json"]
)
# Connect to a Streamable HTTP server
{:ok, client} = ExMCP.Client.start_link(
transport: :http,
url: "http://localhost:8080",
endpoint: "/mcp/v1" # Optional: specify custom endpoint (defaults to "/mcp/v1")
)Using Tools
# List available tools
{:ok, tools} = ExMCP.Client.list_tools(client)
Enum.each(tools, fn tool ->
IO.puts("Tool: #{tool.name} - #{tool.description}")
end)
# Call a tool
{:ok, result} = ExMCP.Client.call_tool(
client,
"search",
%{query: "elixir metaprogramming"},
# Optional progress callback
_progress_token = "search-123"
)
# Handle the result
Enum.each(result, fn
%{type: "text", text: text} ->
IO.puts(text)
%{type: "image", data: data, mimeType: mime} ->
# Handle image content
end)Working with Resources
# List resources
{:ok, resources} = ExMCP.Client.list_resources(client)
# Read a resource
{:ok, content} = ExMCP.Client.read_resource(client, "file:///data.json")
case content do
%{text: text} ->
IO.puts("Text content: #{text}")
%{blob: blob} ->
IO.puts("Binary content: #{byte_size(blob)} bytes")
endUsing Prompts
# List prompts
{:ok, prompts} = ExMCP.Client.list_prompts(client)
# Get a prompt
{:ok, messages} = ExMCP.Client.get_prompt(
client,
"analyze_code",
%{language: "elixir", complexity: "high"}
)
# Use with your LLM
response = MyLLM.chat(messages)Working with Roots
# List available roots
{:ok, roots} = ExMCP.Client.list_roots(client)
Enum.each(roots, fn root ->
IO.puts("Root: #{root.name} at #{root.uri}")
end)Resource Subscriptions
Note: The MCP specification only defines
resources/subscribe. Theunsubscribe_resourcefunction is an ExMCP extension.
# Subscribe to resource changes (MCP standard)
{:ok, _} = ExMCP.Client.subscribe_resource(client, "file:///config.json")
# Handle notifications in your client process
def handle_info({:mcp_notification, "resource/updated", %{"uri" => uri}}, state) do
IO.puts("Resource updated: #{uri}")
# Re-read the resource
{:ok, content} = ExMCP.Client.read_resource(state.client, uri)
# Process updated content...
{:noreply, state}
end
# Unsubscribe when done (ExMCP extension - not in MCP spec)
{:ok, _} = ExMCP.Client.unsubscribe_resource(client, "file:///config.json")Using Sampling
# For servers that support LLM sampling
{:ok, response} = ExMCP.Client.create_message(
client,
%{
messages: [
%{role: "user", content: %{type: "text", text: "Explain quantum computing"}}
],
max_tokens: 500,
temperature: 0.7
}
)
IO.puts("Assistant: #{response.content.text}")Batch Requests
Send multiple requests in a single call for better performance:
# Define multiple requests
requests = [
{:list_tools, []},
{:list_resources, []},
{:read_resource, ["file:///config.json"]},
{:call_tool, ["get_status", %{}]}
]
# Send as batch
{:ok, [tools, resources, config, status]} = ExMCP.Client.batch_request(client, requests)
# Process results
IO.puts("Found #{length(tools)} tools")
IO.puts("Found #{length(resources)} resources")Bi-directional Communication
Enable servers to make requests back to clients:
defmodule MyClientHandler do
@behaviour ExMCP.Client.Handler
@impl true
def init(args) do
{:ok, %{model: args[:model] || "gpt-4", roots: args[:roots]}}
end
@impl true
def handle_ping(state) do
{:ok, %{}, state}
end
@impl true
def handle_list_roots(state) do
{:ok, state.roots, state}
end
@impl true
def handle_create_message(params, state) do
# Server is asking client to sample an LLM
messages = params["messages"]
# Call your LLM integration
response = MyLLM.chat(messages, model: state.model)
result = %{
"role" => "assistant",
"content" => %{
"type" => "text",
"text" => response.content
},
"model" => state.model
}
{:ok, result, state}
end
end
# Start client with handler
{:ok, client} = ExMCP.Client.start_link(
transport: :stdio,
command: ["mcp-server"],
handler: MyClientHandler,
handler_state: %{
model: "claude-3",
roots: [%{uri: "file:///home", name: "Home"}]
}
)Human-in-the-Loop Approval
Implement approval flows for sensitive operations:
# Option 1: Use the built-in console approval handler
{:ok, client} = ExMCP.Client.start_link(
transport: :stdio,
command: ["mcp-server"],
handler: {ExMCP.Client.DefaultHandler, [
approval_handler: ExMCP.Approval.Console,
roots: [%{uri: "file:///data", name: "Data"}]
]}
)
# Option 2: Implement a custom approval handler
defmodule MyApprovalHandler do
@behaviour ExMCP.Approval
@impl true
def request_approval(type, data, opts) do
case type do
:sampling ->
# Show approval UI for LLM sampling
if show_sampling_dialog(data) == :approved do
{:approved, data}
else
{:denied, "User cancelled"}
end
:response ->
# Review LLM response before sending
reviewed_response = show_response_review(data)
if reviewed_response do
{:modified, reviewed_response}
else
{:denied, "Response rejected"}
end
:tool_call ->
# Approve dangerous tool calls
tool_name = data["name"]
if dangerous_tool?(tool_name) do
case show_tool_approval(tool_name, data["arguments"]) do
:approve -> {:approved, data}
:deny -> {:denied, "Tool call blocked"}
{:modify, new_args} -> {:modified, %{data | "arguments" => new_args}}
end
else
{:approved, data}
end
end
end
defp dangerous_tool?(name) do
name in ["delete_file", "execute_command", "send_email"]
end
end
# Use custom approval handler with default client handler
{:ok, client} = ExMCP.Client.start_link(
transport: :stdio,
command: ["mcp-server"],
handler: {ExMCP.Client.DefaultHandler, [
approval_handler: MyApprovalHandler
]}
)Transport Layers
ExMCP supports multiple transport layers. For detailed configuration and optimization information, see the Transport Guide.
stdio Transport
Best for local process communication:
# Server
{:ok, server} = MyServer.start_link(transport: :stdio)
# Client
{:ok, client} = ExMCP.Client.start_link(
transport: :stdio,
command: ["./my_mcp_server"]
)Streamable HTTP Transport
For HTTP-based communication with optional Server-Sent Events (SSE) streaming.
Note: Use
transport: :httpfor the HTTP transport with Server-Sent Events support. The:ssetransport name has been removed.
# Server
{:ok, server} = MyServer.start_link(
transport: :http,
port: 8080,
path: "/mcp"
)
# Client
{:ok, client} = ExMCP.Client.start_link(
transport: :http,
url: "http://localhost:8080",
endpoint: "/mcp/v1", # Optional: defaults to "/mcp/v1"
headers: [{"Authorization", "Bearer token"}]
)
# Client with custom endpoint
{:ok, client} = ExMCP.Client.start_link(
transport: :http,
url: "https://api.example.com",
endpoint: "/ai/mcp", # Custom endpoint path
headers: [{"Authorization", "Bearer token"}]
)Custom Transports
You can implement custom transports by implementing the ExMCP.Transport behaviour. See the API Reference for details.
Advanced Features
Auto-Reconnection
ExMCP clients automatically handle reconnection on failure:
# Client automatically reconnects on failure
{:ok, client} = ExMCP.Client.start_link(
transport: :stdio,
command: ["flaky-server"],
# Reconnection options
reconnect: true,
reconnect_interval: 1000,
max_reconnect_attempts: 10
)
# Handle errors gracefully
case ExMCP.Client.call_tool(client, "risky_tool", %{}) do
{:ok, result} ->
process_result(result)
{:error, %{"code" => -32603, "message" => msg}} ->
Logger.error("Internal error: #{msg}")
{:error, reason} ->
Logger.error("Tool failed: #{inspect(reason)}")
endCustom Transports
Implement the ExMCP.Transport behaviour:
defmodule MyTransport do
@behaviour ExMCP.Transport
@impl true
def connect(opts) do
# Establish connection
{:ok, state}
end
@impl true
def send_message(message, state) do
# Send message
{:ok, new_state}
end
@impl true
def receive_message(state) do
# Receive message (blocking)
{:ok, message, new_state}
end
@impl true
def close(state) do
# Clean up
:ok
end
end
# Use custom transport
{:ok, client} = ExMCP.Client.start_link(
transport: MyTransport,
custom_option: "value"
)Best Practices
1. State Management
Keep server state immutable and use proper OTP patterns:
defmodule MyServer do
use ExMCP.Server.Handler
defstruct [:db_conn, :cache, subscriptions: MapSet.new()]
@impl true
def init(args) do
{:ok, db_conn} = Database.connect(args[:db_url])
{:ok, %__MODULE__{db_conn: db_conn, cache: %{}}}
end
end2. Error Handling
Always return proper error tuples:
@impl true
def handle_call_tool("database_query", %{"sql" => sql}, state) do
case Database.query(state.db_conn, sql) do
{:ok, results} ->
{:ok, %{structuredContent: %{results: results}, content: []}, state}
{:error, :invalid_sql} ->
{:error, "Invalid SQL syntax", state}
{:error, reason} ->
{:error, "Database error: #{inspect(reason)}", state}
end
end3. Tool Design
Make tools focused and composable:
# Good: Focused tools
tool "list_files", "List files in directory" do
param :path, :string
run fn %{path: path}, state -> {:ok, list_files(path), state} end
end
tool "read_file", "Read file contents" do
param :path, :string, required: true
run fn %{path: path}, state -> {:ok, File.read!(path), state} end
end
# Bad: One monolithic tool that does everything4. Resource URIs
Use consistent URI schemes:
# Good: Clear URI structure
resources = [
%{uri: "file:///data/users.json"},
%{uri: "db://postgres/users/table"},
%{uri: "api://v1/users"}
]
# Bad: Inconsistent URIs
resources = [
%{uri: "/data/users.json"},
%{uri: "postgres:users"},
%{uri: "users-api"}
]5. Capability Declaration
With the DSL, capabilities are auto-detected from your declarations. If you define tool blocks, the server automatically advertises tool support. If you define resource blocks, resource support is advertised. No manual capability wiring needed.
defmodule MyServer do
use ExMCP.Server.Handler
use ExMCP.Server.DSL
# Capabilities auto-detected: tools + resources
tool "search", "Search for items" do
param :query, :string
run fn %{query: query}, state -> {:ok, search(query), state} end
end
resource "app://data", "Application data" do
title "Data"
mime_type "application/json"
read fn _params, state -> {:ok, load_data(), state} end
end
endTroubleshooting
Common Issues
Connection Failures
# Check server is running
{:error, :enoent} = ExMCP.Client.start_link(
transport: :stdio,
command: ["non-existent-server"]
)
# Solution: Verify command path
{:ok, client} = ExMCP.Client.start_link(
transport: :stdio,
command: ["/usr/local/bin/mcp-server"]
)Protocol Errors
# Wrong protocol version
{:error, %{"code" => -32600}} = ExMCP.Client.list_tools(client)
# Solution: ExMCP uses protocol version 2025-03-26
# Ensure your server supports this versionTransport Issues
# stdio: Process dies immediately
# Check server stderr
{:ok, client} = ExMCP.Client.start_link(
transport: :stdio,
command: ["server", "--verbose"],
env: [{"DEBUG", "true"}]
)
# HTTP streaming: Connection refused
# Ensure server is listening
{:ok, client} = ExMCP.Client.start_link(
transport: :http,
url: "http://localhost:8080",
endpoint: "/mcp/v1" # Ensure endpoint matches server configuration
)
# Native Service Dispatcher: Service not found
# Check if service is registered
ExMCP.Native.service_available?(:my_service)
# List all services
ExMCP.Native.list_services()
# Cross-node communication
# Ensure nodes are connected
Node.connect(:"worker@cluster.local")
# If using Horde adapter, check cluster members
# Horde.Cluster.members(ExMCP.ServiceRegistry.Horde.Registry)Debugging
Enable debug logging:
# In config/config.exs
config :logger, level: :debug
# Or at runtime
Logger.configure(level: :debug)Trace protocol messages:
# Start a debugging proxy
defmodule DebugTransport do
def send_message(message, state) do
IO.puts(">>> #{message}")
ActualTransport.send_message(message, state)
end
def receive_message(state) do
case ActualTransport.receive_message(state) do
{:ok, message, new_state} ->
IO.puts("<<< #{message}")
{:ok, message, new_state}
other ->
other
end
end
endPerformance Tuning
# Increase timeout for slow operations
{:ok, result} = ExMCP.Client.call_tool(
client,
"expensive_operation",
%{size: "large"},
60_000 # 60 second timeout
)
# Use connection pooling for multiple clients
defmodule MCPPool do
use GenServer
def start_link(server_config, pool_size: size) do
# Create pool of clients
end
def checkout(pool) do
# Get available client
end
endNext Steps
- Explore the examples directory for complete working examples
- Read the API documentation
- Check out the MCP specification
- Join the community and contribute on GitHub
Support
For questions and issues:
- GitHub Issues: Report bugs and feature requests
- Discussions: Ask questions and share ideas
- Stack Overflow: Tag questions with
ex-mcpandelixir
Remember to include:
- ExMCP version
- Elixir/OTP versions
- Transport being used
- Minimal reproduction code
- Error messages and stack traces