McpServer

View Source

Hex.pm Hexdocs.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

  • Type-Safe Structs: All MCP protocol structures are now typed Elixir structs with compile-time validation
  • Connection Context: All controller functions receive a conn parameter as their first argument, providing access to session information, user data, and other connection-specific context through conn.session_id and conn.private
  • Validated Tools: Define tools with automatic input validation and output schemas
  • Interactive Prompts: Create prompts with argument completion support
  • Resource Management: Define and serve resources with URI templates
  • Automatic JSON Encoding: All structs automatically encode to proper MCP JSON format

Installation and setup

  1. Add dependencies to your mix.exs:
def deps do
  [
    {:mcp_server, "~> 0.8.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
    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
    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
    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 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:

  • message(role, type, content) - Creates message structures
  • completion(values, opts) - Creates completion responses

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:

  • UI Tools — Link tools to UI resources with ui and visibility options
  • UI Resources — Serve HTML content via ui:// URIs with CSP and sandbox permissions
  • Structured Content — Return rich data for UI rendering via McpServer.Tool.CallResult

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