Packages
Model Context Protocol (MCP) implementation in Elixir with Phoenix integration
Retired package: Release invalid - Accidental version. Use 0.3.0 instead.
Current section
Files
Jump to
Current section
Files
lib/backplane/mcp_protocol/server/handlers/tools.ex
defmodule Backplane.McpProtocol.Server.Handlers.Tools do
@moduledoc false
alias Backplane.McpProtocol.MCP.Error
alias Backplane.McpProtocol.Server.Component.Schema
alias Backplane.McpProtocol.Server.Component.Tool
alias Backplane.McpProtocol.Server.Frame
alias Backplane.McpProtocol.Server.Handlers
alias Backplane.McpProtocol.Server.Response
@spec handle_list(map, Frame.t(), module()) ::
{:reply, map(), Frame.t()} | {:error, Error.t(), Frame.t()}
def handle_list(request, frame, server_module) do
tools =
server_module
|> Handlers.get_server_tools(frame)
|> Enum.filter(&visible?(&1, frame))
limit = frame.pagination_limit
{tools, cursor} = Handlers.maybe_paginate(request, tools, limit)
{:reply,
then(
%{"tools" => tools},
&if(cursor, do: Map.put(&1, "nextCursor", cursor), else: &1)
), frame}
end
@spec handle_call(map(), Frame.t(), module()) ::
{:reply, map(), Frame.t()} | {:error, Error.t(), Frame.t()}
def handle_call(%{"params" => %{"name" => tool_name, "arguments" => params}} = request, frame, server) do
registered_tools = Handlers.get_server_tools(server, frame)
if tool = find_tool_module(registered_tools, tool_name) do
with :ok <- check_scopes(tool, frame),
:ok <- check_task_policy(tool, request, frame),
{:ok, params} <- validate_params(params, tool, frame),
do: forward_to(server, tool, params, frame)
else
payload = %{message: "Tool not found: #{tool_name}"}
{:error, Error.protocol(:invalid_params, payload), frame}
end
end
def handle_call(%{"params" => %{"name" => tool_name}} = request, frame, server) do
registered_tools = Handlers.get_server_tools(server, frame)
if tool = find_tool_module(registered_tools, tool_name) do
with :ok <- check_scopes(tool, frame),
:ok <- check_task_policy(tool, request, frame),
{:ok, params} <- validate_params(%{}, tool, frame),
do: forward_to(server, tool, params, frame)
else
payload = %{message: "Tool not found: #{tool_name}"}
{:error, Error.protocol(:invalid_params, payload), frame}
end
end
# Private functions
defp check_scopes(%Tool{scopes: []}, _frame), do: :ok
defp check_scopes(%Tool{scopes: required}, frame) do
granted = Frame.scopes(frame)
missing = Enum.reject(required, &(&1 in granted))
if missing == [] do
:ok
else
{:error, Error.execution("insufficient_scope", %{required: required, granted: granted}), frame}
end
end
defp visible?(%Tool{scopes: []}, _frame), do: true
defp visible?(%Tool{scopes: required}, frame), do: Frame.has_all_scopes?(frame, required)
defp find_tool_module(tools, name), do: Enum.find(tools, &(&1.name == name))
# Spec 2025-11-25: tool with execution.taskSupport == "required" MUST be
# invoked as a task. Direct (non-augmented) calls return -32601. Augmented
# calls reach this handler only via the task worker path, where
# `Frame.task_id` is set, so we use that as the discriminator.
defp check_task_policy(%Tool{task_support: :required}, _request, %Frame{task_id: nil} = frame) do
{:error,
Error.protocol(:method_not_found, %{
message: "Tool requires task augmentation (execution.taskSupport == \"required\")"
}), frame}
end
defp check_task_policy(%Tool{task_support: :forbidden}, %{"params" => %{"task" => _}}, frame) do
{:error,
Error.protocol(:method_not_found, %{
message: "Tool does not support task augmentation (execution.taskSupport == \"forbidden\")"
}), frame}
end
defp check_task_policy(_tool, _request, _frame), do: :ok
defp validate_params(_, %Tool{validate_input: nil}, _), do: {:ok, %{}}
defp validate_params(params, %Tool{} = tool, frame) do
with {:error, errors} <- tool.validate_input.(params) do
message = Schema.format_errors(errors)
{:error, Error.protocol(:invalid_params, %{message: message}), frame}
end
end
defp forward_to(server, %Tool{handler: nil} = tool, params, frame) do
case server.handle_tool_call(tool.name, params, frame) do
{:reply, %Response{} = response, frame} ->
maybe_validate_output_schema(tool, response, frame)
{:noreply, frame} ->
{:reply, %{"content" => [], "isError" => false}, frame}
{:error, %Error{} = error, frame} ->
{:error, error, frame}
end
end
defp forward_to(_server, %Tool{handler: handler} = tool, params, frame) do
case handler.execute(params, frame) do
{:reply, %Response{} = response, frame} ->
maybe_validate_output_schema(tool, response, frame)
{:noreply, frame} ->
{:reply, %{"content" => [], "isError" => false}, frame}
{:error, %Error{} = error, frame} ->
{:error, error, frame}
end
end
@output_schema_err "Tool doesnt conform for it output schema"
defp maybe_validate_output_schema(%Tool{output_schema: nil}, resp, frame) do
{:reply, Response.to_protocol(resp), frame}
end
defp maybe_validate_output_schema(_tool, %Response{isError: true} = resp, frame) do
{:reply, Response.to_protocol(resp), frame}
end
defp maybe_validate_output_schema(%Tool{} = tool, %Response{structured_content: nil}, frame) do
metadata = %{tool_name: tool.name}
{:error, Error.execution(@output_schema_err, metadata), frame}
end
defp maybe_validate_output_schema(%Tool{} = tool, %Response{} = resp, frame) do
case tool.validate_output.(resp.structured_content) do
{:ok, _} -> {:reply, Response.to_protocol(resp), frame}
{:error, errors} -> {:error, Error.execution(@output_schema_err, %{errors: errors}), frame}
end
end
end