Current section
Files
Jump to
Current section
Files
lib/ash_ai/mcp/server.ex
defmodule AshAi.Mcp.Server do
@moduledoc """
Implementation of the Model Context Protocol (MCP) RPC functionality.
This module handles HTTP requests and responses according to the MCP specification,
supporting both synchronous and streaming communication patterns.
It also handles the core JSON-RPC message processing for the protocol.
"""
# no session features for now
# alias AshAi.Mcp.Session
@doc """
Process an HTTP POST request containing JSON-RPC messages
"""
# sobelow_skip ["XSS.SendResp"]
def handle_post(conn, body, session_id, opts \\ []) do
accept_header = Plug.Conn.get_req_header(conn, "accept")
_accept_sse = Enum.any?(accept_header, &String.contains?(&1, "text/event-stream"))
_accept_json = Enum.any?(accept_header, &String.contains?(&1, "application/json"))
opts =
[
actor: Ash.PlugHelpers.get_actor(conn),
tenant: Ash.PlugHelpers.get_tenant(conn),
context: Ash.PlugHelpers.get_context(conn) || %{}
]
|> Keyword.merge(opts)
case process_request(body, session_id, opts) do
{:initialize_response, response, new_session_id} ->
# Return the initialize response with a session ID header
conn
|> Plug.Conn.put_resp_header("content-type", "application/json")
|> Plug.Conn.put_resp_header("mcp-session-id", new_session_id)
|> Plug.Conn.send_resp(200, response)
{:json_response, response, _session_id} ->
# Regular JSON response
conn
|> Plug.Conn.put_resp_header("content-type", "application/json")
|> Plug.Conn.send_resp(200, response)
{:batch_response, response, _session_id} ->
# Batch response
conn
|> Plug.Conn.put_resp_header("content-type", "application/json")
|> Plug.Conn.send_resp(200, response)
{:no_response, _, _} ->
# For notifications or other messages that don't require a response
conn
|> Plug.Conn.send_resp(202, "")
end
end
@doc """
Process an HTTP GET request to open an SSE stream
"""
def handle_get(conn, _session_id) do
accept_header = Plug.Conn.get_req_header(conn, "accept")
if Enum.any?(accept_header, &String.contains?(&1, "text/event-stream")) do
# {:ok, pid} = Session.get_or_create(session_id)
# Process.link(pid)
# Session.put(session_id, :conn, conn)
# Get the current host and path to create the post URL
host = Plug.Conn.get_req_header(conn, "host") |> List.first()
scheme = if conn.scheme == :https, do: "https", else: "http"
path = conn.request_path
post_url = "#{scheme}://#{host}#{path}"
# Set up SSE stream
conn
|> Plug.Conn.put_resp_header("content-type", "text/event-stream")
|> Plug.Conn.put_resp_header("cache-control", "no-cache")
|> Plug.Conn.send_chunked(200)
# Send the post_url in an endpoint event according to MCP specification
|> send_sse_event("endpoint", Jason.encode!(%{"url" => post_url}))
# We don't need to keep the SSE connection alive right now
# but we might later for progress etc.
# :timer.sleep(:infinity)
else
# Client doesn't support SSE
conn
|> Plug.Conn.send_resp(400, "Client must accept text/event-stream")
end
end
@doc """
Handle HTTP DELETE request for session termination
"""
def handle_delete(conn, session_id) do
if session_id do
# session features left out for now
# case Session.terminate(session_id) do
# :ok ->
conn
|> Plug.Conn.send_resp(200, "")
# {:error, :not_found} ->
# conn
# |> Plug.Conn.send_resp(404, "")
# end
else
conn
|> Plug.Conn.send_resp(400, "")
end
end
@doc """
Send an SSE event over the chunked connection
"""
def send_sse_event(conn, event, data, id \\ nil) do
chunks = [
if(id, do: "id: #{id}\n", else: ""),
"event: #{event}\n",
"data: #{data}\n\n"
]
Enum.reduce(chunks, conn, fn chunk, conn ->
{:ok, conn} = Plug.Conn.chunk(conn, chunk)
conn
end)
end
@doc """
Get the MCP server version
"""
def get_server_version(opts) do
if opts[:mcp_server_version] do
opts[:mcp_server_version]
else
if opts[:otp_app] do
case :application.get_key(opts[:otp_app], :vsn) do
{:ok, version} -> List.to_string(version)
:undefined -> "0.1.0"
end
else
"0.1.0"
end
end
end
@doc """
Get the MCP server name
"""
def get_server_name(opts) do
if opts[:mcp_name] do
opts[:mcp_name]
else
if opts[:otp_app] do
"MCP Server"
else
"#{opts[:otp_app]} MCP Server"
end
end
end
defp process_request(request, session_id, opts) do
case parse_json_rpc(request) do
{:ok, message} when is_map(message) ->
# Process a single message
process_message(message, session_id, opts)
{:ok, batch} when is_list(batch) ->
# Handle batch requests
responses = Enum.map(batch, fn item -> process_message(item, session_id, opts) end)
# Filter out no_response items and format the response
response_items = Enum.filter(responses, fn {type, _, _} -> type != :no_response end)
if Enum.empty?(response_items) do
# All items were notifications, no response needed
{:no_response, nil, session_id}
else
# Convert each response to its JSON representation
json_responses = Enum.map(response_items, fn {_, json, _} -> json end)
{:batch_response, "[#{Enum.join(json_responses, ",")}]", session_id}
end
{:error, error} ->
# Handle parsing errors
response =
json_rpc_error_response(nil, -32_700, "Parse error", %{"details" => inspect(error)})
{:json_response, response, session_id}
end
end
@doc """
Process a single JSON-RPC message
"""
def process_message(message, session_id, opts) do
case message do
%{"method" => "initialize", "id" => id, "params" => _params} ->
# Handle initialize request
new_session_id = session_id || Ash.UUIDv7.generate()
# Create session and store client info
# {:ok, _pid} = Session.get_or_create(new_session_id)
# Session.put(new_session_id, :client_info, params["client"])
protocol_version_statement = opts[:protocol_version_statement] || "2025-03-26"
# Return capabilities
response = %{
"jsonrpc" => "2.0",
"id" => id,
"result" => %{
"serverInfo" => %{
"name" => get_server_name(opts),
"version" => get_server_version(opts)
},
"protocolVersion" => protocol_version_statement,
"capabilities" => %{
"tools" => %{
"listChanged" => false
}
}
}
}
{:initialize_response, Jason.encode!(response), new_session_id}
%{"method" => "shutdown", "id" => id, "params" => _params} ->
# Handle shutdown request - clean up session resources
# if session_id do
# session.terminate(session_id)
# end
# Return success
response = %{
"jsonrpc" => "2.0",
"id" => id,
"result" => nil
}
{:json_response, Jason.encode!(response), session_id}
%{"method" => "$/cancelRequest", "params" => %{"id" => _request_id}} ->
# TODO: Cancel request?
{:no_response, nil, session_id}
%{"method" => "tools/list", "id" => id} ->
tools =
opts
|> tools()
|> Enum.map(fn function ->
%{
"name" => function.name,
"description" => function.description,
"inputSchema" => function.parameters_schema
}
end)
response = %{
"jsonrpc" => "2.0",
"id" => id,
"result" => %{
"tools" => tools
}
}
{:json_response, Jason.encode!(response), session_id}
%{"method" => "tools/call", "id" => id, "params" => params} ->
tool_name = params["name"]
tool_args = params["arguments"] || %{}
opts =
opts
|> Keyword.update(
:context,
%{mcp_session_id: session_id},
&Map.put(&1, :mcp_session_id, session_id)
)
|> Keyword.put(:filter, fn tool -> tool.mcp == :tool end)
opts
|> tools()
|> Enum.find(&(&1.name == tool_name))
|> case do
nil ->
response = %{
"jsonrpc" => "2.0",
"id" => id,
"error" => %{
"code" => -32_602,
"message" => "Tool not found: #{tool_name}"
}
}
{:json_response, Jason.encode!(response), session_id}
tool ->
context =
opts
|> Keyword.take([:actor, :tenant, :context])
|> Map.new()
|> Map.update(
:context,
%{otp_app: opts[:otp_app]},
&Map.put(&1, :otp_app, opts[:otp_app])
)
case tool.function.(tool_args, context) do
{:ok, result, _} ->
response = %{
"jsonrpc" => "2.0",
"id" => id,
"result" => %{
"isError" => false,
"content" => [%{"type" => "text", "text" => result}]
}
}
{:json_response, Jason.encode!(response), session_id}
{:error, error} ->
response = %{
"jsonrpc" => "2.0",
"id" => id,
"error" => %{
"code" => -32_000,
"message" => "Tool execution failed",
"data" => %{"error" => error}
}
}
{:json_response, Jason.encode!(response), session_id}
end
end
%{"method" => method, "id" => id, "params" => _params} ->
# Handle other requests with IDs (requiring responses)
response = %{
"jsonrpc" => "2.0",
"id" => id,
"error" => %{
"code" => -32_601,
"message" => "Method not implemented: #{method}"
}
}
{:json_response, Jason.encode!(response), session_id}
%{"method" => _method} ->
# Handle other notifications (no id)
{:no_response, nil, session_id}
_other ->
# Invalid message
{:json_response, json_rpc_error_response(nil, -32_600, "Invalid Request"), session_id}
end
end
defp tools(opts) do
opts =
if opts[:tools] == :ash_dev_tools do
opts
|> Keyword.put(:actions, [{AshAi.DevTools.Tools, :*}])
|> Keyword.put(:tools, [
:list_ash_resources,
:list_generators,
:get_usage_rules,
:list_packages_with_rules
])
else
opts
end
opts
|> Keyword.take([:otp_app, :tools, :actor, :context, :tenant, :actions])
|> Keyword.update(
:context,
%{otp_app: opts[:otp_app]},
&Map.put(&1, :otp_app, opts[:otp_app])
)
|> AshAi.functions()
end
@doc """
Parse the JSON-RPC request
"""
def parse_json_rpc(request) when is_binary(request) do
case Jason.decode(request) do
{:ok, decoded} -> {:ok, decoded}
{:error, _} = error -> error
end
end
def parse_json_rpc(request) when is_map(request) do
{:ok, request}
end
@doc """
Create a standard JSON-RPC error response
"""
def json_rpc_error_response(id, code, message, data \\ nil) do
error = %{"code" => code, "message" => message}
error = if data, do: Map.put(error, "data", data), else: error
Jason.encode!(%{
"jsonrpc" => "2.0",
"id" => id,
"error" => error
})
end
end