barrel_mcp_protocol (barrel_mcp v3.0.1)
View SourceThe 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/4→dispatch_versioned/4→dispatch_valid/4;serves/2says which methods an era has;finalize/2decorates 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_requiredrounds and the sealedrequest_state. - Request handlers: one
handle_request/4clause 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
Functions
-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 a JSON-RPC request body. The spec includes list() in the success type so the HTTP transport can detect (and reject) JSON-RPC batches.
-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 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.
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.
-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 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.
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}, fortools/call. The transport spawns the worker via(maps:get(spawn, AsyncPlan))(Ctx)and waits on its mailbox for atool_result/tool_error/tool_failed/tool_validation_failed/cancelledmessage.{subscribe, Sub}: forsubscriptions/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.
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.
The -32021 a client without the tasks extension gets from a tool that requires one (tasks.md "Capability Negotiation").
-spec notification_response() -> no_response.
Return a marker for no response (notifications).
-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.
-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.
-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.