Tools, Resources & Prompts

View Source

The Model Context Protocol defines three core primitives for exposing functionality to AI assistants. barrel_mcp provides a simple, consistent API for all three.

Tools

Tools are functions that the AI can call to perform actions or retrieve information.

Registering a Tool

-module(my_tools).
-export([search/1, calculate/1]).

%% Simple tool returning text
search(Args) ->
    Query = maps:get(<<"query">>, Args),
    %% Return a binary string
    <<"Results for: ", Query/binary>>.

%% Tool returning structured data (auto-converted to JSON)
calculate(Args) ->
    A = maps:get(<<"a">>, Args),
    B = maps:get(<<"b">>, Args),
    Op = maps:get(<<"op">>, Args, <<"add">>),
    Result = case Op of
        <<"add">> -> A + B;
        <<"sub">> -> A - B;
        <<"mul">> -> A * B;
        <<"div">> -> A / B
    end,
    #{<<"result">> => Result, <<"operation">> => Op}.

Register with full schema:

barrel_mcp:reg_tool(<<"search">>, my_tools, search, #{
    description => <<"Search for information">>,
    input_schema => #{
        <<"type">> => <<"object">>,
        <<"properties">> => #{
            <<"query">> => #{
                <<"type">> => <<"string">>,
                <<"description">> => <<"Search query">>
            }
        },
        <<"required">> => [<<"query">>]
    }
}).

barrel_mcp:reg_tool(<<"calculate">>, my_tools, calculate, #{
    description => <<"Perform arithmetic operations">>,
    input_schema => #{
        <<"type">> => <<"object">>,
        <<"properties">> => #{
            <<"a">> => #{<<"type">> => <<"number">>},
            <<"b">> => #{<<"type">> => <<"number">>},
            <<"op">> => #{
                <<"type">> => <<"string">>,
                <<"enum">> => [<<"add">>, <<"sub">>, <<"mul">>, <<"div">>],
                <<"default">> => <<"add">>
            }
        },
        <<"required">> => [<<"a">>, <<"b">>]
    }
}).

Tool Return Values

Tools can return any of:

%% Binary -> single text content block.
text_tool(_Args) ->
    <<"Hello, World!">>.

%% Map -> JSON-encoded text content block.
json_tool(_Args) ->
    #{<<"key">> => <<"value">>, <<"count">> => 42}.

%% List of content blocks -> verbatim.
multi_tool(_Args) ->
    [
        #{<<"type">> => <<"text">>, <<"text">> => <<"First result">>},
        #{<<"type">> => <<"text">>, <<"text">> => <<"Second result">>}
    ].

%% Image content block.
image_tool(_Args) ->
    ImageData = base64:encode(read_image_file()),
    #{
        <<"type">> => <<"image">>,
        <<"data">> => ImageData,
        <<"mimeType">> => <<"image/png">>
    }.

%% Tool-level error: rendered as `{ "isError": true, "content": [...] }'
%% on the wire. Use this for failures that are part of the tool's
%% domain (validation, business rules) rather than infrastructure.
flaky_tool(_Args) ->
    {tool_error, [#{<<"type">> => <<"text">>,
                    <<"text">> => <<"Quota exceeded">>}]}.

%% Structured output: machine-readable data plus optional human-readable
%% content blocks. Surfaces as `structuredContent' on the wire.
%% Pair with the `output_schema' option to validate the data shape.
weather(_Args) ->
    {structured, #{<<"tempF">> => 72, <<"sky">> => <<"clear">>},
     [#{<<"type">> => <<"text">>, <<"text">> => <<"72°F, clear">>}]}.

{tool_error, Content} and {structured, Data, Content} are the recommended shapes. Plain returns still work; raised exceptions are caught and surfaced as a JSON-RPC error to the client.

There is one more, for tools that need something from the user before they can finish. See Asking the client for input.

Async handlers (Ctx-aware)

Tools may be exported as arity 2 instead of arity 1. The second argument is a context map the runtime fills in:

%% (Args, Ctx) -> Result. Ctx holds:
%%   session_id     :: binary() | undefined,
%%   request_id     :: integer() | binary(),
%%   progress_token :: binary() | undefined,
%%   meta           :: map(),  %% inbound _meta from the request
%%   emit_progress  :: fun((Done, Total, Message | undefined) -> ok)
download(Args, Ctx) ->
    Url = maps:get(<<"url">>, Args),
    Emit = maps:get(emit_progress, Ctx),
    Emit(0.0, 1.0, undefined),
    {ok, Body} = fetch(Url),
    Emit(1.0, 1.0, undefined),
    Body.

Resource and prompt handlers may be arity 2 as well. They get the request context rather than the progress and cancel hooks, which is what lets them ask the client for input; see Asking the client for input.

Arity-2 handlers are needed for tools that:

  • emit notifications/progress updates,
  • cooperate with notifications/cancelled (the worker receives {cancel, RequestId} in its mailbox),
  • need the calling session id for server→client primitives,
  • read or echo the request's _meta extension hook (available as maps:get(meta, Ctx, #{})).

Arity-1 handlers continue to work; pick whichever arity you need.

Returning _meta on the response

Tool handlers may attach a _meta map to the response envelope by returning one of the meta-bearing tuples:

  • {result_meta, Result, MetaMap}: plain result + _meta.
  • {structured_meta, Data, Content, MetaMap}: structuredContent + _meta.
  • {tool_error, Content, MetaMap}: error result + _meta.

Empty maps are omitted from the wire. The plain {tool_error, Content} and {structured, Data, Content} shapes still work; _meta is opt-in.

Asking the client for input

A tool that needs something from the user (a confirmation, a value, the model's help, the client's roots) returns instead of blocking:

{input_required, InputRequests, State}

InputRequests is #{Key => #{method => M, params => P}}, where M is <<"elicitation/create">>, <<"sampling/createMessage">> or <<"roots/list">>. State is any Erlang term. The framework seals it and the client echoes it back; handlers never see the blob.

The client answers and re-issues the same call. Your handler runs again from the top, so recover what it knew and read the answers:

confirm_delete(Args, Ctx) ->
    case barrel_mcp:input(Ctx, <<"confirm">>) of
        none ->
            %% First attempt: ask.
            {input_required,
             #{<<"confirm">> => #{
                 method => <<"elicitation/create">>,
                 params => #{
                     <<"message">> => <<"Delete every matching row?">>,
                     %% `properties' is required. A client validating
                     %% against the spec schema rejects the request
                     %% without it.
                     <<"requestedSchema">> => #{
                         <<"type">> => <<"object">>,
                         <<"properties">> => #{
                             <<"ok">> => #{<<"type">> => <<"boolean">>}
                         }
                     }
                 }}},
             maps:get(<<"table">>, Args)};
        {ok, #{<<"action">> := <<"accept">>}} ->
            {ok, Table} = barrel_mcp:request_state(Ctx),
            do_delete(Table);
        {ok, _Declined} ->
            {tool_error, [#{<<"type">> => <<"text">>,
                            <<"text">> => <<"Cancelled">>}]}
    end.

barrel_mcp:input/2 unwraps exactly as far as the equivalent blocking call does:

Request methodReturns
elicitation/create{ok, ElicitResult} or none
sampling/createMessage{ok, Result, Usage} or none
roots/list{ok, [Root]} or none

A client may retry carrying the state but no answers, which is not an error: read none and ask again.

Check before you ask, because the framework refuses to emit a request for a capability the client never declared and the call fails with -32021:

case barrel_mcp:client_supports(Ctx, elicitation) of
    true  -> ask(Args, Ctx);
    false -> use_a_default(Args)
end.

This works from a prompt or resource handler too. Register it as arity 2 and it gets the same Ctx:

gated_resource(_Args, Ctx) ->
    case barrel_mcp:input(Ctx, <<"who">>) of
        none  -> ask_who();
        {ok, Response} -> render(Response)
    end.

Notes:

  • Arity-2 handlers only, for any of the three. An arity-1 handler has no Ctx to read the answer from.
  • Modern era only. Returning it to a legacy client is a programming error, logged and answered as an internal error; that client has the blocking calls (barrel_mcp:elicit_create/3 and friends) instead.
  • A state that fails verification is rejected with -32602 and never re-prompted, so a stale or tampered one cannot drive a loop.
  • Clients bound the number of rounds. barrel_mcp_client allows 5 by default (max_input_rounds), so a tool that always asks never finishes.

Long-running tools (tasks)

Set task_support on reg_tool/4 to say whether a call may become a task: forbidden (the default), optional, or required. long_running => true is the older spelling of optional.

barrel_mcp:reg_tool(<<"render_video">>, my_tools, render_video, #{
    task_support => optional,
    description => <<"Render a video on the GPU farm">>
}).

What the client then sees depends on its era and on whether it declared the tasks extension:

  • A legacy client that negotiated tasks gets a taskId at once; the handler keeps running and the runtime stores its outcome on the task. It tracks progress with tasks/get, tasks/list or notifications/tasks/status.
  • A modern client that declared the extension gets the result in place when the handler answers within task_inline_ms (application environment, default 100 ms), and a task handle otherwise.
  • A client that did not declare the extension gets the tool run synchronously for optional, and error -32021 for required.

The full mode table, and where each transport decides it, is in Server Internals. The same handler shape (arity 1 or arity 2) applies, over stdio too, and task-backed handlers may emit progress like any other tool.

Schema validation

Two opt-in flags on reg_tool/4:

barrel_mcp:reg_tool(<<"search">>, my_tools, search, #{
    input_schema => #{<<"type">> => <<"object">>,
                       <<"required">> => [<<"query">>]},
    output_schema => #{<<"type">> => <<"object">>,
                        <<"required">> => [<<"results">>]},
    validate_input  => true,
    validate_output => true
}).

validate_input checks the call's arguments against input_schema before invoking the handler. validate_output checks the structured Data from {structured, Data, _} returns against output_schema. Failures surface to the client as isError: true content. The validator subset is documented under barrel_mcp_schema.

Metadata: title and icons

Every registration accepts a human-readable title and a list of icons (each #{src, sizes?, mime_type?}). Both surface in the matching */list response. Empty fields are omitted from the wire.

barrel_mcp:reg_tool(<<"search">>, my_tools, search, #{
    title => <<"Knowledge Base Search">>,
    icons => [#{<<"src">> => <<"https://example.com/icon.png">>,
                 <<"sizes">> => <<"32x32">>}]
}).

Managing Tools

%% List all tools
Tools = barrel_mcp:list_tools().

%% Call a tool locally (for testing)
Result = barrel_mcp:call_tool(<<"search">>, #{<<"query">> => <<"test">>}).

%% Unregister a tool
barrel_mcp:unreg_tool(<<"search">>).

Resources

Resources expose data that the AI can read, like files or configuration.

Registering a Resource

-module(my_resources).
-export([get_config/1, get_users/1]).

%% Text resource
get_config(_Args) ->
    <<"app_name=MyApp\nversion=1.0.0\ndebug=false">>.

%% JSON resource (map auto-encoded)
get_users(_Args) ->
    #{
        <<"users">> => [
            #{<<"id">> => 1, <<"name">> => <<"Alice">>},
            #{<<"id">> => 2, <<"name">> => <<"Bob">>}
        ]
    }.

Register resources:

barrel_mcp:reg_resource(<<"config">>, my_resources, get_config, #{
    name => <<"Application Configuration">>,
    uri => <<"config://app/settings">>,
    description => <<"Current application settings">>,
    mime_type => <<"text/plain">>
}).

barrel_mcp:reg_resource(<<"users">>, my_resources, get_users, #{
    name => <<"User List">>,
    uri => <<"app://users/list">>,
    description => <<"All registered users">>,
    mime_type => <<"application/json">>
}).

Binary Resources

For binary data like images or files:

get_logo(_Args) ->
    #{
        blob => read_file("logo.png"),
        mimeType => <<"image/png">>
    }.

Register:

barrel_mcp:reg_resource(<<"logo">>, my_resources, get_logo, #{
    name => <<"Company Logo">>,
    uri => <<"assets://logo">>,
    mime_type => <<"image/png">>
}).

Managing Resources

%% List all resources
Resources = barrel_mcp:list_resources().

%% Read a resource locally
Content = barrel_mcp:read_resource(<<"config">>).

%% Unregister
barrel_mcp:unreg_resource(<<"config">>).

Prompts

Prompts are pre-defined conversation templates that the AI can use.

Registering a Prompt

-module(my_prompts).
-export([summarize/1, translate/1]).

summarize(Args) ->
    Content = maps:get(<<"content">>, Args),
    Style = maps:get(<<"style">>, Args, <<"concise">>),
    #{
        description => <<"Summarize the provided content">>,
        messages => [
            #{
                role => <<"user">>,
                content => #{
                    type => <<"text">>,
                    text => <<"Please summarize the following in a ",
                              Style/binary, " style:\n\n", Content/binary>>
                }
            }
        ]
    }.

translate(Args) ->
    Text = maps:get(<<"text">>, Args),
    TargetLang = maps:get(<<"target_language">>, Args),
    #{
        description => <<"Translate text to another language">>,
        messages => [
            #{
                role => <<"system">>,
                content => #{
                    type => <<"text">>,
                    text => <<"You are a professional translator.">>
                }
            },
            #{
                role => <<"user">>,
                content => #{
                    type => <<"text">>,
                    text => <<"Translate the following to ",
                              TargetLang/binary, ":\n\n", Text/binary>>
                }
            }
        ]
    }.

Register prompts:

barrel_mcp:reg_prompt(<<"summarize">>, my_prompts, summarize, #{
    description => <<"Summarize content in various styles">>,
    arguments => [
        #{
            name => <<"content">>,
            description => <<"The content to summarize">>,
            required => true
        },
        #{
            name => <<"style">>,
            description => <<"Summary style: concise, detailed, or bullet">>,
            required => false
        }
    ]
}).

barrel_mcp:reg_prompt(<<"translate">>, my_prompts, translate, #{
    description => <<"Translate text to another language">>,
    arguments => [
        #{
            name => <<"text">>,
            description => <<"Text to translate">>,
            required => true
        },
        #{
            name => <<"target_language">>,
            description => <<"Target language (e.g., Spanish, French)">>,
            required => true
        }
    ]
}).

Multi-Turn Prompts

Create prompts with conversation history:

code_review(Args) ->
    Code = maps:get(<<"code">>, Args),
    Language = maps:get(<<"language">>, Args, <<"unknown">>),
    #{
        description => <<"Interactive code review session">>,
        messages => [
            #{
                role => <<"system">>,
                content => #{
                    type => <<"text">>,
                    text => <<"You are a senior ", Language/binary,
                              " developer performing a code review.">>
                }
            },
            #{
                role => <<"user">>,
                content => #{
                    type => <<"text">>,
                    text => <<"Please review this code:\n\n```",
                              Language/binary, "\n", Code/binary, "\n```">>
                }
            },
            #{
                role => <<"assistant">>,
                content => #{
                    type => <<"text">>,
                    text => <<"I'll analyze this code for:\n",
                              "1. Correctness\n2. Performance\n",
                              "3. Security\n4. Best practices\n\n",
                              "Let me start the review...">>
                }
            }
        ]
    }.

Managing Prompts

%% List all prompts
Prompts = barrel_mcp:list_prompts().

%% Get a prompt with arguments
PromptResult = barrel_mcp:get_prompt(<<"summarize">>, #{
    <<"content">> => <<"Long text here...">>,
    <<"style">> => <<"bullet">>
}).

%% Unregister
barrel_mcp:unreg_prompt(<<"summarize">>).

Resource Templates

URI templates (RFC 6570) advertise families of resources without enumerating every URI. Register one handler per template:

barrel_mcp:reg_resource_template(<<"file">>, my_resources, read_file_uri, #{
    name => <<"File Reader">>,
    uri_template => <<"file:///{path}">>,
    description => <<"Read any file on the local FS">>,
    mime_type => <<"text/plain">>
}).

resources/read against a templated URI is matched and routed to the template handler automatically, RFC 6570 Level 1 substitutions (simple {var} expansion) cover what the spec's reference examples use. The substituted variables are merged into the handler's Args map under their template names:

%% file:///{path} matched against file:///etc/hosts
read_file_uri(#{<<"path">> := Path} = _Args) ->
    file:read_file(<<"/", Path/binary>>).

barrel_mcp:list_resource_templates/0 lists registrations. Templates also surface on the wire via resources/templates/list.

Completions

Completion handlers suggest values for a prompt argument or a resource-template argument. Register them keyed by the parent plus the argument name:

suggest_lengths(<<"sh">>, _Ctx) -> {ok, [<<"short">>]};
suggest_lengths(_, _Ctx)        -> {ok, [<<"short">>, <<"medium">>, <<"long">>]}.

barrel_mcp:reg_completion(
    {prompt, <<"summarize">>, <<"length">>},
    my_completions, suggest_lengths, #{}).

Handlers are arity 2: (PartialValue, Ctx). Return one of:

  • {ok, [Suggestion]}: full list.
  • {ok, [Suggestion], #{has_more => true}}: more available; the client can issue another completion/complete to drill in.

The completions capability is advertised in initialize as soon as at least one completion handler is registered.

Tasks (long-running operations)

The barrel_mcp_tasks module backs the tasks/list, tasks/get, tasks/cancel, tasks/result and tasks/update MCP methods, plus the notifications/tasks/status notifications. Which of those a client can call depends on its era: tasks/list and tasks/result are legacy, tasks/update is modern, and tasks/get and tasks/cancel are both.

A modern client only gets a task handle if it declared the io.modelcontextprotocol/tasks extension. One that did not gets the same tool run synchronously, since it would otherwise be told to poll a method it does not know it can call. Legacy clients negotiated tasks in the handshake and are unaffected.

Ownership differs too. A legacy task belongs to its session; a modern request has no session, so its tasks belong to the authenticated principal. Either way one client cannot read another's task.

You don't usually call this module directly: registering a tool with long_running => true (see above) wires the lifecycle for you. The collector process records the worker's eventual outcome as a task transition (workingcompleted | failed | cancelled) and emits the matching notification on the session's SSE channel. Status values match the MCP 2025-11-25 wire vocabulary, and createdAt / lastUpdatedAt are emitted as RFC 3339 strings.

Hosts that drive their own long-running operations outside the tool path can use the public API:

{ok, TaskId} = barrel_mcp_tasks:create(SessionId, <<"reindex">>, #{}),
%% later:
ok = barrel_mcp_tasks:finish(SessionId, TaskId, #{<<"reindexed">> => 12000}).

Tasks are evicted from memory one hour after they reach a terminal state (completed / failed / cancelled).

Server → client notifications

The façades below are era-neutral: each fans out to legacy session SSE streams and to modern subscriptions/listen streams alike, so server code does not change between eras.

NotificationFaçade
notifications/resources/updatedbarrel_mcp:notify_resource_updated/1,2. Legacy subscriptions are scoped to the calling Mcp-Session-Id: when a client re-initializes (or its session expires) the new session id has no carry-over subscriptions, and the host must subscribe again. This matches the spec's session-lifecycle model. Modern subscriptions live for as long as the subscriptions/listen stream is open and end with it.
notifications/tools/list_changed<br>notifications/resources/list_changed<br>notifications/prompts/list_changedbarrel_mcp:notify_list_changed/1 (tool, resource, prompt). reg_tool/4/unreg_tool/1 and friends emit it automatically; call the façade if you mutate the catalogue out of band.
notifications/progressbarrel_mcp:notify_progress/3,4 (or via Ctx from an arity-2 tool handler).
notifications/tasks/statusEmitted by barrel_mcp_tasks on every status transition.
notifications/message (logging)Emitted by barrel_mcp_session when a host calls logger-style helpers.

Handler Best Practices

1. Validate Input

my_tool(Args) ->
    case maps:find(<<"required_field">>, Args) of
        {ok, Value} when is_binary(Value), Value =/= <<>> ->
            process(Value);
        _ ->
            error({invalid_input, <<"required_field is mandatory">>})
    end.

2. Handle Errors Gracefully

my_tool(Args) ->
    try
        do_risky_operation(Args)
    catch
        error:Reason ->
            %% Log for debugging
            logger:error("Tool failed: ~p", [Reason]),
            %% Return user-friendly error
            error({tool_error, <<"Operation failed, please try again">>})
    end.

3. Use Authentication Info

my_tool(Args) ->
    case maps:get(<<"_auth">>, Args, undefined) of
        #{subject := UserId, scopes := Scopes} ->
            case lists:member(<<"admin">>, Scopes) of
                true -> admin_operation(UserId, Args);
                false -> user_operation(UserId, Args)
            end;
        undefined ->
            public_operation(Args)
    end.

4. Return Consistent Types

Pick one return type per tool and document it:

%% @doc Always returns a map with status and data
my_tool(Args) ->
    case process(Args) of
        {ok, Data} ->
            #{<<"status">> => <<"success">>, <<"data">> => Data};
        {error, Reason} ->
            #{<<"status">> => <<"error">>, <<"message">> => Reason}
    end.