Alloy has no MCP support in core, on purpose. The context-economy argument
is real: a typical MCP server dumps every tool definition into every request —
Playwright's server costs ~13,700 tokens of tool schemas before the
conversation starts. Most Elixir applications also don't need MCP to expose
their own functions to an agent; a plain Alloy.Tool module is cheaper and
type-checked.
When you do need a third-party MCP server, the gateway pattern below wraps it
in one tool definition using
anubis_mcp — so the context cost is a
short tool list, not N full schemas.
Setup
Add the client to your deps and supervision tree:
# mix.exs
{:anubis_mcp, "~> 1.5"}
# application.ex
children = [
{Anubis.Client,
name: MyApp.MCPClient,
transport: {:streamable_http, base_url: "http://localhost:8000"},
client_info: %{"name" => "MyApp", "version" => "1.0.0"},
protocol_version: "2025-06-18"}
]The gateway tool
defmodule MyApp.Tools.MCP do
@behaviour Alloy.Tool
@client MyApp.MCPClient
@impl true
def name, do: "mcp"
@impl true
def description do
tools =
for %{"name" => name, "description" => desc} <- cached_tools() do
"- #{name}: #{String.slice(desc || "", 0, 200)}"
end
"""
Call a tool on the connected MCP server. Pass the tool name and its
arguments. Available tools:
#{Enum.join(tools, "\n")}
"""
end
@impl true
def input_schema do
%{
type: "object",
properties: %{
tool: %{type: "string", description: "Name of the MCP tool to call"},
arguments: %{
type: "object",
description: "Arguments for the tool, matching its schema"
}
},
required: ["tool"]
}
end
# Cap what a chatty server can dump into your context.
@impl true
def max_result_chars, do: 16_000
@impl true
def execute(%{"tool" => tool} = input, _context) do
arguments = Map.get(input, "arguments", %{})
case Anubis.Client.call_tool(@client, tool, arguments) do
{:ok, %Anubis.MCP.Response{is_error: false, result: result}} ->
{:ok, render_content(result)}
{:ok, %Anubis.MCP.Response{result: result}} ->
{:error, render_content(result)}
{:error, error} ->
{:error, "MCP transport error: #{inspect(error)}"}
end
end
# Tool list rarely changes; fetch once and cache for the VM's lifetime.
defp cached_tools do
case :persistent_term.get({__MODULE__, :tools}, :missing) do
:missing ->
{:ok, %Anubis.MCP.Response{result: %{"tools" => tools}}} =
Anubis.Client.list_tools(@client)
:persistent_term.put({__MODULE__, :tools}, tools)
tools
tools ->
tools
end
end
defp render_content(%{"content" => content}) when is_list(content) do
content
|> Enum.map(fn
%{"type" => "text", "text" => text} -> text
other -> inspect(other)
end)
|> Enum.join("\n")
end
defp render_content(result), do: inspect(result)
endThen just add it to tools::
{:ok, result} =
Alloy.run("Search the docs for compaction and summarize what you find",
provider: {Alloy.Provider.Anthropic, api_key: key, model: model},
tools: [MyApp.Tools.MCP]
)Remote MCP without a client
For remote HTTP MCP servers on Anthropic, you can also mount the server
provider-side instead of running a client in your app. Pass Anthropic's
mcp_servers body field and the matching beta header through extra_body /
extra_headers on the provider config. The client-side gateway above remains
the right pattern for local or stdio MCP servers and for providers without a
server-side MCP connector.
Variant: one tool per MCP tool
When you want the model to see full per-tool schemas (better argument
validation, at the cost of more context), build inline tools from the
server's discovery response with Alloy.Tool.inline/1:
defmodule MyApp.MCPTools do
@client MyApp.MCPClient
@doc "One Alloy tool per tool exposed by the MCP server."
def all do
{:ok, %Anubis.MCP.Response{result: %{"tools" => tools}}} =
Anubis.Client.list_tools(@client)
for %{"name" => name} = tool <- tools do
Alloy.Tool.inline(
name: name,
description: tool["description"] || name,
input_schema: tool["inputSchema"] || %{type: "object", properties: %{}},
max_result_chars: 16_000,
execute: fn arguments, _context ->
case Anubis.Client.call_tool(@client, name, arguments) do
{:ok, %Anubis.MCP.Response{is_error: false, result: result}} ->
{:ok, render_content(result)}
{:ok, %Anubis.MCP.Response{result: result}} ->
{:error, render_content(result)}
{:error, error} ->
{:error, "MCP transport error: #{inspect(error)}"}
end
end
)
end
end
defp render_content(%{"content" => content}) when is_list(content) do
content
|> Enum.map(fn
%{"type" => "text", "text" => text} -> text
other -> inspect(other)
end)
|> Enum.join("\n")
end
defp render_content(result), do: inspect(result)
end
{:ok, result} =
Alloy.run("Search the docs for compaction",
provider: provider,
tools: MyApp.MCPTools.all() ++ [Alloy.Tool.Core.Read]
)Trade-offs, honestly
Gateway vs. one-tool-per-MCP-tool. The gateway costs one tool definition and a compact name/description list — the right default, and the reason this recipe exists. The per-tool variant gives the model full schemas (provider- side argument validation) but pays the context cost the gateway avoids: every tool's complete schema rides along on every request. Measure before choosing it for servers with more than a handful of tools, and consider filtering the discovery list to the tools you actually want exposed.
Trust. An MCP server is remote code with a marketing page. Its tool
descriptions enter your prompt (prompt-injection surface) and its results
enter your context. Use max_result_chars, prefer servers you operate, and
consider a middleware hook to block specific tool names.
Don't use this for your own code. If the functions live in your
application, write Alloy.Tool modules directly — no transport, no JSON-RPC,
no second process tree, and the compiler checks your schema helpers.