Skip to main content

README.md

# barrel_mcp

MCP (Model Context Protocol) library for Erlang. Implements the
MCP specification (protocol `2025-11-25` with downward negotiation
through `2024-11-05`) for both server and client modes, including
the Streamable HTTP transport for Claude Code and any other MCP
client.

## Features

- **Full MCP Protocol**: tools, resources, resource templates
  (with runtime RFC 6570 expansion on `resources/read`),
  prompts, completions, sampling, **tasks** (long-running
  operations), `_meta` extension hook end-to-end, notifications
  (`*/list_changed`, `progress`, `cancelled`,
  `resources/updated`, `tasks/status`, `replay_truncated`).
- **Tool handlers**: arity 1 or arity 2 (`(Args, Ctx)`) with
  `Ctx`-driven progress, cancel, and `_meta` hooks. Return
  shapes: plain content, `{tool_error, ...}` (→ `isError: true`),
  `{structured, Data, ...}` (→ `structuredContent`), or any of
  the meta-bearing variants that attach `_meta` to the response.
- **Schema validation**: opt-in `validate_input` /
  `validate_output` against registered JSON Schemas
  (`barrel_mcp_schema`).
- **Transports**: Streamable HTTP (Claude Code), legacy HTTP,
  stdio (Claude Desktop). The HTTP server is built on `h1`/`h2`
  (HTTP/1.1 + HTTP/2 on one port via ALPN) — no Cowboy. Streamable
  HTTP defaults to `127.0.0.1`, validates `Origin`, and replays SSE
  events via `Last-Event-ID`.
- **Authentication**: bearer (JWT/opaque), API keys (peppered
  HMAC-SHA-256), basic (PBKDF2-SHA256), custom providers.
  Constant-time hash comparison; legacy SHA-256 hex digests still
  verify for one release. RFC 9728 Protected Resource Metadata
  endpoint with spec-correct `WWW-Authenticate` for OAuth client
  auto-discovery.
- **Client library** (`barrel_mcp_client`): supervised
  `gen_statem` with stdio + Streamable HTTP transports, OAuth 2.1
  + PKCE, federation registry (one connection per server id),
  pagination, schema pre-flight.
- **Zero JSON dependency**: uses OTP 27+ built-in `json` module.

## Installation

Add to your `rebar.config`:

```erlang
{deps, [
    {barrel_mcp,
        {git, "https://github.com/barrel-platform/barrel_mcp.git",
              {tag, "v2.0.2"}}}
]}.
```

Track `main` instead of pinning a tag for the latest fixes:

```erlang
{barrel_mcp,
    {git, "https://github.com/barrel-platform/barrel_mcp.git",
          {branch, "main"}}}
```

## Architecture

barrel_mcp uses a supervised gen_statem process to manage the handler registry:

- **Writes** (reg/unreg) go through the gen_statem for atomic operations
- **Reads** (find/all/run) use persistent_term directly for O(1) lookups
- **States**: `not_ready``ready` for flexible initialization
- **Postpone pattern**: Calls in `not_ready` state are postponed until ready

```
┌─────────────────────────────────────────────────────────────────┐
│                       barrel_mcp_sup                             │
│                      (supervisor)                                │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│                    barrel_mcp_registry                           │
│                      (gen_statem)                                │
│                                                                  │
│  States: not_ready ──────────────────► ready                    │
│              │         (self ! ready)                            │
│              │              or                                   │
│              └──── wait for external process ────►               │
│                                                                  │
│  ┌─────────────┐        ┌─────────────────────────────────────┐ │
│  │  ETS Table  │───────►│     persistent_term (read-only)     │ │
│  │ (authority) │  sync  │         O(1) lookups                │ │
│  └─────────────┘        └─────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
         ▲                              │
         │ reg/unreg                    │ find/all/run
         │ (atomic, postponed           │ (lock-free)
         │  if not ready)               │
```

### Configuration

To make the registry wait for an external process before becoming ready:

```erlang
%% In sys.config or application env
{barrel_mcp, [
    {wait_for_proc, my_init_process}  % Wait for this process to be registered
]}.
```

If `wait_for_proc` is not set, the registry becomes ready immediately after init.

## Usage by role

barrel_mcp covers the three MCP roles in one library:

- **server** — exposes tools, resources, prompts to MCP clients.
- **client** — connects to one MCP server, calls tools, reads
  resources, handles server-initiated requests.
- **host (agent)** — drives one or more clients on behalf of an
  LLM; collects each server's tool catalog, hands it to the
  model, routes the model's tool call back through the right
  client.

The three short examples below cover the typical wiring; deeper
guides live under `guides/` (`getting-started.md`,
`tools-resources-prompts.md`, `building-a-client.md`).

### Server — expose a tool over Streamable HTTP

```erlang
-module(my_server).
-export([start/0, search/1]).

start() ->
    {ok, _} = application:ensure_all_started(barrel_mcp),
    ok = barrel_mcp:reg_tool(<<"search">>, ?MODULE, search, #{
        description => <<"Search the index">>,
        input_schema => #{<<"type">> => <<"object">>,
                           <<"required">> => [<<"q">>]}
    }),
    {ok, _} = barrel_mcp:start_http_stream(#{port => 8080,
                                              session_enabled => true}),
    ok.

search(#{<<"q">> := Q}) ->
    iolist_to_binary([<<"results for ">>, Q]).
```

That's a complete MCP server. Point any MCP client (Claude Code,
Claude Desktop via stdio, the `barrel_mcp_client` below, …) at
`http://127.0.0.1:8080/mcp`.

### Client — connect and call a tool

```erlang
client_demo() ->
    {ok, _} = application:ensure_all_started(barrel_mcp),
    {ok, Pid} = barrel_mcp_client:start(#{
        transport => {http, <<"http://127.0.0.1:8080/mcp">>}
    }),
    {ok, Result} = barrel_mcp_client:call_tool(
                     Pid, <<"search">>, #{<<"q">> => <<"hello">>}),
    barrel_mcp_client:close(Pid),
    Result.
```

The transport tuple selects the wire (`{http, Url}`,
`{stdio, [Cmd | Args]}`). Auth and OAuth options live on the same
spec — see `guides/building-a-client.md`.

### Host (agent) — hand many MCP servers to an LLM

```erlang
agent_loop() ->
    {ok, _} = application:ensure_all_started(barrel_mcp),
    {ok, _} = barrel_mcp:start_client(<<"github">>, #{
        transport => {http, <<"https://mcp.github.example/mcp">>},
        auth => {bearer, GhToken}
    }),
    {ok, _} = barrel_mcp:start_client(<<"shell">>, #{
        transport => {stdio, ["mcp-shell-server"]}
    }),
    %% Hand every connected server's tools to the model:
    AnthropicTools = barrel_mcp_agent:to_anthropic(),
    %% ... call the LLM with AnthropicTools and capture the
    %%     tool_use block it returned ...
    Block = ask_llm(AnthropicTools),
    {NsName, Args} = barrel_mcp_tool_format:from_anthropic_call(Block),
    %% Routes "github:..." to the github client, "shell:..." to
    %% the shell client.
    barrel_mcp_agent:call_tool(NsName, Args).
```

`barrel_mcp_agent` namespaces tool names as
`<<"ServerId:ToolName">>` across the federation.
`barrel_mcp_tool_format` translates between MCP tool maps and the
provider shapes (Anthropic Messages API, OpenAI Chat Completions);
swap `to_anthropic/0` and `from_anthropic_call/1` for the OpenAI
counterparts to use a different model. `ask_llm/1` is your own
LLM HTTP call — barrel_mcp does not bundle an LLM SDK.

## Quick Start

### Starting the Application

```erlang
%% Start barrel_mcp application
application:ensure_all_started(barrel_mcp).

%% Wait for registry to be ready (optional, for custom initialization)
ok = barrel_mcp_registry:wait_for_ready().
```

### Registering Tools

```erlang
%% Register a tool
barrel_mcp:reg_tool(<<"search">>, my_module, search, #{
    description => <<"Search for items">>,
    input_schema => #{
        type => <<"object">>,
        properties => #{
            query => #{type => <<"string">>, description => <<"Search query">>},
            limit => #{type => <<"integer">>, default => 10}
        },
        required => [<<"query">>]
    }
}).

%% Your handler function (must accept a map and be exported with arity 1)
-module(my_module).
-export([search/1]).

search(#{<<"query">> := Query} = Args) ->
    Limit = maps:get(<<"limit">>, Args, 10),
    %% Return binary, map, or list of content blocks
    <<"Found results for: ", Query/binary>>.
```

### Registering Resources

```erlang
barrel_mcp:reg_resource(<<"config">>, my_module, get_config, #{
    name => <<"Application Config">>,
    uri => <<"config://app/settings">>,
    description => <<"Application configuration">>,
    mime_type => <<"application/json">>
}).
```

### Registering Prompts

```erlang
barrel_mcp:reg_prompt(<<"summarize">>, my_module, summarize_prompt, #{
    description => <<"Summarize content">>,
    arguments => [
        #{name => <<"content">>, description => <<"Content to summarize">>, required => true},
        #{name => <<"style">>, description => <<"Summary style">>, required => false}
    ]
}).

%% Handler returns prompt messages
summarize_prompt(Args) ->
    Content = maps:get(<<"content">>, Args),
    #{
        description => <<"Summarize the following content">>,
        messages => [
            #{role => <<"user">>, content => #{type => <<"text">>, text => Content}}
        ]
    }.
```

### Starting Streamable HTTP Server (Claude Code)

For Claude Code integration, use the Streamable HTTP transport:

```erlang
%% Start Streamable HTTP server on port 9090
{ok, _} = barrel_mcp:start_http_stream(#{port => 9090}).

%% With API key authentication
{ok, _} = barrel_mcp:start_http_stream(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{
            keys => #{<<"my-key">> => #{subject => <<"user">>}}
        }
    }
}).
```

Then add to Claude Code:

```bash
claude mcp add my-server --transport http http://localhost:9090/mcp \
  --header "X-API-Key: my-key"
```

See `guides/http-stream.md` for full documentation.

### Starting HTTP Server (Legacy)

```erlang
%% Start HTTP server on port 9090
{ok, _} = barrel_mcp:start_http(#{port => 9090}).

%% Or with custom IP binding
{ok, _} = barrel_mcp:start_http(#{port => 9090, ip => {127, 0, 0, 1}}).
```

## Authentication

barrel_mcp provides pluggable authentication following OAuth 2.1 patterns as recommended by the MCP specification. Authentication is optional and configurable per HTTP server.

### Built-in Providers

| Provider | Description |
|----------|-------------|
| `barrel_mcp_auth_none` | No authentication (default) |
| `barrel_mcp_auth_bearer` | Bearer token (JWT or opaque) |
| `barrel_mcp_auth_apikey` | API key authentication |
| `barrel_mcp_auth_basic` | HTTP Basic authentication |
| `barrel_mcp_auth_custom` | Custom auth module (simple interface) |

### Bearer Token (JWT) Authentication

```erlang
%% Start HTTP server with JWT authentication
{ok, _} = barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_bearer,
        provider_opts => #{
            secret => <<"your-jwt-secret-key">>,
            issuer => <<"https://auth.example.com">>,
            audience => <<"https://mcp.example.com">>,
            clock_skew => 60  % seconds
        },
        required_scopes => [<<"mcp:read">>, <<"mcp:write">>]
    }
}).
```

For RS256/ES256 or opaque tokens, use a custom verifier:

```erlang
%% Custom token verifier (e.g., for token introspection)
Verifier = fun(Token) ->
    case my_auth_service:validate(Token) of
        {ok, Claims} -> {ok, Claims};
        error -> {error, invalid_token}
    end
end,

{ok, _} = barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_bearer,
        provider_opts => #{verifier => Verifier}
    }
}).
```

### API Key Authentication

```erlang
%% Simple API key list
{ok, _} = barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{
            keys => #{
                <<"key-abc123">> => #{subject => <<"user1">>, scopes => [<<"read">>]},
                <<"key-xyz789">> => #{subject => <<"user2">>, scopes => [<<"read">>, <<"write">>]}
            }
        }
    }
}).

%% With hashed keys for security (recommended for production)
HashedKey = barrel_mcp_auth_apikey:hash_key(<<"my-secret-key">>),
{ok, _} = barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_apikey,
        provider_opts => #{
            keys => #{HashedKey => #{subject => <<"user1">>}},
            hash_keys => true
        }
    }
}).
```

### Basic Authentication

```erlang
%% Simple username/password
{ok, _} = barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_basic,
        provider_opts => #{
            credentials => #{
                <<"admin">> => <<"password123">>,
                <<"user">> => <<"secret">>
            },
            realm => <<"MCP Server">>
        }
    }
}).

%% With hashed passwords (recommended)
HashedPwd = barrel_mcp_auth_basic:hash_password(<<"my-password">>),
{ok, _} = barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_basic,
        provider_opts => #{
            credentials => #{<<"admin">> => HashedPwd},
            hash_passwords => true
        }
    }
}).
```

### Custom Authentication (Simple Interface)

For integrating with existing auth systems, use `barrel_mcp_auth_custom` with a simple two-function module:

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

init(_Opts) ->
    {ok, #{}}.

authenticate(Token, State) ->
    case my_key_store:validate(Token) of
        {ok, Info} ->
            {ok, #{subject => Info}, State};
        error ->
            {error, invalid_token, State}
    end.
```

Configure it:

```erlang
{ok, _} = barrel_mcp:start_http(#{
    port => 9090,
    auth => #{
        provider => barrel_mcp_auth_custom,
        provider_opts => #{
            module => my_auth
        }
    }
}).
```

See `guides/custom-authentication.md` for full documentation.

### Custom Authentication Provider (Full Behaviour)

For more control, implement the full `barrel_mcp_auth` behaviour:

```erlang
-module(my_auth_provider).
-behaviour(barrel_mcp_auth).

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

init(Opts) ->
    {ok, Opts}.

authenticate(Request, State) ->
    Headers = maps:get(headers, Request, #{}),
    case barrel_mcp_auth:extract_bearer_token(Headers) of
        {ok, Token} ->
            %% Your validation logic
            case validate_with_my_service(Token) of
                {ok, User} ->
                    {ok, #{
                        subject => User,
                        scopes => [<<"read">>],
                        claims => #{}
                    }};
                error ->
                    {error, invalid_token}
            end;
        {error, no_token} ->
            {error, unauthorized}
    end.

challenge(Reason, _State) ->
    {401, #{<<"www-authenticate">> => <<"Bearer realm=\"mcp\"">>}, <<>>}.
```

### Accessing Auth Info in Handlers

Authentication info is available in the request context:

```erlang
my_tool_handler(Args) ->
    %% Auth info is passed in the _auth key
    case maps:get(<<"_auth">>, Args, undefined) of
        undefined ->
            <<"No auth info">>;
        AuthInfo ->
            Subject = maps:get(subject, AuthInfo),
            <<"Hello ", Subject/binary>>
    end.
```

### Starting stdio Server (for Claude Desktop)

```erlang
%% This blocks and handles MCP over stdin/stdout
barrel_mcp:start_stdio().
```

### Using as Client

`barrel_mcp_client` is a `gen_statem`. Start it, wait for the
handshake to complete, call tools.

```erl
{ok, Pid} = barrel_mcp_client:start_link(#{
    transport => {http, <<"http://localhost:9090/mcp">>}
}),
{ok, Tools}  = barrel_mcp_client:list_tools(Pid),
{ok, Result} = barrel_mcp_client:call_tool(Pid, <<"search">>,
                                           #{<<"query">> => <<"hello">>}),
ok = barrel_mcp_client:close(Pid).
```

For the full task-oriented walkthrough — transport choice, auth,
OAuth, server-to-client handlers, federation, schema validation —
see [Building a client](guides/building-a-client.md). For
architecture and behaviour contracts, see
[Internals](guides/internals.md). Three runnable examples live
under [`examples/` on
GitHub](https://github.com/barrel-platform/barrel_mcp/tree/main/examples)
(`echo_client`, `sampling_host`, `agent_host`).

## Claude Desktop Configuration

When using barrel_mcp with stdio transport for Claude Desktop:

```json
{
  "mcpServers": {
    "my-server": {
      "command": "/path/to/my_app/bin/my_app",
      "args": ["mcp"]
    }
  }
}
```

Your application's entry point should call `barrel_mcp:start_stdio()`.

## API Reference

### Tools

| Function | Description |
|----------|-------------|
| `barrel_mcp:reg_tool(Name, Module, Function, Opts)` | Register a tool |
| `barrel_mcp:unreg_tool(Name)` | Unregister a tool |
| `barrel_mcp:call_tool(Name, Args)` | Call a tool locally |
| `barrel_mcp:list_tools()` | List all registered tools |

### Resources

| Function | Description |
|----------|-------------|
| `barrel_mcp:reg_resource(Name, Module, Function, Opts)` | Register a resource |
| `barrel_mcp:unreg_resource(Name)` | Unregister a resource |
| `barrel_mcp:read_resource(Name)` | Read a resource locally |
| `barrel_mcp:list_resources()` | List all registered resources |

### Prompts

| Function | Description |
|----------|-------------|
| `barrel_mcp:reg_prompt(Name, Module, Function, Opts)` | Register a prompt |
| `barrel_mcp:unreg_prompt(Name)` | Unregister a prompt |
| `barrel_mcp:get_prompt(Name, Args)` | Get a prompt locally |
| `barrel_mcp:list_prompts()` | List all registered prompts |

### Registry

| Function | Description |
|----------|-------------|
| `barrel_mcp_registry:start_link()` | Start the registry (called by supervisor) |
| `barrel_mcp_registry:wait_for_ready()` | Wait for registry to be ready |
| `barrel_mcp_registry:wait_for_ready(Timeout)` | Wait with custom timeout |

### Server

| Function | Description |
|----------|-------------|
| `barrel_mcp:start_http_stream(Opts)` | Start Streamable HTTP server (Claude Code) |
| `barrel_mcp:stop_http_stream()` | Stop Streamable HTTP server |
| `barrel_mcp:start_http(Opts)` | Start HTTP server (legacy) |
| `barrel_mcp:stop_http()` | Stop HTTP server |
| `barrel_mcp:start_stdio()` | Start stdio server (blocking) |

### Client

| Function | Description |
|----------|-------------|
| `barrel_mcp_client:connect(Opts)` | Connect to MCP server |
| `barrel_mcp_client:initialize(Client)` | Initialize connection |
| `barrel_mcp_client:list_tools(Client)` | List available tools |
| `barrel_mcp_client:call_tool(Client, Name, Args)` | Call a tool |
| `barrel_mcp_client:list_resources(Client)` | List available resources |
| `barrel_mcp_client:read_resource(Client, Uri)` | Read a resource |
| `barrel_mcp_client:list_prompts(Client)` | List available prompts |
| `barrel_mcp_client:get_prompt(Client, Name, Args)` | Get a prompt |
| `barrel_mcp_client:close(Client)` | Close connection |

### Authentication

| Function | Description |
|----------|-------------|
| `barrel_mcp_auth:extract_bearer_token(Headers)` | Extract Bearer token from headers |
| `barrel_mcp_auth:extract_api_key(Headers, Opts)` | Extract API key from headers |
| `barrel_mcp_auth:extract_basic_auth(Headers)` | Extract Basic auth credentials |
| `barrel_mcp_auth_apikey:hash_key(Key)` | Hash an API key (SHA256) |
| `barrel_mcp_auth_basic:hash_password(Password)` | Hash a password (SHA256) |

## MCP Protocol Support

### Supported Methods

**Lifecycle:**
- `initialize` / `initialized`
- `ping`

**Tools:**
- `tools/list`
- `tools/call`

**Resources:**
- `resources/list`
- `resources/read`
- `resources/templates/list`
- `resources/subscribe` / `resources/unsubscribe`

**Prompts:**
- `prompts/list`
- `prompts/get`

**Sampling:**
- `sampling/createMessage`

**Logging:**
- `logging/setLevel`

## Pending features

Design notes and scoped sketches for things not yet implemented
live in [`docs/pending-features.md`](docs/pending-features.md).
First entry: **Enterprise-Managed Authorization** (the second
half of the MCP `ext-auth` extension — RFC 8693 token-exchange
chained through an org IdP for SSO-driven MCP access). Open an
issue if you want one prioritised.

## Development

```bash
# Compile
rebar3 compile

# Run tests
rebar3 eunit

# Dialyzer
rebar3 dialyzer

# Shell
rebar3 shell
```

## License

Apache-2.0