ExMCP.Server.ResultNormalizer (ex_mcp v1.0.0-rc.5)

Copy Markdown View Source

Shared result and error normalization for every server dispatch path.

ExMCP.Server.Dispatch (handler-process transports and stdio), ExMCP.MessageProcessor.MethodHandlers (HTTP) and ExMCP.Protocol.RequestProcessor (DSL servers) all convert handler return values into JSON-RPC results. Keeping that conversion here guarantees the transports agree on tool-result shape, key stringification and, most importantly, on what is safe to send back to a client.

Client-facing error messages

error_message/2 never runs inspect/1 over an arbitrary term into a response. Handler-authored detail (a binary reason, or a map/struct with a message field) is preserved because MCP servers are expected to explain themselves; anything else is logged with Logger.error/1 and replaced by a generic message so internal structs, pids, or file paths cannot leak.

Summary

Functions

Returns the JSON-RPC error code a handler error reason should map to.

Builds a client-safe error message from a handler error reason.

Builds a paginated list result such as %{"tools" => [...], "nextCursor" => ...}.

Recursively converts atom keys to strings.

Builds an MCP tool result that reports a failure through isError.

Normalizes a handle_call_tool/3 result into an MCP tools/call result.

Functions

error_code(reason, default \\ -32000)

@spec error_code(term(), integer()) :: integer()

Returns the JSON-RPC error code a handler error reason should map to.

Cursor complaints map to invalid params (-32602); everything else uses default. Codes embedded in the reason are deliberately not honoured: handlers have historically returned %{"code" => ...} maps whose codes do not match the transport-level meaning of the failure.

error_message(prefix, reason)

@spec error_message(String.t(), term()) :: String.t()

Builds a client-safe error message from a handler error reason.

Detail that the handler clearly authored (a binary, an atom, or a :message / "message" field) is kept. Everything else is logged and omitted from the response.

paginated(key, entries, next_cursor \\ nil)

@spec paginated(String.t(), list(), String.t() | nil) :: map()

Builds a paginated list result such as %{"tools" => [...], "nextCursor" => ...}.

stringify_keys(list)

@spec stringify_keys(term()) :: term()

Recursively converts atom keys to strings.

Known MCP protocol fields such as :input_schema, :mime_type and :is_error are mapped to their lower-camel-case wire names so raw Handler implementations may use idiomatic Elixir keys.

tool_error_result(reason)

@spec tool_error_result(term()) :: map()

Builds an MCP tool result that reports a failure through isError.

tool_result(result, opts \\ [])

@spec tool_result(
  term(),
  keyword()
) :: map()

Normalizes a handle_call_tool/3 result into an MCP tools/call result.

Accepts a bare content list, a binary (wrapped as a text content item), or a map that already carries content.

Options

  • :wrap_bare_map - when true, a map that carries no content key is wrapped as %{"content" => map} instead of being used as the result verbatim. The handler-process transports (ExMCP.Server.Dispatch) have always done this; the HTTP path has not, and both behaviours are relied on by existing servers.