WardrikMCP

Copy Markdown

Gives WardrikCore agents the tools of any MCP server.

Install

def deps do
  [
    {:wardrik_mcp, "~> 0.1.0-rc.1"}
  ]
end

Connect to servers

Add WardrikMCP to your supervision tree with a name and its servers. A server runs as a local command (:stdio) or is reached over HTTP (:streamable_http):

children = [
  {WardrikMCP,
   name: MyApp.MCP,
   servers: [
     github: [
       transport:
         {:stdio,
          command: "github-mcp-server",
          args: ["stdio"],
          env: %{"GITHUB_PERSONAL_ACCESS_TOKEN" => System.fetch_env!("GITHUB_TOKEN")}},
       only: ["search_issues", "create_issue"]
     ],
     linear: [
       transport:
         {:streamable_http,
          url: "https://mcp.linear.app/mcp",
          headers: %{"authorization" => "Bearer " <> System.fetch_env!("LINEAR_TOKEN")}}
     ]
   ]}
]

A command doesn't inherit the app's environment. It gets its env and only HOME, LANG, LC_ALL, LOGNAME, PATH, SHELL, TERM, TMPDIR and USER; it's started through env, which unsets the rest. Give it anything else it needs in env.

Each server connects on its own. A server that's down offers no tools and doesn't block the others. It's retried with backoff, up to a minute between tries.

Give an agent the tools

Pass WardrikMCP as a capability, with the servers to take tools from:

WardrikCore.start_agent(
  agent_id: "triage",
  model: "anthropic:claude-haiku-4-5",
  capabilities: [{WardrikMCP, name: MyApp.MCP, servers: [:github, :linear]}]
)

The model sees each tool as <server>__<tool>, as github__search_issues, with the server's own description and JSON Schema. When a server says its tools changed, the agent gets the new ones on its next turn.

Server options

OptionValueDefault
transport{:stdio, command: …, args: …, env: …} or {:streamable_http, url: …, headers: …}, where url is the endpoint the server publishesrequired
onlythe server's tool names to offerevery tool
exceptthe server's tool names to leave outnone
timeoutmilliseconds a call may take60_000
max_result_bytesbytes of a result the model gets131_072
prefixthe prefix for tool namesthe server's name
credential_namethe credential each principal reaches the server withnone
credential_header, credential_schemewhere the credential goes over HTTP"authorization", "Bearer"
credential_envthe environment variable the credential goes in, for a commandnone
costeach call's cost, as [usd: 0.001], or a function of the resultnone
gatedwhich tools need a person's approval: true, false, or tool names, as ["create_*"]tools not marked read-only
trustedtrue to not label answers "outside"false

See WardrikMCP.Server.

Results

The model gets a result's structured content if the server sends it, and its text otherwise. A result over max_result_bytes is cut, and the model is told. An error the tool returns reaches the model as the call's error.

Add servers at runtime

Give WardrikMCP a :store to add and remove servers while the app runs. Every node sharing the store sees them.

{WardrikMCP, name: MyApp.MCP, store: store, servers: []}

WardrikMCP.add(MyApp.MCP, "linear", transport: {:streamable_http, url: "https://mcp.linear.app/mcp"})
#=> :ok

WardrikMCP.servers(MyApp.MCP)
#=> ["linear"]

WardrikMCP.remove(MyApp.MCP, "linear")
#=> :ok

Options for a server added at runtime must be data, not functions. Each node re-reads the store every minute, or when you call WardrikMCP.reconcile/1.

Checking URLs

When users supply the URLs, give a :check. It's called on each URL when the server is added and each time a connection starts. With a check, runtime servers must use HTTP; commands are refused.

{WardrikMCP, name: MyApp.MCP, store: store, check: &MyApp.Egress.public/1}

WardrikMCP.add(MyApp.MCP, "internal", transport: {:streamable_http, url: "https://10.0.0.5/mcp"})
#=> {:error, "https://10.0.0.5/mcp can't be reached: 10.0.0.5 is a private address"}

Per-person credentials

Set credential_name so each principal reaches the server with their own credential, over their own connection. The credential comes from the agent's WardrikCore.Credentials.

WardrikMCP.add(MyApp.MCP, "linear",
  transport: {:streamable_http, url: "https://mcp.linear.app/mcp"},
  credential_name: "linear"
)
  • Over HTTP, it's sent as authorization: Bearer <token>. Change this with credential_header and credential_scheme.
  • A command gets it in the environment variable named by credential_env.
  • A principal with no credential yet is asked to sign in, and the call waits.
  • A changed credential reconnects.
  • If the server refuses the credential (401 or 403), it's reported with WardrikCore.Credentials.refused/2 and the call is tried once more. An OAuth grant is renewed, or the person is asked to sign in again.

Until someone has connected, the server offers one tool, linear__connect, so an agent can start. After that, its tools are kept in the store and offered to everyone. A person's connection closes after five minutes without a call.

Tool annotations

MCP servers mark their tools with hints. Each tool carries them as annotations:

Server hintAnnotation
readOnlyHintread_only, and idempotent
destructiveHintdestructive
idempotentHintidempotent
openWorldHintopen_world

A hint the server doesn't give isn't set. With WardrikPolicy, you can make tools that aren't read-only wait for a person:

WardrikPolicy.new!(default: [destructive: :deny, read_only: :allow, else: :ask])

Call costs

Give a server a fixed cost, or a function that reads it from the result:

search: [
  transport: {:streamable_http, url: "https://search.example.com/mcp"},
  cost: fn result -> %{usd: result["_meta"]["usd"]} end
]

Each call's cost is measured on its step, so budgets count it.

Telemetry

[:wardrik_mcp, :tools] is emitted each time a server's tools change.

  • Measurements: :count, the number of tools the server offers. 0 while it's down.
  • Metadata: :mcp, the WardrikMCP name, and :server, the server's name.

License

Apache-2.0