McpServer

Hex.pmHexdocs.pm

McpServer is an Elixir library that builds a DSL for defining Model Context Protocol (MCP) tools, prompts, and routers in Elixir. It allows you to easily expose tool endpoints with input/output schemas and validation, as well as define interactive prompts with argument completion.

Key Features

Installation and setup

  1. Add dependencies to your mix.exs:
def deps do
[
{:mcp_server, "~> 0.10.0"},
{:bandit, "~> 1.0"} # HTTP server
]
end
  1. Define your MCP Router:

Create a module that uses McpServer.Router and defines your tools and prompts. Example:

defmodule MyApp.MyController do
import McpServer.Controller, only: [message: 3, completion: 2, content: 3]
alias McpServer.Tool.Content, as: ToolContent
alias McpServer.Tool.CallResult
# Tool functions - all receive conn as first parameter
# Return {:ok, CallResult.new(content: [...])} or {:error, reason}
def echo(_conn, args) do
{:ok, CallResult.new(content: [ToolContent.text(Map.get(args, "message", "default"))])}
end
def greet(conn, args) do
name = Map.get(args, "name", "World")
{:ok, CallResult.new(content: [ToolContent.text("Hello, #{name}, you are connected with session #{conn.session_id}!")])}
end
def calculate(_conn, args) do
result = Map.get(args, "a", 0) + Map.get(args, "b", 0)
{:ok, CallResult.new(content: [ToolContent.text("#{result}")])}
end
# Prompt functions - all receive conn as first parameter
def get_greet_prompt(_conn, %{"user_name" => user_name}) do
[
message("user", "text", "Hello #{user_name}! Welcome to our MCP server. How can I assist you today?"),
message("assistant", "text", "I'm here to help you with any questions or tasks you might have.")
]
end
def complete_greet_prompt(_conn, "user_name", user_name_prefix) do
names = ["Alice", "Bob", "Charlie", "David"]
filtered_names = Enum.filter(names, &String.starts_with?(&1, user_name_prefix))
completion(filtered_names, total: 100, has_more: true)
end
# Resource reader example - receives conn as first parameter, returns ReadResult struct
def read_user(_conn, %{"id" => id}) do
McpServer.Resource.ReadResult.new(
contents: [
content(
"User #{id}",
"https://example.com/users/#{id}",
mimeType: "application/json",
text: "{\"id\": \"#{id}\", \"name\": \"User #{id}\"}",
title: "User title #{id}"
)
]
)
end
end
defmodule MyApp.Router do
use McpServer.Router
# Define tools
tool "greet", "Greets a person", MyApp.MyController, :greet do
input_field("name", "The name to greet", :string, required: false)
output_field("greeting", "The greeting message", :string)
end
tool "calculate", "Adds two numbers", MyApp.MyController, :calculate do
input_field("a", "First number", :integer, required: true)
input_field("b", "Second number", :integer, required: true)
output_field("result", "The sum of the numbers", :integer)
end
tool "echo", "Echoes back the input", MyApp.MyController, :echo,
title: "Echo",
hints: [:read_only, :non_destructive, :idempotent, :closed_world] do
icon "https://example.com/echo-icon.svg", mime_type: "image/svg+xml", sizes: ["48x48"]
input_field("message", "The message to echo", :string, required: true)
output_field("response", "The echoed message", :string)
end
# Define prompts
prompt "greet", "A friendly greeting prompt that welcomes users" do
icon "https://example.com/greet-icon.png"
argument("user_name", "The name of the user to greet", required: true)
get MyApp.MyController, :get_greet_prompt
complete MyApp.MyController, :complete_greet_prompt
end
# Define resources
resource "user", "https://example.com/users/{id}" do
icon "https://example.com/user-icon.png", mime_type: "image/png"
description "User resource"
mimeType "application/json"
title "User title"
read MyApp.MyController, :read_user
complete MyApp.MyController, :complete_user
end
end
  1. Start the Bandit server with your router:

Add to your application supervision tree:

Make sure to respect the recommended security options for MCP servers

children = [
{Bandit, plug: {
McpServer.HttpPlug,
router: MyApp.Router,
server_info: %{name: "MyApp MCP Server", version: "1.0.0"}
}, port: 4000, ip: {127, 0, 0, 1}}
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)

Your MCP server will now be running and serving your defined tools and prompts.

Tools

Tools are functions that can be called by the MCP client. They support input validation and output schemas.

Tool Definition

tool "tool_name", "Description", ControllerModule, :function_name do
input_field("param", "Parameter description", :type, required: true)
output_field("result", "Result description", :type)
end

Controller Implementation

Tool controller functions receive conn and args, and must return {:ok, CallResult.new(...)} or {:error, reason}:

alias McpServer.Tool.Content, as: ToolContent
alias McpServer.Tool.CallResult
# Return text content
def my_tool(_conn, %{"query" => query}) do
{:ok, CallResult.new(content: [ToolContent.text("Results for: #{query}")])}
end
# Return multiple content types
def generate_chart(_conn, %{"data" => data}) do
chart_image = create_chart(data)
{:ok, CallResult.new(content: [
ToolContent.text("Chart generated successfully"),
ToolContent.image(chart_image, "image/png")
])}
end
# Return audio content
def synthesize_speech(_conn, %{"text" => text}) do
audio_data = text_to_speech(text)
{:ok, CallResult.new(content: [
ToolContent.text("Speech synthesized"),
ToolContent.audio(audio_data, "audio/wav")
])}
end
# Return structured content for UI rendering
def get_weather(_conn, %{"location" => location}) do
weather = fetch_weather(location)
{:ok, CallResult.new(
content: [ToolContent.text("Weather in #{location}: #{weather.temp}°F")],
structured_content: %{
"temperature" => weather.temp,
"humidity" => weather.humidity
}
)}
end
# Signal an error
def risky_tool(_conn, %{"input" => input}) do
case process(input) do
{:ok, result} -> {:ok, CallResult.new(content: [ToolContent.text(result)])}
{:error, reason} -> {:error, reason}
end
end

Prompts

Prompts are interactive message templates with argument completion support. They're useful for generating structured conversations.

Prompt Definition

prompt "prompt_name", "Description" do
argument("arg_name", "Argument description", required: true)
get ControllerModule, :get_function
complete ControllerModule, :complete_function
end

Controller Implementation

Prompt controllers need two functions:

  1. Get function - Receives conn and arguments, returns a list of messages:
def get_prompt_name(conn, %{"arg_name" => value}) do
# Access session info via conn.session_id or conn.private
[
message("user", "text", "User message with #{value}"),
message("assistant", "text", "Assistant response"),
message("system", "text", "System instructions")
]
end
  1. Complete function - Receives conn, argument name, and prefix, returns completion suggestions:
def complete_prompt_name(conn, "arg_name", prefix) do
# Access session info via conn.session_id or conn.private
suggestions = ["option1", "option2", "option3"]
filtered = Enum.filter(suggestions, &String.starts_with?(&1, prefix))
completion(filtered, total: 100, has_more: true)
end

Helper Functions

The McpServer.Prompt module provides utility functions:

Structures Reference

The library provides typed structs for all MCP message types and data structures. All structs implement Jason.Encoder for JSON serialization.

See STRUCTURES.md for a complete reference of all available structs and their fields.

MCP Apps (Interactive UIs)

McpServer supports the MCP Apps extension (io.modelcontextprotocol/ui) for delivering interactive UIs alongside AI conversations. This includes:

See MCP_APPS.md for the complete guide.

Usage & Testing

Testing Tools

defmodule MyApp.RouterTest do
use ExUnit.Case
setup do
%{conn: %McpServer.Conn{session_id: "test-session"}}
end
test "call a tool", %{conn: conn} do
{:ok, result} = MyApp.Router.call_tool(conn, "echo", %{"message" => "Hello World"})
assert %McpServer.Tool.CallResult{content: [content]} = result
assert %McpServer.Tool.Content.Text{text: "Hello World"} = content
end
test "list tools", %{conn: conn} do
{:ok, tools} = MyApp.Router.list_tools(conn)
echo = Enum.find(tools, &(&1.name == "echo"))
assert echo.description == "Echoes back the input"
end
end

Testing Prompts

defmodule MyApp.PromptTest do
use ExUnit.Case
setup do
%{conn: %McpServer.Conn{session_id: "test-session"}}
end
test "get prompt messages", %{conn: conn} do
{:ok, messages} = MyApp.Router.get_prompt(conn, "greet", %{"user_name" => "Alice"})
assert hd(messages).role == "user"
assert hd(messages).content.text =~ "Hello Alice"
end
test "complete prompt arguments", %{conn: conn} do
{:ok, completion} = MyApp.Router.complete_prompt(conn, "greet", "user_name", "A")
assert "Alice" in completion.values
assert completion.has_more == true
end
test "list prompts", %{conn: conn} do
{:ok, prompts} = MyApp.Router.prompts_list(conn)
greet = Enum.find(prompts, &(&1.name == "greet"))
assert greet.description == "A friendly greeting prompt that welcomes users"
end
end