FastestMCP.Client is a connected MCP client for streamable HTTP and stdio. It keeps session state, auth, request tracking, callbacks, and remote task handles in one OTP process.

It is the right API when you need:

  • a negotiated MCP session rather than stateless HTTP calls
  • remote task handles for tools/call, prompt tasks, or resource tasks
  • session-stream notifications
  • sampling or elicitation callbacks
  • subscriptions, completions, and auth reuse on one connection

HTTP Connection

client =
  FastestMCP.Client.connect!("http://127.0.0.1:4100/mcp",
    client_info: %{"name" => "docs-client", "version" => "1.0.0"},
    session_stream: true,
    sampling_handler: fn messages, params ->
      IO.inspect({:sampling, messages, params})
      %{"text" => "sampled"}
    end,
    elicitation_handler: fn message, params ->
      IO.inspect({:elicitation, message, params})
      {:accept, %{"value" => "Alice"}}
    end,
    log_handler: &IO.inspect/1,
    progress_handler: &IO.inspect/1,
    notification_handler: &IO.inspect/1
  )

%{items: tools} = FastestMCP.Client.list_tools(client)
FastestMCP.Client.call_tool(client, "sum", %{"a" => 20, "b" => 22})

Use session_stream: true when you want:

  • notifications/tasks/status
  • resource update notifications
  • server log and progress notifications
  • server-to-client elicitation or sampling relay during tasks/result

Stdio Connection

client =
  FastestMCP.Client.connect!(
    {:stdio, "/path/to/server-command", ["--serve-mcp"]},
    client_info: %{"name" => "stdio-client", "version" => "1.0.0"}
  )

Stdio stays request/response only. It does not carry unsolicited session notifications.

Protected Servers

client =
  FastestMCP.Client.connect!("http://127.0.0.1:4100/mcp",
    access_token: System.fetch_env!("MCP_TOKEN")
  )

FastestMCP.Client.call_tool(client, "whoami", %{})

If you need to connect first and authenticate later:

client =
  FastestMCP.Client.connect!("http://127.0.0.1:4100/mcp",
    auto_initialize: false
  )

:ok = FastestMCP.Client.set_auth_input(client, headers: [{"x-trace-id", "trace-123"}])
:ok = FastestMCP.Client.set_access_token(client, System.fetch_env!("MCP_TOKEN"))

FastestMCP.Client.initialize(client)

Per-request overrides are also supported:

FastestMCP.Client.call_tool(client, "secure.echo", %{"message" => "hi"},
  access_token: "request-specific-token",
  headers: [{"x-request-id", "req-123"}]
)

Core Operations

The client mirrors the main MCP surfaces:

Connected list helpers return a stable page-map shape:

%{items: tools, next_cursor: next_cursor} =
  FastestMCP.Client.list_tools(client)

%{items: prompts, next_cursor: nil} =
  FastestMCP.Client.list_prompts(client)

Remote Task Handles

When a server returns a task, the Elixir client wraps it in %FastestMCP.Client.Task{}.

Tool example:

alias FastestMCP.Client.Task, as: RemoteTask

task =
  FastestMCP.Client.call_tool(
    client,
    "slow_report",
    %{"id" => 42},
    task: true
  )

RemoteTask.status(task)
RemoteTask.fetch(task)
RemoteTask.wait(task)
RemoteTask.result(task)
RemoteTask.cancel(task)

By default, RemoteTask.wait/2 returns when the task leaves active work states. That includes terminal states such as "completed" and "failed", and also interactive states such as "input_required". Pass status: or statuses: when you need to wait for a specific state:

RemoteTask.wait(task, status: "completed")
RemoteTask.wait(task, statuses: ["completed", "failed"])

The same handle shape works for prompt and resource tasks:

prompt_task =
  FastestMCP.Client.render_prompt(
    client,
    "draft_release",
    %{"title" => "v1.2.3"},
    task: true
  )

resource_task =
  FastestMCP.Client.read_resource(
    client,
    "memo://release",
    task: true
  )

FastestMCP.Client.Task.result(prompt_task)
FastestMCP.Client.Task.result(resource_task)

SEP-1686 standardizes tool tasks. FastestMCP also supports prompt and resource tasks as Elixir client extensions over the same task-handle API.

Task Listing

FastestMCP.Client.list_tasks/2 follows the same page-map shape:

%{items: tasks, next_cursor: next_cursor} =
  FastestMCP.Client.list_tasks(client, page_size: 20)

Enum.each(tasks, fn task ->
  IO.inspect({task["taskId"], task["status"]})
end)

The server enforces session and auth scoping, so task listing only returns tasks visible to the connected session identity.

Task Status Notifications

When the session stream is open, the client can react to notifications/tasks/status automatically.

Register per-task callbacks:

RemoteTask.on_status_change(task, fn status ->
  IO.inspect({status["taskId"], status["status"]})
end)

Or inspect the raw session notification feed:

client =
  FastestMCP.Client.connect!("http://127.0.0.1:4100/mcp",
    session_stream: true,
    notification_handler: fn
      %{"method" => "notifications/tasks/status", "params" => params} ->
        IO.inspect({:task_status, params})

      message ->
        IO.inspect({:notification, message})
    end
  )

Tracked task handles update their cached status from those notifications and fall back to tasks/get polling when needed.

Elicitation and Sampling Relay

Remote task resolution uses the standard tasks/result path. That matters for interactive tasks: tasks/result can block, the server can call elicitation/create or sampling/createMessage back into the client, and the same request resumes after the handler replies.

task = FastestMCP.Client.call_tool(client, "ask_name", %{}, task: true)

result =
  FastestMCP.Client.Task.result(task)

IO.inspect(result)

If the connected client has an elicitation handler:

client =
  FastestMCP.Client.connect!("http://127.0.0.1:4100/mcp",
    session_stream: false,
    elicitation_handler: fn "What is your name?", _params ->
      {:accept, "Alice"}
    end
  )

then RemoteTask.result(task) can trigger that callback, open the session stream on demand, and return the resumed result after the relay finishes. Scalar elicitation handlers may return the raw scalar value or %{"value" => value}.

FastestMCP.Client.send_task_input/5 still exists as a FastestMCP extension, but tasks/result is the standard SEP-1686 flow.

Client-Owned Callback Tasks

If the server calls the client for sampling or elicitation and marks the request as task-capable, the Elixir client now supports that task runtime too.

That means:

  • the client returns a CreateTaskResult immediately
  • the installed callback handler runs in a supervised worker
  • the server can then use tasks/get, tasks/list, tasks/result, and tasks/cancel against the client-owned task on the same connection
  • the client emits notifications/tasks/status back to the server as the callback task changes state

The client only advertises these callback-task capabilities when the matching handler is installed:

client =
  FastestMCP.Client.connect!("http://127.0.0.1:4100/mcp",
    session_stream: true,
    sampling_handler: fn _messages, _params -> %{"text" => "draft"} end,
    elicitation_handler: fn _message, _params -> {:accept, %{"ok" => true}} end
  )

With that configuration, initialization capabilities include the task callback request surface:

%{
  "tasks" => %{
    "list" => %{},
    "cancel" => %{},
    "requests" => %{
      "sampling" => %{"createMessage" => %{}},
      "elicitation" => %{"create" => %{}}
    }
  }
} = FastestMCP.Client.initialize_result(client)["capabilities"]

If a handler is not installed, that callback-task capability is not advertised.

When the server later calls tasks/result for one of those client-owned callback tasks, the client does not return an intermediate "not completed" error. It holds that tasks/result request open until the callback reaches a terminal state, then posts the final response with _meta["io.modelcontextprotocol/related-task"].

Task-augmented sampling example:

client =
  FastestMCP.Client.connect!("http://127.0.0.1:4100/mcp",
    session_stream: true,
    sampling_handler: fn _messages, _params ->
      Process.sleep(150)
      %{"text" => "draft summary"}
    end
  )

# Server flow on the same connection:
# 1. sampling/createMessage arrives with _meta.task = true
# 2. client returns CreateTaskResult immediately
# 3. server calls tasks/result with that taskId
# 4. client waits for the sampling handler to finish
# 5. final tasks/result payload includes related-task metadata

Task-augmented elicitation example:

client =
  FastestMCP.Client.connect!("http://127.0.0.1:4100/mcp",
    session_stream: true,
    elicitation_handler: fn "Deploy to production?", _params ->
      Process.sleep(150)
      {:accept, %{"approved" => true}}
    end
  )

# The server can poll with tasks/get or tasks/list, or wait directly on
# tasks/result. The client resolves that request only after the elicitation
# handler accepts, declines, cancels, or fails.

Result Caching

Remote task handles cache terminal state and final results once they have been observed or fetched. In practice this gives you:

  • repeated RemoteTask.result/1 calls without another round trip
  • cached terminal status after completion or cancellation
  • resilience when the session stream closes after the terminal result was already cached

Resource Subscriptions

Streamable HTTP clients can subscribe to concrete resource URIs or template patterns:

%{} = FastestMCP.Client.subscribe_resource(client, "config://release")
%{} = FastestMCP.Client.subscribe_resource(client, "users://{id}{?format}")

%{} = FastestMCP.Client.unsubscribe_resource(client, "config://release")

Subscribed clients receive notifications/resources/updated through the generic notification handler.

Session Stream Control

If you connect without session_stream: true, you can manage the stream explicitly:

:ok = FastestMCP.Client.open_session_stream(client)
FastestMCP.Client.session_stream_open?(client)
:ok = FastestMCP.Client.close_session_stream(client)

This is useful when initialization should stay plain HTTP first and the event stream should only open later.

Callback Handlers

Install or replace handlers at runtime with:

The generic notification handler is where resource updates, list-change notifications, and custom session notifications arrive.

Why This Shape

The client is session-first on purpose. It models one negotiated MCP connection, not a bag of stateless request helpers. That keeps task relay, callback routing, auth reuse, subscriptions, and task-result caching aligned with the actual protocol session.