Building a Server

Copy Markdown

What 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
end

What 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
end

Add 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
end

Notice 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
end

Resources 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
end

Component 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
end

When listed, this tool appears as:

- calculator: Perform basic arithmetic operations on two numbers

The 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
end

And 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
end

Dynamic 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
end

Resources 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
end

And 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
end

What 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}
end

The 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
end

Backplane.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
end

Tool 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
end

These 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
end

What'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.