Building a Server
Copy MarkdownWhat if your Elixir application could become a capability that AI assistants can discover and use? Let's explore how to expose your application's features through MCP.
Your First Tool
Remember our greeter from the introduction? Let's understand what's really happening:
defmodule MyApp.Greeter do
@moduledoc "Greet someone warmly"
use Backplane.McpProtocol.Server.Component, type: :tool
alias Backplane.McpProtocol.Server.Response
schema do
field :name, :string, required: true
end
def execute(%{name: name}, frame) do
{:reply, Response.text(Response.tool(), "Hello #{name}! Welcome to the MCP world!"), frame}
end
endWhat makes this special? When an AI assistant connects to your server, it can:
- Discover this tool exists
- Understand what parameters it needs
- Call it with the right data
- Get a response back
The schema block defines what the tool expects. The execute function does the work. That's it.
Creating Your Server
Now let's build a server that exposes this tool:
defmodule MyApp.Server do
use Backplane.McpProtocol.Server,
name: "my-app",
version: "1.0.0",
capabilities: [:tools]
# Register our greeter tool
component MyApp.Greeter
endAdd it to your supervision tree:
children = [
# Start with STDIO for easy testing
{MyApp.Server, transport: :stdio}
]How do you test this? Complete one file for reference:
Mix.install([{:backplane_mcp_protocol, "~> 0.6.0"}])
defmodule MyApp.Greeter do
@moduledoc "Greet someone warmly"
use Backplane.McpProtocol.Server.Component, type: :tool
alias Backplane.McpProtocol.Server.Response
schema do
field :name, :string, required: true
end
def execute(%{name: name}, frame) do
{:reply, Response.text(Response.tool(), "Hello #{name}! Welcome to the MCP world!"), frame}
end
end
defmodule MyApp.Server do
use Backplane.McpProtocol.Server,
name: "my-app",
version: "1.0.0",
capabilities: [:tools]
# Register our greeter tool
component MyApp.Greeter
end
children = [{MyApp.Server, transport: :stdio}]
{:ok, _pid} = Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)Save it to my_app.exs and you can test it with the helper task:
mix backplane.mcp_protocol.stdio.interactive -c elixir --args=--no-halt,my_app.exs
Or you can add it to claude, assuming you have claude-code installed:
claude mcp add my-app -- elixir --no-halt my_app.exs
Building Real Tools
Let's create something more substantial. What if we built a tool that searches through your application's data?
defmodule MyApp.ProductSearch do
@moduledoc "Search for products in our catalog"
use Backplane.McpProtocol.Server.Component, type: :tool
alias Backplane.McpProtocol.Server.Response
schema do
field :query, :string, required: true
field :limit, :integer, default: 10
field :category, :string
end
@impl true
def execute(%{query: query} = params, frame) do
limit = params[:limit] || 10
category = params[:category]
products =
MyApp.Catalog.search(query)
|> maybe_filter_by_category(category)
|> Enum.take(limit)
|> Enum.map(&format_product/1)
{:reply, Response.json(Response.tool(), products), frame}
end
defp maybe_filter_by_category(products, nil), do: products
defp maybe_filter_by_category(products, category) do
Enum.filter(products, &(&1.category == category))
end
defp format_product(product) do
%{
id: product.id,
name: product.name,
price: product.price,
description: product.description
}
end
endNotice how we're using your existing business logic? The tool is just a thin wrapper that makes it accessible to AI.
Adding Resources
Tools perform actions. Resources provide data. What if AI assistants could read your application's data directly?
defmodule MyApp.ConfigResource do
@moduledoc "Current application configuration"
use Backplane.McpProtocol.Server.Component,
type: :resource,
uri: "config://app/settings"
alias Backplane.McpProtocol.Server.Response
@impl true
def read(_params, frame) do
config = %{
environment: Application.get_env(:my_app, :environment),
features: Application.get_env(:my_app, :feature_flags),
version: Application.spec(:my_app, :vsn) |> to_string()
}
{:reply, Response.json(Response.resource(), config), frame}
end
endResources have URIs. AI assistants can discover and read them:
Client: list_resources()
Server: [{uri: "config://app/settings", name: "Application Config", ...}]
Client: read_resource("config://app/settings")
Server: {contents: [{text: "{\n \"environment\": \"production\",\n ..."}]}Creating Prompts
Prompts are templates that help AI assistants interact with your users more effectively:
defmodule MyApp.BugReportPrompt do
@moduledoc "Generate a structured bug report"
use Backplane.McpProtocol.Server.Component, type: :prompt
alias Backplane.McpProtocol.Server.Response
schema do
field :title, :string, required: true
field :severity, :enum, values: ["low", "medium", "high", "critical"]
field :steps_to_reproduce, :string
field :expected_behavior, :string
field :actual_behavior, :string
end
@impl true
def get_messages(params, frame) do
content = build_report_content(params)
response =
Response.prompt()
|> Response.user_message(content)
|> Response.system_message("This is the Bug report prompt, user already have the data")
{:reply, response, frame}
end
defp build_report_content(params) do
"""
Please help me file a bug report for: #{params.title}
Severity: #{params.severity || "not specified"}
Steps to reproduce:
#{params.steps_to_reproduce || "not provided"}
Expected behavior:
#{params.expected_behavior || "not provided"}
Actual behavior:
#{params.actual_behavior || "not provided"}
Please format this as a proper bug report and suggest any missing information.
"""
end
endComponent Descriptions
How do AI assistants know what your components do? They read descriptions. Backplane.McpProtocol makes this simple.
Using @moduledoc
By default, all components use their @moduledoc as the description. This works for tools, resources, and prompts:
defmodule MyApp.Calculator do
@moduledoc "Perform basic arithmetic operations on two numbers"
use Backplane.McpProtocol.Server.Component, type: :tool
schema do
field :operation, :enum, required: true, values: ["add", "subtract", "multiply", "divide"]
field :a, :float, required: true
field :b, :float, required: true
end
@impl true
def execute(params, frame) do
# Implementation...
end
endWhen listed, this tool appears as:
- calculator: Perform basic arithmetic operations on two numbersThe same pattern works for resources:
defmodule MyApp.LogsResource do
@moduledoc "Application logs from the last 24 hours"
use Backplane.McpProtocol.Server.Component,
type: :resource,
uri: "logs://app/recent"
@impl true
def read(_params, frame) do
# Implementation...
end
endAnd for prompts:
defmodule MyApp.CodeReviewPrompt do
@moduledoc "Generate a thorough code review focusing on best practices"
use Backplane.McpProtocol.Server.Component, type: :prompt
schema do
field :language, :string, required: true
field :code, :string, required: true
end
@impl true
def get_messages(params, frame) do
# Implementation...
end
endDynamic Descriptions with description/0
What if your description needs to be dynamic? Maybe it includes runtime configuration or current state? Implement a description/0 function:
defmodule MyApp.WeatherTool do
@moduledoc "Weather information tool"
use Backplane.McpProtocol.Server.Component, type: :tool
schema do
field :city, :string, required: true
end
@impl true
def description do
interval = Application.get_env(:my_app, :weather_cache_minutes, 15)
"Get current weather for any city (data updated every #{interval} minutes)"
end
@impl true
def execute(params, frame) do
# Implementation...
end
endResources can use dynamic descriptions too:
defmodule MyApp.MetricsResource do
@moduledoc "System metrics resource"
use Backplane.McpProtocol.Server.Component,
type: :resource,
uri: "metrics://system"
@impl true
def description do
{uptime_ms, _} = :erlang.statistics(:wall_clock)
uptime_seconds = div(uptime_ms, 1000)
"Real-time system metrics (uptime: #{uptime_seconds}s)"
end
@impl true
def read(_params, frame) do
# Implementation...
end
endAnd prompts can too:
defmodule MyApp.AnalysisPrompt do
@moduledoc "Data analysis prompt"
use Backplane.McpProtocol.Server.Component, type: :prompt
schema do
field :dataset, :string, required: true
end
@impl true
def description do
model = Application.get_env(:my_app, :analysis_model, "default")
"Analyze datasets using #{model} model"
end
@impl true
def get_messages(params, frame) do
# Implementation...
end
endWhat makes a good description? Think about what an AI assistant needs to know:
- What does this component do?
- When should it be used?
- What are its key capabilities or constraints?
Keep descriptions clear and concise. The better your descriptions, the better AI assistants can help your users.
Transport Options
How do clients connect to your server? Let's explore your options:
STDIO (Development & CLIs)
Perfect for CLI tools and development:
{MyApp.Server, transport: :stdio}Your server communicates through standard input/output. Great for:
- Command-line tools
- Development and testing
- Subprocess isolation
HTTP (Web Applications)
For web services that multiple clients connect to:
{MyApp.Server, transport: {:streamable_http, port: 8080}}This creates an HTTP endpoint at http://localhost:8080/mcp. Modern
2026-07-28 POSTs are stateless and do not create MCP sessions. Explicitly
legacy clients keep the existing session behavior.
Integration with Phoenix
Already have a Phoenix app? Integrate MCP as a route:
# In your Phoenix endpoint (lib/my_app_web/endpoint.ex)
defmodule MyAppWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
# Add the MCP plug before your router
plug Backplane.McpProtocol.Server.Transport.StreamableHTTP.Plug,
server: MyApp.Server,
path: "/mcp"
# Your other plugs...
plug MyAppWeb.Router
end
# In your application supervisor
children = [
MyAppWeb.Endpoint,
{MyApp.Server, transport: :streamable_http}
]Now your MCP server is available at http://localhost:4000/mcp.
Modern Request Lifecycle
The same server module accepts both protocol eras. Modern Streamable HTTP is
POST-only: it does not issue Mcp-Session-Id, open a GET notification stream,
replay events, or use DELETE cleanup. Modern stdio also executes each request
with a fresh frame. Legacy initialize and session transports continue to use
init/2 and Backplane.McpProtocol.Server.Session.
Use the optional init_request/2 callback for request-local setup. Its frame is
discarded after the modern request completes:
@impl true
def init_request(request_context, frame) do
frame = Backplane.McpProtocol.Server.Frame.assign(frame, :tenant, tenant_for(request_context.auth))
{:ok, frame}
endThe request context contains the validated protocol version, client identity
and capabilities, request _meta, normalized HTTP headers, authorization
claims, log level, and MRTR inputs. server/discover is handled by the package
and reports supported versions, capabilities, server identity, instructions,
and conservative cache hints.
For HTTP, every modern request must agree across its body and
MCP-Protocol-Version, Mcp-Method, Mcp-Name, and declared Mcp-Param-*
mirrors. Header mismatches are rejected before application dispatch.
Error Handling
What happens when things go wrong? Let's handle errors gracefully:
defmodule MyApp.DatabaseQuery do
@moduledoc "Query the database"
use Backplane.McpProtocol.Server.Component, type: :tool
alias Backplane.McpProtocol.Server.Response
schema do
field :query, :string, required: true
end
@impl true
def execute(%{query: query}, frame) do
case MyApp.Repo.query(query) do
{:ok, result} ->
{:reply, Response.json(Response.tool(), format_result(result)), frame}
{:error, reason} ->
{:reply, Response.error(Response.tool(), "Query failed: #{to_string(reason)}"), frame}
end
end
endBackplane.McpProtocol automatically formats your error responses according to the MCP protocol.
Stateful Operations
Need to maintain state across calls? The frame provides context:
The following session-ID pattern is for explicitly legacy peers. Modern frames
have context.session_id == nil; keep durable modern state in an external store
keyed by authenticated application identity or by your own request data.
defmodule MyApp.Conversation do
@moduledoc "Continue a conversation"
use Backplane.McpProtocol.Server.Component, type: :tool
alias Backplane.McpProtocol.Server.Response
schema do
field :message, :string, required: true
end
@impl true
def execute(%{message: message}, frame) do
session_id = frame.context.session_id
history = ConversationStore.get_history(session_id)
new_history = history ++ [message]
ConversationStore.save_history(session_id, new_history)
content = generate_response(new_history)
{:reply, Response.text(Response.tool(), content), frame}
end
endTool Annotations
Need to add extra metadata to your tools? Annotations provide additional context:
defmodule MyApp.DatabaseQuery do
@moduledoc "Query the application database"
use Backplane.McpProtocol.Server.Component,
type: :tool,
annotations: %{
"x-api-version" => "2.0",
"x-rate-limit" => "10/minute",
"x-auth-required" => true
}
schema do
field :query, :string, required: true
end
@impl true
def execute(params, frame) do
# Implementation...
end
endThese annotations are exposed in the tool definition, helping clients understand additional constraints or requirements.
For modern HTTP argument projection, use x-mcp-header in a raw JSON Schema.
Only primitive, statically reachable values are mirrored. Unsafe strings use
the protocol's base64 sentinel encoding. The server validates the decoded
header against the body value before invoking the component, preventing raw
header injection.
Modern Results and MRTR
Modern successful results are normalized with resultType: "complete".
Discovery and catalog/read results also include conservative ttlMs and
cacheScope hints unless the server overrides them.
Only tools/call, prompts/get, and resources/read may return
resultType: "input_required". Include inputRequests and an opaque
requestState; the client performs the requested roots, sampling, or
elicitation work and retries the original method with inputResponses and the
same state. Authenticate or seal any requestState that influences behavior,
and do not log it.
Modern Subscriptions
Modern clients open subscriptions/listen as a long-lived request. HTTP owns
one POST/SSE stream per subscription; stdio multiplexes the same notifications
through its connection writer. Publish JSON-RPC notifications through the
server's supervised subscription hub:
alias Backplane.McpProtocol.Server.{Modern.Subscriptions, Registry}
hub = Registry.subscriptions_name(MyApp.Server)
:ok =
Subscriptions.publish(hub, %{
"jsonrpc" => "2.0",
"method" => "notifications/tools/list_changed",
"params" => %{}
})Only matching subscribers receive the notification. Closing the request or connection cancels its owned subscription; modern traffic does not use the legacy GET stream, replay IDs, or session deletion.
Schemas, Structured Content, and Tasks
Modern wire schemas are preserved as arbitrary JSON Schema 2020-12 maps,
including $defs, composition, references, and unknown keywords. Peri remains
the local validator for its supported subset; external network $ref
resolution is disabled by default. structuredContent may contain any JSON
value, including arrays, primitives, booleans, and explicit null.
The optional modern io.modelcontextprotocol/tasks extension is deferred.
Existing Tasks support remains available for legacy 2025-11-25 sessions.
Testing Your Server
How do you know your server works correctly? Let's explore interactive testing first:
Interactive CLI Testing
Backplane.McpProtocol provides interactive Mix tasks for different transports if you need quick testing:
# Test STDIO server
mix backplane.mcp_protocol.stdio.interactive --command elixir --args=--no-halt,my_app.exs
# Test HTTP server
mix backplane.mcp_protocol.streamable_http.interactive --base-url=http://localhost:8080 --header 'authorization: Bearer 123'
# With verbose logging
mix backplane.mcp_protocol.streamable_http.interactive --base-url=http://localhost:4000 -vvv
In the interactive session:
mcp> list_tools
Available tools:
- greeter: Greet someone warmly
- product_search: Search for products in our catalog
mcp> call_tool
Tool name: greeter
Tool arguments (JSON): {"name": "Alice"}
Result: Hello Alice! Welcome to the MCP world!
mcp> show_state
Client State:
Protocol: 2026-07-28
...Unit Testing
Now let's write some tests:
defmodule MyApp.ServerTest do
use ExUnit.Case
alias Backplane.McpProtocol.Server.{Frame, Response}
test "greeter tool works correctly" do
frame = %Frame{}
assert {:reply, %Response{} = response, ^frame} =
MyApp.Greeter.execute(%{name: "joe"}, frame)
assert response.type == :tool
assert [%{"type" => "Hello joe! Welcome to the MCP world!"}] = response.content
end
endWhat's Next?
You've seen how to expose your Elixir application's capabilities to AI assistants. What patterns interest you most?
- Complex multi-step workflows?
- Authentication and authorization?
- Real-time updates and notifications?
The server abstraction handles all the protocol complexity. You just focus on what your application does best.