Opens explicitly authorized MCP servers through ExMCP and imports their tools.
Connections belong to :owner (the caller by default), independently of any
ACP session. Exact descriptors must be approved through :authorize or
:trusted_servers; connection cleanup never depends on a model-visible name.
Descriptors
A server is a map with string keys. A local server runs as a child process that speaks MCP on stdin and stdout:
%{
"name" => "files",
"type" => "stdio",
"command" => "npx",
"args" => ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env" => [%{"name" => "LOG_LEVEL", "value" => "warn"}]
}"type" may be left out when "command" is present. The command is found on
PATH and runs in :cwd (the current directory by default). It sees the
host's ordinary variables (HOME, PATH, LANG and the like) and its own
"env", not the rest of the host's environment. Closing the import, or the
end of its :owner, stops the server and every process it started.
A remote server is "type" => "http" (Streamable HTTP) or "sse", with a
"url":
%{"name" => "docs", "type" => "http", "url" => "https://mcp.example.com/mcp"}"sse" is MCP's deprecated HTTP+SSE transport (protocol 2024-11-05), and its
"url" is the event stream's, usually ending in /sse (a URL without a path
means /sse). The client GETs that
stream, and the server's first event names the URL requests are posted to,
headers and all. It works with servers whose posting URL carries the session
as sessionId, as the TypeScript SDK's and ExMCP's do. It does not work with
the Python SDK's SSE servers, which name it session_id: the dial fails
with {:sse_endpoint_without_session_id, why, exmcp_reason}, where why
says to use their Streamable HTTP endpoint. A server that speaks Streamable
HTTP as well is better reached as "http".
An "sse" descriptor may not carry "headers" or "auth": the server
names where requests are posted, and ExMCP sends a connection's headers
there, whatever origin it names, before Imp can see it. Such a descriptor is
refused before anything is dialed (:mcp_sse_credentials_refused): it
refuses the whole import under the default on_failure: :refuse, and under
on_failure: :drop it is left out, named in unavailable and logged, as a
server that cannot be dialed is. An "sse" URL with a query string is
refused the same way (:mcp_sse_url_refused): ExMCP would dial the stream
without it.
Only descriptors the caller authorized are dialed. trusted_servers: lists
them exactly; authorize: is a function of the descriptor (and optionally a
%{cwd: cwd, descriptor: descriptor} context) that returns :allow or
{:deny, reason}. A refused descriptor refuses the import with
{:mcp_server_not_authorized, server_name, reason}; one not in
trusted_servers: has the reason :not_trusted:
{:ok, import} = Imp.MCP.connect([server], trusted_servers: [server])
tool = Enum.find(import.tools, &(to_string(&1.name) == "read_text_file"))
Imp.Tool.call(tool, %{"path" => "/tmp/notes.txt"})
import.cleanup.()Authenticating an HTTP server
A descriptor may carry static "headers". It may instead name an auth kind,
which is resolved to a header when the connection is built and never written
back into the descriptor:
%{"type" => "oauth", "credential" => "readwise"}resolves through the Imp.MCP.OAuth.Store passed as the :credential_store
option, refreshing the grant when it is near expiry. The credential must have
been authorized for this descriptor's "url"; naming another server's
credential is refused rather than resolved. See Imp.MCP.OAuth.
%{"type" => "bearer_env", "variable" => "EXA_API_KEY"}reads the variable from the host's environment. When it is unset the server
is connected with no Authorization header and one warning naming the server
and the variable is logged, so a server that also answers anonymously still
works. Add "required" => true to refuse the connection instead, with a
message naming the variable.
Both forms may be combined with static "headers"; the resolved header is
appended. Tokens never appear in the descriptor, so authorization callbacks,
:call_meta and tool provenance never see one.
A server that cannot be reached
Under the default on_failure: :refuse, one unreachable server fails the
whole import: every client is disconnected and an error is returned.
Under on_failure: :drop, a server whose transport or initialize fails,
which never answers at all, or which cannot answer tools/list, is left out:
its client is closed, the servers beside it keep their tools, and the
returned Imp.MCP.Import names it in unavailable with the reason the
refusal would have carried, summarized to one short line, and with the
index of the descriptor in the list that was passed in. Use it for a caller
whose servers are independent, such as a long-lived agent holding several
third-party catalogs. An "sse" descriptor that carries credentials (see
above) is left out the same way.
Each dial is bounded by :timeout on its own, so a host that accepts the
connection and then answers nothing costs that server its timeout and no more.
Calls to one server at once
pool_size: (1 by default) is how many connections Imp opens to each
http or sse server. An ExMCP client sends one HTTP request at a time,
making the POST from inside its own process, so a quick call made while a
slow one is out on the same connection waits for the slow one to answer.
With a pool each tool call borrows an idle connection for the length of the
call, so up to pool_size calls to one server run at once. A host sets it
from how many of its own calls can be out together. A call that finds every
connection busy waits for one, and if none comes free within :timeout it
fails as :not_sent (Imp.MCP.CallFailure): nothing was sent.
The first connection to a server decides whether the server is reachable and
lists its tools. The others are dialed at once, the way the first connected,
so a server costs the import at most about three :timeouts (the first
dial, a second one with the standard handshake only when a server refuses
ExMCP's opening probe, and the extras) whatever pool_size is. Each holds the server's origin in the trusted origins for as
long as it lives, and one that cannot be opened leaves the server with fewer
connections and is logged.
A connection whose call timed out, or whose caller died during the call, may
still be waiting on that request inside ExMCP, and lent again it would hold
the next call behind it. It is taken out of the pool instead and closed once
that request is done, so the server finishes what it was doing, and a
replacement is dialed in the background; calls wait for it. A replacement that cannot be dialed
leaves the server a connection fewer, and a server left with none answers
its calls :not_sent with reason: :not_connected.
A call is answered at its :timeout, as :unknown with reason: :timeout,
while its request runs on to the connection's own request limit (the
:timeout, at least 30 s). That holds for a request that asks for progress
too: ExMCP ends such a request's stream a second after the timeout a call is
made with, and the server ends the tool with it, so Imp makes the call with
the whole request limit and keeps the caller's timeout itself.
A stdio server has one connection whatever pool_size says. ExMCP writes
each request to its pipe and matches answers by id, so calls to it already
run at once over that connection, and they go straight to it. A second
connection would be a second server process with state of its own.
What a tool is named
The declaration decides, and nothing else. A descriptor may carry a
"tool_prefix":
%{"name" => "exa", "type" => "http", "url" => "…", "tool_prefix" => "exa_"}Every tool that server offers is then named exa_ <> its own name, always,
whether or not anything else is connected. A descriptor without a prefix
contributes its tools under the names the server gave them. A name therefore
never depends on which servers answered.
Nothing is renamed to resolve anything. If two connected servers without
prefixes offer the same tool name, the import refuses with
{:mcp_tool_name_collision, tool, servers} naming the tool and both servers,
and logs the fix — give one of them a "tool_prefix". A tool whose name is
one the program has already taken (:reserved_tool_names) is refused as
{:mcp_tool_name_reserved, tool, servers}. Both refusals stand under
on_failure: :drop, which drops what the network did and never what the
caller declared.
A consequence to plan for: a collision between two unprefixed servers goes unnoticed for as long as one of them is absent, and then refuses the import the first time both answer.
Imp.Tool provenance (tool.metadata.mcp) carries the descriptor's index,
its server_name, and the tool_name the server published, whatever the
tool ended up called.
Dropping covers failures of the connection and of tools/list, not of the
declaration. A descriptor that :authorize refused
({:mcp_server_not_authorized, server, answer}, where answer is what the
callback returned, or :not_trusted for a descriptor not among
:trusted_servers), one whose auth cannot produce a header (a
bearer_env variable declared required and unset, for example), one whose
"tool_prefix" is not a string, one that is malformed
({:invalid_mcp_server, index, %ArgumentError{}}, named by its place in the
list), a tool name two servers both claim, and anything raised by the
caller's own :tool_filter all refuse the import under either setting.
A server left out under :drop is %{server_name: name, index: index, reason: reason} in the import's unavailable list, where reason is the term the
import would have refused with under :refuse. A connection that failed is
{:mcp_connection_failed, detail} and a catalog that could not be listed is
{:mcp_tools_list_failed, server, detail}, where detail is ExMCP's error,
:timeout for a dial that did not answer in time, {:exit, reason}, or the
exception raised while connecting.
Summary
Types
@type context() :: %{cwd: String.t(), descriptor: descriptor()}
@type descriptor() :: map()
A server descriptor: a map with string keys (see Descriptors above).