Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [4.1.0] - 2026-09-23

Everything is additive. A server whose auth provider does not export
`visible/4` lists exactly what it listed on 4.0.x, on every transport.

### Added

- Optional `barrel_mcp_auth` callback `visible/4`: whether a caller
sees a tool, resource, resource template or prompt in a list
response. Entries are filtered before pagination. Calls and reads
are not affected. A raise or a non-boolean hides the entry.
- `barrel_mcp_auth:visible/4`, the dispatch, and
`barrel_mcp_ctx:auth_config/1`.
- `barrel_mcp_auth_custom` forwards `visible/4` to its module when the
module exports it.
- `barrel_mcp_client:call_tool/4` accepts `meta => map()`, sent as
`params._meta` and merged with `progress_token`.

### Fixed

- The simple HTTP transport now passes the caller's auth info to the
protocol core. Modern tasks and `requestState` created over it are
owned by the caller's principal instead of `anonymous`.

## [4.0.0] - 2026-09-20

### Upgrading from 3.0.1
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Add to your `rebar.config`:
{deps, [
{barrel_mcp,
{git, "https://github.com/barrel-platform/barrel_mcp.git",
{tag, "v4.0.0"}}}
{tag, "v4.1.0"}}}
]}.
```

Expand Down Expand Up @@ -678,7 +678,7 @@ Your application's entry point should call `barrel_mcp:start_stdio()`.

## Pending features

Nothing is pending as of 4.0.0: the library implements every released
Nothing is pending as of 4.1.0: the library implements every released
revision of the specification, and both halves of the `ext-auth`
extension. [`docs/pending-features.md`](docs/pending-features.md)
records what is deliberately out of scope. Open an issue if you want
Expand Down
2 changes: 1 addition & 1 deletion docs/pending-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Design notes for features that are **not yet implemented** and likely
worth doing. Each entry is sized to be picked up as one focused PR.

**Nothing is pending as of 4.0.0.** The last entry here, Dynamic Client
**Nothing is pending as of 4.1.0.** The last entry here, Dynamic Client
Registration (RFC 7591), shipped and has since been deprecated by the
specification in favour of Client ID Metadata Documents, which shipped
alongside it. See [Client registration](../guides/authentication.md#client-registration).
Expand Down
15 changes: 15 additions & 0 deletions guides/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,21 @@ introspect_token(Token, #{introspect_url := Url} = State) ->
end.
```

### Per-caller listings

Export the optional `visible/4` callback when callers of one endpoint
should list different tools, resources, templates or prompts:

```erlang
visible(Kind, {Name, _Handler}, #{scopes := Scopes}, _State) ->
lists:member(<<(atom_to_binary(Kind))/binary, ":", Name/binary>>, Scopes).
```

It filters list responses only, before pagination; calls are not
checked. See
[Show each caller its own entries](custom-authentication.md#show-each-caller-its-own-entries)
for the notes, including why `cache_scope` must stay `private`.

## Accessing Auth Info in Handlers

After successful authentication, auth info is available in the request:
Expand Down
3 changes: 3 additions & 0 deletions guides/building-a-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,9 @@ When `progress_token` is supplied, the calling process receives one
the server emits, until the request settles (response, cancel, or
timeout).

`meta => Map` is sent as `params._meta` (for example an idempotency
key), merged with `progress_token` when both are given.

The full echo-client example lives in
[`examples/echo_client/src/echo_client.erl`](https://github.com/barrel-platform/barrel_mcp/tree/main/examples/echo_client/src/echo_client.erl).

Expand Down
16 changes: 16 additions & 0 deletions guides/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ After connecting, initialize to exchange capabilities:
%% }
```

### Send `_meta` with a call

Use the `meta` option when the server needs request metadata that is
not an argument, such as an idempotency key. It is sent as
`params._meta`, and the tool handler receives it as `meta` in its
ctx.

```erlang
{ok, Result} = barrel_mcp_client:call_tool(Pid, <<"charge">>, Args, #{
meta => #{<<"idempotencyKey">> => <<"order-42">>}
}).
```

A `progress_token` given together with `meta` is merged into it, and
the token wins if `meta` also sets `progressToken`.

### Error Handling

```erlang
Expand Down
39 changes: 39 additions & 0 deletions guides/custom-authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,45 @@ Your `authenticate/2` function should return a map with:

Additional keys are preserved in the `claims` field.

## Show each caller its own entries

Your module can decide which tools, resources, resource templates and
prompts a caller sees in list responses. You need this when one
endpoint serves callers whose tokens reach different things, and each
should list only its own.

Export `visible/4` next to `authenticate/2`. It receives the kind, the
registry entry, the auth info your `authenticate/2` returned
(normalised to `#{subject, scopes, claims}`, plus `principal`), and
your module state:

```erlang
-module(my_auth).
-export([init/1, authenticate/2, visible/4]).

visible(tool, {Name, _Handler}, #{claims := #{tools := Allowed}}, _State) ->
lists:member(Name, Allowed);
visible(_Kind, _Entry, _AuthInfo, _State) ->
true.
```

`Kind` is `tool`, `resource`, `resource_template` or `prompt`; `Entry`
is `{Name, Handler}` as `barrel_mcp_registry:all/1` returns it.

Notes:

- Hiding is not an access check. `tools/call`, `resources/read` and
`prompts/get` still reach a hidden entry, so its handler must refuse
a caller that may not use it.
- Entries are filtered before pagination, so cursors stay valid and
every page is full.
- Anything but `true`, including a raise, hides the entry and logs a
warning.
- Keep the `cache_scope` application setting at its default
`private`. `public` lets a shared cache serve one caller's list to
another.
- Without `visible/4` every caller sees every entry, as before.

## Example: barrel_memory Integration

Here's how barrel_memory uses custom auth with its existing key system:
Expand Down
5 changes: 5 additions & 0 deletions guides/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ deployment, and connects to both. See the
- Providers: `barrel_mcp_auth_bearer`, `barrel_mcp_auth_apikey`,
`barrel_mcp_auth_basic`, `barrel_mcp_auth_none`,
`barrel_mcp_auth_custom`.
- Per-caller listings: a provider's optional `visible/4` decides
which tools, resources, templates and prompts a caller lists,
filtered before pagination. See `custom-authentication.md`.
- Hashing: `barrel_mcp_auth_basic:hash_password/1,2` defaults to
PBKDF2-SHA256 (100k iterations, 16-byte salt).
`barrel_mcp_auth_apikey:hash_key/2` produces a peppered HMAC-SHA-256
Expand Down Expand Up @@ -230,6 +233,8 @@ by the spec.
- Progress: pass `progress_token` to `call_tool/4` and the caller
receives `{mcp_progress, Token, Params}` for every matching
`notifications/progress` until the request settles.
- Request `_meta`: pass `meta => Map` to `call_tool/4` and it is
sent as `params._meta`, merged with any `progress_token`.
- Periodic ping: opt-in via `ping_interval` (and
`ping_failure_threshold`) in the connect spec; the connection is
closed with reason `ping_failed` after the configured number of
Expand Down
2 changes: 1 addition & 1 deletion src/barrel_mcp.app.src
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{application, barrel_mcp, [
{description, "MCP Protocol Library for Erlang"},
{vsn, "4.0.0"},
{vsn, "4.1.0"},
{registered, [
barrel_mcp_registry,
barrel_mcp_sup,
Expand Down
51 changes: 49 additions & 2 deletions src/barrel_mcp_auth.erl
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@
extract_api_key/2,
extract_basic_auth/1,
auth_headers/1,
principal/2
principal/2,
visible/4
]).

%% Types
Expand Down Expand Up @@ -156,7 +157,17 @@
-callback principal(AuthInfo :: auth_info(), State :: term()) ->
{ok, term()} | {error, no_subject}.

-optional_callbacks([init/1, auth_headers/1, principal/2]).
%% Optional: whether this caller sees a registry entry in a list
%% response. Hiding is not an access check: the handler still refuses.
%% Without this callback every caller sees every entry.
-callback visible(
Kind :: tool | resource | resource_template | prompt,
Entry :: {Name :: binary(), Handler :: map()},
AuthInfo :: auth_info(),
State :: term()
) -> boolean().

-optional_callbacks([init/1, auth_headers/1, principal/2, visible/4]).

%%====================================================================
%% API
Expand Down Expand Up @@ -224,6 +235,42 @@ with_principal(#{provider := Provider} = Config, ProviderState, AuthInfo) ->
{error, no_subject} -> {error, unauthorized}
end.

%% @doc Whether the caller behind `AuthInfo' sees `Entry' in a list
%% response, as the provider's optional `visible/4' decides.
%%
%% `true' without a provider, without a caller, or when the provider
%% does not export the callback. Anything but `true', including a
%% raise, hides the entry: a broken scope check must not reveal what it
%% was meant to hide.
-spec visible(
auth_config() | undefined,
tool | resource | resource_template | prompt,
{binary(), map()},
auth_info() | undefined
) -> boolean().
visible(undefined, _Kind, _Entry, _AuthInfo) ->
true;
visible(_AuthConfig, _Kind, _Entry, undefined) ->
true;
visible(#{provider := Provider} = Config, Kind, {Name, _} = Entry, AuthInfo) ->
case exported(Provider, visible, 4) of
false ->
true;
true ->
State = maps:get(provider_state, Config, undefined),
try Provider:visible(Kind, Entry, AuthInfo, State) of
true -> true;
_ -> false
catch
Class:Reason ->
logger:warning(
"barrel_mcp ~p:visible/4 raised on ~p ~ts, entry hidden: ~p:~p",
[Provider, Kind, Name, Class, Reason]
),
false
end
end.

subject_of(AuthInfo) ->
case maps:get(subject, AuthInfo, undefined) of
S when is_binary(S), S =/= <<>> -> S;
Expand Down
16 changes: 15 additions & 1 deletion src/barrel_mcp_auth_custom.erl
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
%%% <li>`authenticate(Token, State) -> {ok, AuthInfo, State} | {error, Reason, State}'</li>
%%% </ul>
%%%
%%% It may also export `visible(Kind, {Name, Handler}, AuthInfo, State)
%%% -> boolean()' to choose which registry entries a caller sees in list
%%% responses; see `barrel_mcp_auth:visible/4'.
%%%
%%% == Usage ==
%%%
%%% ```
Expand Down Expand Up @@ -51,7 +55,7 @@

-behaviour(barrel_mcp_auth).

-export([init/1, authenticate/2, challenge/2]).
-export([init/1, authenticate/2, challenge/2, visible/4]).

%%====================================================================
%% barrel_mcp_auth callbacks
Expand Down Expand Up @@ -96,6 +100,16 @@ authenticate(Request, #{module := Module, module_state := ModuleState}) ->
challenge(_Reason, _State) ->
{401, #{<<"www-authenticate">> => <<"Bearer realm=\"mcp\"">>}, <<>>}.

%% @doc Ask the custom module whether this caller sees a registry entry,
%% when it exports `visible/4'. Every entry is visible otherwise.
-spec visible(atom(), {binary(), map()}, map(), map()) -> boolean().
visible(Kind, Entry, AuthInfo, #{module := Module, module_state := ModuleState}) ->
_ = code:ensure_loaded(Module),
case erlang:function_exported(Module, visible, 4) of
true -> Module:visible(Kind, Entry, AuthInfo, ModuleState);
false -> true
end.

%%====================================================================
%% Internal functions
%%====================================================================
Expand Down
19 changes: 16 additions & 3 deletions src/barrel_mcp_client.erl
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,19 @@ call_tool(Pid, Name, Args) ->
%% request settles.</li>
%% <li>`{timeout: Ms}', override the per-request timeout
%% (`request_timeout' from the connect spec, default 30000).</li>
%% <li>`{meta: Map}', sent as `params._meta', which the tool handler
%% receives as `meta' in its ctx. A `progress_token' is merged
%% into it and wins a clash on `progressToken'.</li>
%% </ul>
-spec call_tool(pid(), binary(), map(), map()) -> {ok, map()} | {error, term()}.
call_tool(Pid, Name, Args, Opts) ->
Params0 = #{<<"name">> => Name, <<"arguments">> => Args},
Params0 =
case maps:get(meta, Opts, undefined) of
Meta when is_map(Meta), map_size(Meta) > 0 ->
#{<<"name">> => Name, <<"arguments">> => Args, <<"_meta">> => Meta};
_ ->
#{<<"name">> => Name, <<"arguments">> => Args}
end,
Params = maybe_attach_progress_token(Params0, Opts),
request(Pid, <<"tools/call">>, Params, request_timeout(Opts)).

Expand Down Expand Up @@ -1818,8 +1827,12 @@ request_timeout(Opts) ->

maybe_attach_progress_token(Params, Opts) ->
case maps:get(progress_token, Opts, undefined) of
undefined -> Params;
Tok -> Params#{<<"_meta">> => #{<<"progressToken">> => Tok}}
undefined ->
Params;
Tok ->
Params#{
<<"_meta">> => (maps:get(<<"_meta">>, Params, #{}))#{<<"progressToken">> => Tok}
}
end.

%% A legacy request carries only what the caller put in `_meta'; a
Expand Down
25 changes: 20 additions & 5 deletions src/barrel_mcp_ctx.erl
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
auth_info/1,
principal/1,
streaming/1,
meta/1
meta/1,
auth_config/1
]).

-type era() :: modern | legacy.
Expand All @@ -68,7 +69,10 @@
client_capabilities => map(),
%% The revision the transport itself declares for this request, from
%% `MCP-Protocol-Version'. Used only to decide the era.
transport_version => binary() | undefined
transport_version => binary() | undefined,
%% The endpoint's auth provider and its state, so a list response
%% can ask the provider what this caller sees.
auth_config => map() | undefined
}.

-type ctx() :: #{
Expand All @@ -82,7 +86,8 @@
session_id := binary() | undefined,
auth_info := term(),
streaming := boolean(),
meta := map()
meta := map(),
auth_config => map()
}.

-export_type([ctx/0, era/0, extra/0]).
Expand All @@ -104,7 +109,7 @@ from_request(Request, Extra) when is_map(Request), is_map(Extra) ->
Params = as_map(maps:get(<<"params">>, Request, #{})),
Meta = as_map(maps:get(<<"_meta">>, Params, #{})),
Era = classify(Method, Meta, maps:get(transport_version, Extra, undefined)),
#{
Ctx = #{
era => Era,
protocol_version => version_of(Era, Meta, Extra),
client_info => as_map_or_undefined(
Expand All @@ -122,7 +127,11 @@ from_request(Request, Extra) when is_map(Request), is_map(Extra) ->
auth_info => maps:get(auth_info, Extra, undefined),
streaming => as_boolean(maps:get(streaming, Extra, false)),
meta => Meta
}.
},
case Extra of
#{auth_config := AuthConfig} when is_map(AuthConfig) -> Ctx#{auth_config => AuthConfig};
_ -> Ctx
end.

%% Either signal puts a request in the modern era: the `_meta' key it
%% carries, or the revision the transport declares for it. Taking only
Expand Down Expand Up @@ -321,6 +330,12 @@ session_id(#{session_id := S}) -> S.
-spec auth_info(ctx()) -> term().
auth_info(#{auth_info := A}) -> A.

%% @doc The endpoint's auth config, or `undefined' when the transport
%% has none (stdio).
-spec auth_config(ctx() | map()) -> map() | undefined.
auth_config(#{auth_config := C}) -> C;
auth_config(_) -> undefined.

%% @doc The stable identity behind the credential, as
%% `barrel_mcp_auth:authenticate/3' derived it.
%%
Expand Down
Loading
Loading