Current section

Files

Jump to
corex lib mcp server.ex
Raw

lib/mcp/server.ex

# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2025 Dashbit
# See LICENSE for third-party notices.
# Upstream: tidewave v0.5.6 (deps/tidewave/lib/tidewave/mcp/server.ex)
# Modifications: Corex.MCP namespace; Corex.MCP.Tools.*; Corex serverInfo;
# tools stored in :persistent_term, initialized from plug init/1.
defmodule Corex.MCP.Server do
@moduledoc false
require Logger
import Plug.Conn
alias Corex.MCP.Config
alias Corex.MCP.Tools.Components, as: McpToolComponents
alias Corex.MCP.Tools.Installation, as: McpToolInstallation
@protocol_version "2025-03-26"
@vsn Mix.Project.config()[:version] || "0.0.0"
@tools_key {__MODULE__, :tools_and_dispatch}
@parse_error -32_600
@method_not_found -32_601
@invalid_params -32_602
@doc "Loads tool specs and callbacks into persistent term storage."
def init_tools do
tools =
[McpToolComponents.tools(), McpToolInstallation.tools()]
|> Enum.flat_map(& &1)
|> maybe_append_test_tools()
dispatch = Map.new(tools, &{&1.name, &1.callback})
:persistent_term.put(@tools_key, {tools, dispatch})
:ok
end
defp maybe_append_test_tools(tools) do
if Application.get_env(:corex, :mcp_test_raise_tool, false) do
tools ++ [test_raise_tool()]
else
tools
end
end
defp test_raise_tool do
%{
name: "test_raise",
description: "Test-only tool that raises",
inputSchema: %{
type: "object",
properties: %{}
},
callback: fn _args, _assigns -> raise "secret internal path" end
}
end
@doc "Returns the stored `{tools, dispatch}` tuple."
def tools_and_dispatch do
:persistent_term.get(@tools_key)
end
defp tools do
{tools, _dispatch} = tools_and_dispatch()
for tool <- tools do
tool
|> Map.put(:description, String.trim(tool.description))
|> Map.drop([:callback])
end
end
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: @method_not_found,
message: "Method not found",
data: %{name: name}
}}
end
end
defp validate_protocol_version(client_version) do
cond do
is_nil(client_version) ->
{:error, "Protocol version is required"}
client_version < @protocol_version ->
{:error, "Unsupported protocol version. Server supports #{@protocol_version} or later"}
true ->
:ok
end
end
defp handle_ping(request_id) do
jsonrpc_result(request_id, %{})
end
defp handle_initialize(request_id, params) do
case validate_protocol_version(params["protocolVersion"]) do
:ok ->
jsonrpc_result(request_id, %{
protocolVersion: @protocol_version,
capabilities: %{tools: %{listChanged: false}},
serverInfo: %{name: "Corex MCP", version: to_string(@vsn)},
tools: tools()
})
{:error, reason} ->
{:error,
%{
jsonrpc: "2.0",
id: request_id,
error: %{code: @invalid_params, message: reason}
}}
end
end
defp handle_list_tools(request_id, _params) do
result_or_error(request_id, {:ok, %{tools: tools()}})
end
defp handle_list_prompts(request_id, _params) do
result_or_error(request_id, {:ok, %{prompts: []}})
end
defp handle_list_resources(request_id, _params) do
result_or_error(request_id, {:ok, %{resources: []}})
end
defp handle_list_templates(request_id, _params) do
result_or_error(request_id, {:ok, %{templates: []}})
end
defp jsonrpc_result(request_id, result) do
{:ok, %{jsonrpc: "2.0", id: request_id, result: result}}
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
jsonrpc_result(request_id, result)
end
defp result_or_error(request_id, {:error, :invalid_arguments}) do
{:error,
%{
jsonrpc: "2.0",
id: request_id,
error: %{code: @invalid_params, message: "Invalid arguments for tool"}
}}
end
defp result_or_error(request_id, {:error, message}) when is_binary(message) do
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 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 safe_call_tool(request_id, params, assigns) do
handle_call_tool(request_id, params, assigns)
catch
kind, reason ->
stacktrace = __STACKTRACE__
formatted = Exception.format(kind, reason, stacktrace)
unless verbose_errors?(assigns) do
Logger.error("Corex.MCP tool call failed: #{formatted}")
end
text =
if verbose_errors?(assigns) do
"Failed to call tool: #{formatted}"
else
"Failed to call tool"
end
{:ok,
%{
jsonrpc: "2.0",
id: request_id,
result: %{
content: [
%{
type: "text",
text: text
}
],
isError: true
}
}}
end
defp verbose_errors?(%Config{verbose_errors: value}), do: value
defp verbose_errors?(assigns) when is_map(assigns) do
Map.get(assigns, :verbose_errors, Application.get_env(:corex, :mcp_verbose_errors, false))
end
defp verbose_errors?(_), do: Application.get_env(:corex, :mcp_verbose_errors, false)
defp handle_message(%{"method" => "notifications/initialized"} = message, _assigns) do
mcp_debug("Received initialized notification")
mcp_debug("Full message: #{inspect(message, pretty: true)}")
{:ok, nil}
end
defp handle_message(%{"method" => "notifications/cancelled"} = message, _assigns) do
mcp_debug("Request cancelled: #{inspect(message["params"])}")
{:ok, nil}
end
defp handle_message(%{"method" => method, "id" => id} = message, assigns) do
mcp_debug("Routing MCP message - Method: #{method}, ID: #{id}")
mcp_debug("Full message: #{inspect(message, pretty: true)}")
route_request(method, id, message, assigns)
end
defp route_request("ping", id, _message, _assigns), do: handle_ping(id)
defp route_request("initialize", id, message, _assigns) do
handle_initialize(id, message["params"] || %{})
end
defp route_request("tools/list", id, message, _assigns) do
handle_list_tools(id, message["params"])
end
defp route_request("tools/call", id, message, assigns) do
safe_call_tool(id, message["params"], assigns)
end
defp route_request("prompts/list", id, message, _assigns) do
handle_list_prompts(id, message["params"])
end
defp route_request("resources/list", id, message, _assigns) do
handle_list_resources(id, message["params"])
end
defp route_request("resources/templates/list", id, message, _assigns) do
handle_list_templates(id, message["params"])
end
defp route_request(other, id, _message, _assigns) do
Logger.warning("Received unsupported method: #{other}")
{:error,
%{
jsonrpc: "2.0",
id: id,
error: %{code: @method_not_found, message: "Method not found", data: %{name: other}}
}}
end
defp validate_jsonrpc_message(%{"jsonrpc" => "2.0"} = message) do
cond do
Map.has_key?(message, "id") and Map.has_key?(message, "method") ->
valid_request_id?(message["id"], message)
not Map.has_key?(message, "id") and Map.has_key?(message, "method") ->
{:ok, message}
Map.has_key?(message, "id") and Map.has_key?(message, "result") ->
{:ok, message}
true ->
{:error, :invalid_jsonrpc}
end
end
defp validate_jsonrpc_message(_), do: {:error, :invalid_jsonrpc}
defp valid_request_id?(id, message) when is_binary(id) or is_number(id), do: {:ok, message}
defp valid_request_id?(_, _), do: {:error, :invalid_jsonrpc}
defp send_json(conn, data) do
conn
|> put_resp_content_type("application/json")
|> send_resp(conn.status || 200, Corex.Json.encode!(data))
end
defp send_jsonrpc_error(conn, id, code, message, data \\ nil) do
error =
%{code: code, message: message}
|> maybe_put_error_data(data)
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Corex.Json.encode!(%{jsonrpc: "2.0", id: id, error: error}))
end
defp maybe_put_error_data(error, nil), do: error
defp maybe_put_error_data(error, data), do: Map.put(error, :data, data)
@doc "Handles a JSON-RPC MCP request over HTTP."
def handle_http_message(conn) do
mcp_debug("Received #{conn.method} message")
params = conn.body_params
conn = fetch_query_params(conn)
mcp_debug("Raw params: #{inspect(params, pretty: true)}")
with {:ok, message} <- validate_jsonrpc_message(params),
{:ok, response} <- handle_message(message, conn.private.corex_mcp_config) do
case response do
nil ->
conn |> put_status(202) |> send_json(%{status: "ok"})
response ->
mcp_debug("Sending HTTP response: #{inspect(response, pretty: true)}")
conn |> put_status(200) |> send_json(response)
end
else
{:error, :invalid_jsonrpc} ->
Logger.warning("Invalid JSON-RPC message format")
send_jsonrpc_error(conn, nil, @parse_error, "Could not parse message")
{:error, error_response} when is_map(error_response) ->
Logger.warning("Error handling message: #{inspect(error_response)}")
conn |> put_status(400) |> send_json(error_response)
end
end
defp mcp_debug(message) do
if Application.get_env(:corex, :debug) do
Logger.debug(message)
end
end
end