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

Copy Markdown View Source

Shared result and error normalization for every server dispatch path.

ExMCP.Server.Dispatch (handler-process transports and stdio), the HTTP message processor, 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" => ...}.

Prepares the complete source collection for a modern tools/list page.

Applies the result envelope required by the request's protocol era.

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.

Validates that an extension result was enabled by per-request capabilities.

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" => ...}.

prepare_tools_list(tools)

@spec prepare_tools_list([map()]) :: [map()]

Prepares the complete source collection for a modern tools/list page.

Custom handlers that paginate tools must call this function before slicing a page or calculating an opaque cursor. It stringifies protocol keys, excludes invalid x-mcp-header definitions, removes ExMCP-only execution metadata, and applies the deterministic modern ordering.

The normal result path applies the same operation defensively, but at that point it cannot repair a cursor that a custom handler calculated from an unfiltered collection.

protocol_result(result, request_context, opts \\ [])

@spec protocol_result(map(), map(), keyword()) :: map()

Applies the result envelope required by the request's protocol era.

Legacy results are returned unchanged. Modern results receive a resultType discriminator and result metadata identifying the server. Handler-supplied input_required (or extension) result types are preserved.

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.

validate_result_capabilities(result, request_context)

@spec validate_result_capabilities(map(), map()) ::
  :ok | {:error, ExMCP.Error.ProtocolError.t()}

Validates that an extension result was enabled by per-request capabilities.