Imp.MCP.Connections (Imp v0.5.0)

Copy Markdown View Source

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

A server descriptor: a map with string keys (see Descriptors above).

Types

context()

@type context() :: %{cwd: String.t(), descriptor: descriptor()}

descriptor()

@type descriptor() :: map()

A server descriptor: a map with string keys (see Descriptors above).