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:

defmodule MyApp.Greeter do
  @moduledoc "Greet someone warmly"

  use Anubis.Server.Component, type: :tool

  schema do
    field :name, :string, required: true
  end

  def execute(%{name: name}, _frame) do
    {:ok, "Hello #{name}! Welcome to the MCP world!"}
  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 Anubis.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([{:anubis_mcp, "~> 0.11"}])

defmodule MyApp.Greeter do
  @moduledoc "Greet someone warmly"

  use Anubis.Server.Component, type: :tool

  schema do
    field :name, :string, required: true
  end

  def execute(%{name: name}, _frame) do
    {:ok, "Hello #{name}! Welcome to the MCP world!"}
  end
end

defmodule MyApp.Server do
  use Anubis.Server,
    name: "my-app",
    version: "1.0.0",
    capabilities: [:tools]

  # Register our greeter tool
  component MyApp.Greeter
end

children = [Anubis.Server.Registry, {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 anubis.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 Anubis.Server.Component, type: :tool

  alias Anubis.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 Anubis.Server.Component,
    type: :resource,
    uri: "config://app/settings"

  alias Anubis.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 Anubis.Server.Component, type: :prompt

  alias Anubis.Server.Response

  schema do
    field :title, :string, required: true
    field :severity, :string, 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 bug_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

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. Each client gets its own session.

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 Anubis.Server.Transport.StreamableHTTP.Plug,
    server: MyApp.Server,
    path: "/mcp"

  # Your other plugs...
  plug MyAppWeb.Router
end

# In your application supervisor
children = [
  MyAppWeb.Endpoint,
  Anubis.Server.Registry,
  {MyApp.Server, transport: :streamable_http}
]

Now your MCP server is available at http://localhost:4000/mcp.

Error Handling

What happens when things go wrong? Let's handle errors gracefully:

defmodule MyApp.DatabaseQuery do
  @moduledoc "Query the database"

  use Anubis.Server.Component, type: :tool

  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)}")}
    end
  end
end

Anubis automatically formats your error responses according to the MCP protocol.

Stateful Operations

Need to maintain state across calls? The frame provides context:

defmodule MyApp.Conversation do
  @moduledoc "Continue a conversation"

  use Anubis.Server.Component, type: :tool

  schema do
    field :message, :string, required: true
  end

  @impl true
  def execute(%{message: message}, frame) do
    session_id = frame.private.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 Anubis.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.

Testing Your Server

How do you know your server works correctly? Let's explore interactive testing first:

Interactive CLI Testing

Anubis provides interactive Mix tasks for different transports if you need quick testing:

# Test STDIO server
mix anubis.stdio.interactive --command elixir --args=--no-halt,my_app.exs

# Test HTTP server
mix anubis.streamable_http.interactive --base-url=http://localhost:8080 --header 'authorization: Bearer 123'

# With verbose logging
mix anubis.stdio.sse --base-url=http//:localhost:4000 -vvv

In the interactive session:

mcp> ping
pong

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: 2024-11-05
  Initialized: true
  ...

Unit Testing

Now let's write some tests:

defmodule MyApp.ServerTest do
  use ExUnit.Case

  alias Anubis.Server.Frame

  test "greeter tool works correctly" do
    frame = %Frame{}

    assert {:reply, resp, ^frame} = Greeter.execute(%{name: "joe"}, frame)
    assert {:ok, %{"result" => %{"content" => content}}} = JSON.decode(resp)
    assert [%{"text" => "Hello joe! Welcome to the MCP world!"}] = 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.