Current section
Files
Jump to
Current section
Files
lib/tidewave/mcp/server.ex
defmodule Tidewave.MCP.Server do
@moduledoc false
require Logger
alias Tidewave.MCP.Connection
alias Tidewave.MCP.Tools
@protocol_version "2024-11-05"
@vsn Mix.Project.config()[:version]
@doc false
def init_tools do
tools = raw_tools()
dispatch_map = Map.new(tools, fn tool -> {tool.name, tool.callback} end)
# TODO: switch back to persistent_term when we don't support OTP 27 any more
# :persistent_term.put({__MODULE__, :tools_and_dispatch}, {tools, dispatch_map})
:ets.new(:tidewave_tools, [:set, :named_table, read_concurrency: true])
:ets.insert(:tidewave_tools, {:tools, {tools, dispatch_map}})
end
@doc false
def tools_and_dispatch do
# TODO: switch back to persistent_term when we don't support OTP 27 any more
# :persistent_term.get({__MODULE__, :tools_and_dispatch})
[{:tools, tools}] = :ets.lookup(:tidewave_tools, :tools)
tools
end
defp raw_tools do
[
Tools.FS.tools(),
Tools.Logs.tools(),
Tools.Source.tools(),
Tools.Eval.tools(),
Tools.Ecto.tools(),
Tools.Phoenix.tools(),
Tools.Hex.tools()
]
|> List.flatten()
end
@doc false
def tools(connect_params) do
{tools, _} = tools_and_dispatch()
listable? = fn
%{listable: listable} when is_function(listable, 1) ->
listable.(connect_params)
_tool ->
true
end
for tool <- tools, listable?.(tool) do
tool
|> Map.put(:description, String.trim(tool.description))
|> Map.drop([:callback, :listable])
end
end
# A callback must return either
#
# * `{:ok, result}` if the callback does not receive state
# * `{:ok, result, new_state}` if the callback receives state (i.e. if it is of arity 2)
# * `{:ok, result, metadata}` if the callback is of arity 1 and returns metadata (returned as `_meta`)
# * `{:ok, result, new_state, metadata}` if the callback is of arity 2 and returns metadata (returned as `_meta`)
# * `{:error, reason}` for any error
# * `{:error, reason, new_state}` for any error that should also update the state
#
defp dispatch(name, args, assigns) do
{_tools, dispatch} = tools_and_dispatch()
case dispatch do
%{^name => callback} when is_function(callback, 2) ->
callback.(args, assigns)
%{^name => callback} when is_function(callback, 1) ->
callback.(args)
_ ->
{:error,
%{
code: -32601,
message: "Method not found",
data: %{
name: name
}
}}
end
end
def handle_ping(request_id) do
{:ok,
%{
jsonrpc: "2.0",
id: request_id,
result: %{}
}}
end
def handle_initialize(request_id, params, state_pid) do
case validate_protocol_version(params["protocolVersion"]) do
:ok ->
{:ok,
%{
jsonrpc: "2.0",
id: request_id,
result: %{
protocolVersion: @protocol_version,
capabilities: %{
tools: %{
listChanged: false
}
},
serverInfo: %{
name: "Tidewave MCP Server",
version: @vsn
},
tools: tools(Connection.connect_params(state_pid))
}
}}
{:error, reason} ->
{:error, reason}
end
end
def handle_list_tools(request_id, _params, state_pid) do
result_or_error(
request_id,
{:ok, %{tools: tools(Connection.connect_params(state_pid))}}
)
end
def handle_call_tool(request_id, %{"name" => name} = params, assigns) do
args = Map.get(params, "arguments", %{})
result_or_error(request_id, dispatch(name, args, assigns))
end
defp result_or_error(request_id, {:ok, text, metadata})
when is_binary(text) and is_map(metadata) do
result_or_error(request_id, {:ok, %{content: [%{type: "text", text: text}], _meta: metadata}})
end
defp result_or_error(request_id, {:ok, text}) when is_binary(text) do
result_or_error(request_id, {:ok, %{content: [%{type: "text", text: text}]}})
end
defp result_or_error(request_id, {:ok, result}) when is_map(result) do
{:ok,
%{
jsonrpc: "2.0",
id: request_id,
result: result
}}
end
defp result_or_error(request_id, {:error, :invalid_arguments}) do
{:error,
%{
jsonrpc: "2.0",
id: request_id,
error: %{code: -32602, message: "Invalid arguments for tool"}
}}
end
defp result_or_error(request_id, {:error, message}) when is_binary(message) do
# tool errors should be treated as successful response with isError: true
# https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#error-handling
result_or_error(
request_id,
{:ok, %{content: [%{type: "text", text: message}], isError: true}}
)
end
defp result_or_error(request_id, {:error, error}) when is_map(error) do
{:error,
%{
jsonrpc: "2.0",
id: request_id,
error: error
}}
end
defp validate_protocol_version(client_version) do
cond do
is_nil(client_version) ->
{:error, "Protocol version is required"}
client_version < unquote(@protocol_version) ->
{:error,
"Unsupported protocol version. Server supports #{unquote(@protocol_version)} or later"}
true ->
:ok
end
end
## handle_message function for SSE plug
# Built-in message routing
def handle_message(%{"method" => "notifications/initialized"} = message, _state_pid, _assigns) do
Logger.info("Received initialized notification")
Logger.debug("Full message: #{inspect(message, pretty: true)}")
{:ok, nil}
end
def handle_message(%{"method" => method, "id" => id} = message, state_pid, assigns) do
Logger.info("Routing MCP message - Method: #{method}, ID: #{id}")
Logger.debug("Full message: #{inspect(message, pretty: true)}")
case method do
"ping" ->
Logger.debug("Handling ping request")
handle_ping(id)
"initialize" ->
Logger.info(
"Handling initialize request with params: #{inspect(message["params"], pretty: true)}"
)
handle_initialize(id, message["params"], state_pid)
"tools/list" ->
Logger.debug("Handling tools list request")
handle_list_tools(id, message["params"], state_pid)
"tools/call" ->
Logger.debug(
"Handling tool call request with params: #{inspect(message["params"], pretty: true)}"
)
safe_call_tool(id, message["params"], assigns)
other ->
Logger.warning("Received unsupported method: #{other}")
{:error,
%{
jsonrpc: "2.0",
id: id,
error: %{
code: -32601,
message: "Method not found",
data: %{
name: other
}
}
}}
end
end
def handle_message(unknown_message, _state_pid) do
Logger.error("Received invalid message format: #{inspect(unknown_message, pretty: true)}")
{:error,
%{
jsonrpc: "2.0",
id: nil,
error: %{
code: -32600,
message: "Invalid Request",
data: %{
received: unknown_message
}
}
}}
end
defp safe_call_tool(request_id, params, assigns) do
handle_call_tool(request_id, params, assigns)
catch
kind, reason ->
# tool exceptions should be treated as successful response with isError: true
# https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#error-handling
{:ok,
%{
jsonrpc: "2.0",
id: request_id,
result: %{
content: [
%{
type: "text",
text: "Failed to call tool: #{Exception.format(kind, reason, __STACKTRACE__)}"
}
],
isError: true
}
}}
end
end