Toolnexus.McpServe
Elixir · package toolnexus · SPEC §7C · elixir/lib/toolnexus/mcp_serve.ex
@spec exposed_tools([Toolnexus.Tool.t()], map() | nil) :: [Toolnexus.Tool.t()]def exposed_tools(tools, cfg)# cfg[:tools] (a list of names) filters the set; absent/nil ⇒ every toolkit tool;# unknown names in the filter are ignored, never an error.
# The everyday entry point — same serve/3 call as A2A, a different opts key:@spec serve(Toolnexus.Toolkit.t(), String.t(), keyword()) :: Toolnexus.Serve.Handle.t()def Toolnexus.Toolkit.serve(toolkit, addr, opts \\ [])# opts[:mcp] = %{name?, version?, tools?: [String.t()]} — mounts POST /mcpThere is no McpServe.build/1 — the inbound MCP profile is Toolnexus.McpServe.exposed_tools/2
(the toolkit → exposed-tool-list filter) plus Toolnexus.McpServe.handle_http/4 (the streamable-HTTP
JSON-RPC dispatcher), both driven by Toolnexus.Serve.start/2 when opts[:mcp] is present —
mounted at POST /mcp, co-located with the A2A routes on the same listener. Where A2A advertises
skills and fulfils a Task through the whole client loop, MCP advertises the toolkit’s
unified tools (every source — mcp · skill · native · http · builtin · a2a) and dispatches each
tools/call straight to Tool.execute — there is no client, no Task, no TaskStore; the calling
MCP client is the LLM host. This turns a toolkit into a universal MCP gateway: aggregate N
servers + skills + your own tools behind one toolkit, then re-expose the union as one MCP server.
When to use it
Section titled “When to use it”- Re-expose an aggregated toolkit to Claude Desktop, an IDE, or another agent — connect any MCP
client to
POST /mcpand it seestools/list+ cantools/callevery tool the toolkit knows, regardless of source. - Gateway N MCP servers behind one endpoint — build a toolkit from several
mcpServers, serve it, and downstream clients only need to know about the one gateway. - Narrow what’s exposed —
mcp: %{tools: ["safe_tool"]}filters the surface without touching the underlying toolkit.
Why this and not the alternative
Section titled “Why this and not the alternative”Deferred per §7C: a stdio transport, MCP resources/prompts/sampling/completion (skills already
reach clients via the skill tool), and auth in core.
Examples
Section titled “Examples”1. The smallest useful call — exposed_tools/2 filtering, no server
Section titled “1. The smallest useful call — exposed_tools/2 filtering, no server”alias Toolnexus.{McpServe, Native}
mk = fn name -> Native.define_tool(name: name, description: name, execute: fn _ -> name end) endtools = [mk.("add"), mk.("explode")]
true = McpServe.exposed_tools(tools, nil) == toolstrue = McpServe.exposed_tools(tools, %{}) == toolstrue = Enum.map(McpServe.exposed_tools(tools, %{tools: ["explode"]}), & &1.name) == ["explode"]# unknown names in the filter are ignored, never an errortrue = McpServe.exposed_tools(tools, %{"tools" => ["nope"]}) == []
IO.puts("ok: nil/[]->all, a named subset, unknown names silently dropped")2. The realistic case — self-hosting: our own MCP client against our own served toolkit
Section titled “2. The realistic case — self-hosting: our own MCP client against our own served toolkit”alias Toolnexus.{Context, Mcp, Native, Serve, Toolkit}
Application.ensure_all_started(:req)
add_tool = Native.define_tool( name: "add", description: "Add two numbers", input_schema: %{"type" => "object", "properties" => %{"a" => %{"type" => "number"}, "b" => %{"type" => "number"}}, "required" => ["a", "b"]}, execute: fn args -> to_string(trunc(args["a"] + args["b"])) end)
fail_tool = Native.define_tool(name: "explode", description: "Always errors", execute: fn _ -> raise "kaboom" end)
{:ok, tk} = Toolnexus.create_toolkit(extra_tools: [add_tool, fail_tool], builtins: false)handle = Toolkit.serve(tk, "127.0.0.1:0", mcp: %{name: "gateway", version: "9.9.9"})
source = Mcp.load(%{"mcpServers" => %{"gw" => %{"url" => handle.url <> "/mcp", "timeout" => 10_000}}})
true = source.status == %{"gw" => "connected"}true = Enum.map(source.tools, & &1.name) == ["gw_add", "gw_explode"]
add = Enum.find(source.tools, &(&1.name == "gw_add"))true = add.source == "mcp"result = add.execute.(%{"a" => 2, "b" => 3}, %Context{})false = result.is_errortrue = result.output == "5"
# isError propagates end to end (execute raise -> MCP isError -> client error)explode = Enum.find(source.tools, &(&1.name == "gw_explode"))er = explode.execute.(%{}, %Context{})true = er.is_errortrue = er.output == "kaboom"
Mcp.close(source)Serve.stop(handle)
IO.puts("ok: gw_add -> #{result.output}, gw_explode isError=#{er.is_error}")3. The full surface — mcp.tools filters the exposed subset over the real wire
Section titled “3. The full surface — mcp.tools filters the exposed subset over the real wire”alias Toolnexus.{Mcp, Native, Serve, Toolkit}
Application.ensure_all_started(:req)
mk = fn name -> Native.define_tool(name: name, description: name, execute: fn _ -> name end) end
{:ok, tk} = Toolnexus.create_toolkit(extra_tools: [mk.("add"), mk.("explode")], builtins: false)handle = Toolkit.serve(tk, "127.0.0.1:0", mcp: %{tools: ["add", "does-not-exist"]})
source = Mcp.load(%{"mcpServers" => %{"gw" => %{"url" => handle.url <> "/mcp", "timeout" => 10_000}}})
# only "add" survives — "explode" is filtered out, "does-not-exist" is silently ignoredtrue = Enum.map(source.tools, & &1.name) == ["gw_add"]
Mcp.close(source)Serve.stop(handle)
IO.puts("ok: filtered to #{length(source.tools)} tool(s) over real streamable-HTTP")mcp profile fields
Section titled “mcp profile fields”| Field | Default | What it does |
|---|---|---|
:name |
"toolnexus" |
initialize’s serverInfo.name. |
:version |
"0.1.0" |
initialize’s serverInfo.version. |
:tools |
all toolkit tools | Filters the exposed set by name; unknown names ignored. |
See also
Section titled “See also”Toolnexus.Serve.start— publish an Agent Card and answer JSON-RPC over the client loop — your toolkit becomes someone else’s remote agent.Toolnexus.Serve.build_agent_card— construct the Agent Card that advertises your name, skills and endpoint.Toolnexus.Mcp.load— the outbound mirror: connect to a served MCP endpoint as a client.