# Building a Server

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:

```elixir
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:

```elixir
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:

```elixir
children = [
  # Start with STDIO for easy testing
  {MyApp.Server, transport: :stdio}
]
```

How do you test this? Complete one file for reference:

```elixir
Mix.install([{:backplane_mcp_protocol, "~> 1.10.51"}])

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:

```bash
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:

```bash
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?

```elixir
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?

```elixir
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:

```elixir
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:

```elixir
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:

```text
- calculator: Perform basic arithmetic operations on two numbers
```

The same pattern works for resources:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
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:

```elixir
{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:

```elixir
{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:

```elixir
# 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:

```elixir
@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:

```elixir
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.

```elixir
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:

```elixir
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:

```elixir
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

### Composing Peri validators

Use `Server.Component.Schema.validator/1` when a custom field delegates to
another Peri schema. It returns a callback suitable for `{:custom, callback}`,
normalizing both single and list errors and preserving declared field paths:

```elixir
alias Backplane.McpProtocol.Server.Component.Schema

attachment_validator = Schema.validator(%{name: {:required, :string}})

Frame.register_tool(frame, "create_note",
  input_schema: %{
    title: {:required, :string},
    attachment: {:required, {:custom, attachment_validator}}
  }
)
```

Invalid attachment names produce JSON-RPC `-32602` with an
`attachment.name` validation detail. Successful validation retains Peri's
validated or transformed value. This composition works with dynamically
registered tools and custom fields in component schemas, for both modern and
legacy requests.

When built through `Schema.validator/1`, custom callbacks may return either a
single `Peri.Error` or a list of errors from `Peri.validate/2`. The adapter
normalizes that result before Peri's nested traversal and converts root `nil`
paths to empty paths before enclosing fields are prefixed. Use
`Schema.validator(inner_schema)` at the inner boundary to retain dynamic key
detail that plain Peri validation omits. The error formatter renders nested
leaves using their complete declared field paths. Strict `{:schema, fields, {:additional_keys, type}}`
validators retain the rejected extra key. Lists whose item type is
`{:custom, callback}` also retain the failing item index and preserve list
constraints and transformed values:

```elixir
strict_attachment =
  {:schema, %{name: {:required, :string}},
   {:additional_keys,
    {:required, {:custom, fn _ -> {:error, "unsupported attachment field", []} end}}}}

attachments_schema = %{
  attachments: {:list, {:custom, Schema.validator(strict_attachment)}}
}

validator = Schema.validator(attachments_schema)
```

An unsupported `unknown_field` on the first attachment reports
`attachments.0.unknown_field: unsupported attachment field`. A rejected root
`creator` reports `creator: unsupported attachment field`. Use the helper at
each delegated schema boundary: a callback's returned singleton or error list
is normalized before enclosing Peri traversal, but exceptions raised inside a
callback still indicate a validator implementation error. The adapter does
not infer missing paths from arbitrary opaque callbacks or change other Peri
map/list validation forms.

### Wire schemas and structured results

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:

```bash
# 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:

```elixir
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.
