Every handler — tool call/2, resource read/2, prompt get/2, and every
handle_* callback — receives a %Noizu.MCP.Ctx{} as its last argument. It
carries session identity, per-session state, and the channel back to the
client.
Handlers run in supervised Tasks, never in the session process itself.
A slow tool therefore never blocks ping, cancellation, or progress — and you
may block inside a handler (HTTP calls, Ctx.sample/2, …) freely.
Progress
def call(args, ctx) do
Noizu.MCP.Ctx.report_progress(ctx, 0.25, total: 1.0, message: "fetching")
# ...
Noizu.MCP.Ctx.report_progress(ctx, 1.0, total: 1.0)
{:ok, "done"}
endProgress is sent only when the client supplied a progressToken with the
request; otherwise report_progress/3 is a no-op. Never invent tokens.
Logging to the client
Noizu.MCP.Ctx.info(ctx, "cache miss")
Noizu.MCP.Ctx.log(ctx, :warning, %{"retries" => 3}, logger: "myapp.search")Levels follow the spec (debug, info, notice, warning, error, critical, alert, emergency), each with a convenience function. Messages are filtered
by the client's logging/setLevel choice. This is client-facing logging
— it does not replace Logger.
Cancellation
Cancellation kills the handler Task, so most handlers need nothing special. Long loops that must stop between units of work can poll:
def call(%{items: items}, ctx) do
Enum.reduce_while(items, [], fn item, acc ->
if Noizu.MCP.Ctx.cancelled?(ctx),
do: {:halt, acc},
else: {:cont, [process(item) | acc]}
end)
# ...
end(After cancellation the response is dropped either way — cancelled?/1 is
about stopping side effects early, not about the reply.)
Session state
Two scopes, both under ctx.assigns:
Noizu.MCP.Ctx.assign(ctx, key, value)— returns an updated ctx for use within the current handler (and ininit/2, where it seeds the session).Noizu.MCP.Ctx.put_session(ctx, key, value)— writes through to the session so subsequent requests observe it.
@impl Noizu.MCP.Server
def init(ctx, _params), do: {:ok, Noizu.MCP.Ctx.assign(ctx, :tenant, :acme)}
def call(args, ctx) do
Noizu.MCP.Ctx.put_session(ctx, :last_query, args.query)
{:ok, "tenant=#{ctx.assigns.tenant}"}
endOn authenticated HTTP transports the verified token claims appear at
ctx.assigns.auth_claims (see Authentication). Session
assigns also drive session-gated tool visibility — put_session/3 a flag,
list with include_hidden:, and notify_changed(:tools); see
Toolkits, Categories & Hidden Tools.
Talking back to the client
A server handler can make requests to the client mid-call. Each is
capability-checked ({:error, :capability_not_supported} when the client
didn't advertise it) and takes its own timeout::
# LLM sampling
{:ok, result} =
Noizu.MCP.Ctx.sample(ctx, %{
"messages" => [%{"role" => "user", "content" => %{"type" => "text", "text" => q}}],
"maxTokens" => 200
}, timeout: 30_000)
result["content"]["text"]
# Elicitation (ask the human)
schema = %{"type" => "object", "properties" => %{"confirm" => %{"type" => "boolean"}},
"required" => ["confirm"]}
case Noizu.MCP.Ctx.elicit(ctx, "Proceed with deletion?", schema, timeout: 60_000) do
{:ok, {:accept, %{"confirm" => true}}} -> ...
{:ok, {:accept, _}} -> ...
{:ok, :decline} -> ...
{:ok, :cancel} -> ...
{:error, reason} -> ...
end
# Client filesystem roots
{:ok, roots} = Noizu.MCP.Ctx.list_roots(ctx, timeout: 5_000)
Enum.map(roots, & &1.uri)These block only the handler Task. They are deliberately unavailable from
the session process itself (returning {:error, :not_allowed_in_session_process})
to prevent deadlock — in practice you only hit this if you call them outside
a handler.
Telemetry
Server-side requests emit [:noizu_mcp, :server, :request, :start | :stop | :exception] with method/session metadata — attach standard
:telemetry handlers for metrics.