barrel_mcp_protocol (barrel_mcp v3.0.1)

View Source

The MCP protocol core: JSON-RPC envelopes in, envelopes out.

This module owns what every transport shares: decoding a request, deciding which era it belongs to and whether that era has the method, validating _meta, running the method handler, and rendering the answer for that era. It owns nothing about the wire: no sockets, no headers, no sessions on the wire, no SSE. A transport (barrel_mcp_http_engine, barrel_mcp_stdio) calls handle/2 with a protocol state map and writes whatever comes back.

What handle/2 returns

A response map to encode, no_response for a notification, {async, Plan} for a tools/call (the transport drives the plan, see drive_async_plan/4, because only it knows how to stream the answer), {subscribe, Sub} for subscriptions/listen, or a list for a batch.

Sections, in file order

  • API: handle/1,2, decode/1, the response constructors.
  • Batches: the legacy batch envelope and its per-era refusal.
  • Era dispatch: dispatch/4dispatch_versioned/4dispatch_valid/4; serves/2 says which methods an era has; finalize/2 decorates a result for its era.
  • Tasks: the task collector, task_plan/2 (the mode rule for every transport but Streamable HTTP), create_task_result/3.
  • Multi round-trip requests: input_required rounds and the sealed request_state.
  • Request handlers: one handle_request/4 clause per method.
  • Notification handlers.
  • Internal functions, cursor pagination, envelope helpers.

Processes

Everything here runs in the caller's process (a request process under HTTP, a worker under stdio) except the task collector, which spawn_task_collector/3 starts to outlive the request. See the Server Internals guide for the process model and the hop list of a request.

Summary

Functions

The result that hands a client a task instead of a value.

Decode a JSON-RPC request body. The spec includes list() in the success type so the HTTP transport can detect (and reject) JSON-RPC batches.

Classify a decoded JSON-RPC envelope.

Drive an {async, AsyncPlan} from handle/2 to completion on the calling process and return a JSON-RPC response map.

As drive_async_plan/2, but threads the authenticated principal (AuthInfo, the auth provider's authenticate/2 map) into the tool Ctx under auth_info. Transports that authenticate before driving the plan (the simple HTTP transport) pass it here; callers with no auth provider use the /2 form and auth_info is undefined.

As drive_async_plan/3, telling OnSpawn the worker pid before waiting on it. A caller that must reap the worker if its own peer goes away has no other way to learn the pid: the wait is blocking and the spawn happens inside it.

Encode a JSON-RPC response, or a batch of them.

Build a JSON-RPC error response. Alias of error_response/3.

Build a JSON-RPC notification envelope (no id).

Build a JSON-RPC request envelope.

Build a JSON-RPC success response.

Create an error response.

Stamp the fields a modern result must carry: resultType, and serverInfo in the result's _meta.

Format a tool handler's plain return value into the MCP content-block list shape. Public so transports driving async tool calls (HTTP / stdio) can produce identical envelopes.

Handle a JSON-RPC request with default state.

Handle a JSON-RPC request with state.

Turn a handler's {input_required, Requests, State} into the JSON-RPC envelope for it, given the plan that produced the call.

The -32021 a client without the tasks extension gets from a tool that requires one (tasks.md "Capability Negotiation").

Return a marker for no response (notifications).

Run a tool's outcome into its task rather than back to the caller, which has already been handed the task id.

Who a task belongs to, which differs by era.

Whether this client opted into receiving task handles.

Types

result/0

-type result() :: map() | [map()] | no_response | {async, map()} | {subscribe, map()}.

Functions

create_task_result(TaskId, Task, Ctx)

-spec create_task_result(binary(), map(), barrel_mcp_ctx:ctx()) -> map().

The result that hands a client a task instead of a value.

pollIntervalMs is a hint at how often to come back; ttlMs is how long the handle stays resolvable.

decode(Binary)

-spec decode(binary()) -> {ok, map() | list()} | {error, term()}.

Decode a JSON-RPC request body. The spec includes list() in the success type so the HTTP transport can detect (and reject) JSON-RPC batches.

decode_envelope(L)

-spec decode_envelope(map()) ->
                         {request, Id :: term(), Method :: binary(), Params :: map()} |
                         {notification, Method :: binary(), Params :: map()} |
                         {response, Id :: term(), Result :: term()} |
                         {error, Id :: term(), Code :: integer(), Message :: binary(), Data :: term()} |
                         {invalid, term()}.

Classify a decoded JSON-RPC envelope.

Returns the kind so client and server agree on routing without each having to peek at the same keys.

drive_async_plan(Plan, Timeout)

-spec drive_async_plan(map(), timeout()) -> map().

Drive an {async, AsyncPlan} from handle/2 to completion on the calling process and return a JSON-RPC response map.

Used by transports that don't have their own request/wait machinery (stdio, legacy HTTP). The Streamable HTTP transport drives async plans itself because it needs to record per-session in-flight entries for cancellation routing.

drive_async_plan(Plan, Timeout, AuthInfo)

-spec drive_async_plan(map(), timeout(), term()) -> map().

As drive_async_plan/2, but threads the authenticated principal (AuthInfo, the auth provider's authenticate/2 map) into the tool Ctx under auth_info. Transports that authenticate before driving the plan (the simple HTTP transport) pass it here; callers with no auth provider use the /2 form and auth_info is undefined.

drive_async_plan(Plan, Timeout, AuthInfo, OnSpawn)

-spec drive_async_plan(map(), timeout(), term(), fun((pid() | undefined) -> term())) -> map().

As drive_async_plan/3, telling OnSpawn the worker pid before waiting on it. A caller that must reap the worker if its own peer goes away has no other way to learn the pid: the wait is blocking and the spawn happens inside it.

encode(Response)

-spec encode(map() | [map()]) -> binary().

Encode a JSON-RPC response, or a batch of them.

encode_error(Id, Code, Message)

-spec encode_error(term(), integer(), binary()) -> map().

Build a JSON-RPC error response. Alias of error_response/3.

encode_notification(Method, Params)

-spec encode_notification(binary(), map()) -> map().

Build a JSON-RPC notification envelope (no id).

encode_request(Id, Method, Params)

-spec encode_request(term(), binary(), map()) -> map().

Build a JSON-RPC request envelope.

encode_response(Id, Result)

-spec encode_response(term(), term()) -> map().

Build a JSON-RPC success response.

error_response(Id, Code, Message)

-spec error_response(term(), integer(), binary()) -> map().

Create an error response.

error_response(Id, Code, Message, Meta)

-spec error_response(term(), integer(), binary(), map()) -> map().

finalize(Envelope, Ctx)

-spec finalize(map() | no_response | {async, map()} | {subscribe, map()},
               barrel_mcp_ctx:ctx() | undefined) ->
                  map() | no_response | {async, map()} | {subscribe, map()}.

Stamp the fields a modern result must carry: resultType, and serverInfo in the result's _meta.

Public so transports that build a response envelope themselves (the async tool path in barrel_mcp_http_engine) decorate it the same way. Legacy results, error responses and notifications pass through untouched, as does anything with no context.

format_tool_result_external(Result)

-spec format_tool_result_external(term()) -> [map()].

Format a tool handler's plain return value into the MCP content-block list shape. Public so transports driving async tool calls (HTTP / stdio) can produce identical envelopes.

handle(Request)

-spec handle(map() | list()) -> result().

Handle a JSON-RPC request with default state.

handle(L, State)

-spec handle(map() | list(), map()) -> result().

Handle a JSON-RPC request with state.

Returns one of:

  • map(): a JSON-RPC response envelope ready to encode.
  • [map()]: one envelope per request element of a batch, on the revisions that accept batches.
  • no_response: for inbound notifications, and for a batch of nothing but notifications and responses.
  • {async: AsyncPlan}, for tools/call. The transport spawns the worker via (maps:get(spawn, AsyncPlan))(Ctx) and waits on its mailbox for a tool_result / tool_error / tool_failed / tool_validation_failed / cancelled message.
  • {subscribe, Sub}: for subscriptions/listen, which needs a stream the transport holds open.

A top-level JSON array is a batch. Whether one is accepted depends on the negotiated revision: required at 2025-03-26, accepted at 2024-11-05, and refused with Invalid Request from 2025-06-18 on.

input_required_envelope(Plan, Requests, State, RequestId)

-spec input_required_envelope(map(), map(), term(), term()) -> map().

Turn a handler's {input_required, Requests, State} into the JSON-RPC envelope for it, given the plan that produced the call.

Shared by every transport so an MRTR turn looks the same whichever one served it.

missing_tasks_capability(Id)

-spec missing_tasks_capability(term()) -> map().

The -32021 a client without the tasks extension gets from a tool that requires one (tasks.md "Capability Negotiation").

notification_response()

-spec notification_response() -> no_response.

Return a marker for no response (notifications).

spawn_task_collector(Owner, TaskId, SpawnWorker)

-spec spawn_task_collector(term(), binary(), fun((pid()) -> pid() | undefined)) ->
                              {pid(), pid() | undefined}.

Run a tool's outcome into its task rather than back to the caller, which has already been handed the task id.

Takes the spawn rather than returning a bare collector so the worker cannot be started without the collector being told to watch it: that handoff is what keeps a worker that dies silently from stranding the task, and a second call site would otherwise be free to forget it. undefined when the tool could not be started at all, which the collector settles as no_worker.

success_response(Id, Result)

success_response(Id, Result, Meta)

task_owner(Ctx)

-spec task_owner(barrel_mcp_ctx:ctx()) -> term().

Who a task belongs to, which differs by era.

A legacy task is scoped to its session. A modern request has no session, so it is scoped to the authenticated principal instead; without one that is {principal, undefined}, which is still a real scope and not a wildcard.

tasks_enabled(Ctx)

-spec tasks_enabled(barrel_mcp_ctx:ctx()) -> boolean().

Whether this client opted into receiving task handles.

A server must never hand a task to a client that did not declare the extension: it would poll a method it does not know it can call. Legacy clients negotiated tasks in the handshake instead.