FastestMCP keeps runtime state explicit.

Every handler receives a %FastestMCP.Context{}. That is one of the core design decisions in the library. Instead of rewriting function signatures or hiding state in framework globals, FastestMCP makes the request state, session state, auth state, and task state visible at the handler edge.

Accessing Context

The normal shape is explicit arity-2 handlers:

server =
  FastestMCP.server("context")
  |> FastestMCP.add_tool("process_file", fn %{"file_uri" => file_uri}, ctx ->
    :ok = FastestMCP.Context.info(ctx, "Processing #{file_uri}")

    %{
      file_uri: file_uri,
      request_id: ctx.request_id
    }
  end,
    input_schema: %{
      "type" => "object",
      "properties" => %{"file_uri" => %{"type" => "string"}},
      "required" => ["file_uri"]
    }
  end)

The same %FastestMCP.Context{} is passed to:

  • tools
  • resources
  • resource templates
  • prompts

That means the same runtime model applies no matter which component type is executing.

Convenience Helpers

The default style is explicit handler ctx.

Explicit handler ctx

FastestMCP.add_tool(server, "review", fn %{"subject" => subject}, ctx ->
  %{subject: subject, request_id: ctx.request_id}
end)

Context.current/0 and current!/0

Use these when nested helper code is only valid during an active request:

defmodule MyApp.ReleaseHelpers do
  def current_request_summary do
    ctx = FastestMCP.Context.current!()

    %{
      request_id: ctx.request_id,
      session_id: ctx.session_id
    }
  end
end

FastestMCP.add_tool(server, "nested", fn _arguments, _ctx ->
  MyApp.ReleaseHelpers.current_request_summary()
end)

request_context

FastestMCP.add_tool(server, "request_info", fn _arguments, ctx ->
  request = FastestMCP.Context.request_context(ctx)

  %{
    request_id: request.request_id,
    transport: request.transport,
    path: request.path,
    headers: request.headers,
    meta: request.meta
  }
end)

client_id

FastestMCP.add_tool(server, "client_info", fn _arguments, ctx ->
  %{
    client_id: FastestMCP.Context.client_id(ctx),
    principal: ctx.principal
  }
end)

Server access

FastestMCP.add_tool(server, "server_info", fn _arguments, ctx ->
  server = FastestMCP.Context.server(ctx)

  %{
    server_name: server.name,
    schema_options: server.schema_options
  }
end)

Explicit handler ctx remains the primary style. current/0 and current!/0 are convenience helpers for nested runtime code, not a new hidden-global programming model.

What Lives On Context

The context carries several different lifetimes of data:

  • request state for one operation
  • session state shared across requests with the same session id
  • auth state such as principal, authentication status, verified scopes and audiences, and application capabilities
  • task state when the operation is running as a background task
  • lifespan context produced at server startup
  • dependency resolvers declared on the server
  • HTTP request metadata captured from the transport

That separation matters because each lifetime has different cleanup and failure rules.

Request Context Snapshots

FastestMCP.Context.request_context/1 returns a stable %FastestMCP.RequestContext{} wrapper with:

  • request_id
  • transport
  • path
  • query_params
  • headers
  • meta

That is the narrow convenience surface for code that wants request metadata without depending on the full %FastestMCP.Context{} struct.

Transport

The request snapshot also exposes the active transport:

FastestMCP.add_tool(server, "connection_info", fn _arguments, ctx ->
  case FastestMCP.Context.request_context(ctx).transport do
    :stdio -> "Connected via stdio"
    :streamable_http -> "Connected via streamable HTTP"
    :in_process -> "Called in process"
    other -> "Connected via #{other || "unknown"}"
  end
end)

Client Metadata

Clients can attach request-scoped metadata, and FastestMCP exposes it through request_context.meta.

From the connected client:

FastestMCP.Client.call_tool(client, "send_email", %{"to" => "ops@example.com"},
  meta: %{trace_id: "trace-123", user_id: "user-42"}
)

Inside the handler:

FastestMCP.add_tool(server, "send_email", fn _arguments, ctx ->
  request = FastestMCP.Context.request_context(ctx)

  %{
    trace_id: request.meta["trace_id"],
    user_id: request.meta["user_id"]
  }
end)

This is useful for correlation ids, caller hints, or app-level request data that should follow one MCP operation without being promoted to session state.

Session State

Session state is the right place for conversation-local memory.

alias FastestMCP.Context

server =
  FastestMCP.server("context")
  |> FastestMCP.add_tool("session_info", fn _arguments, ctx ->
    visits = Context.get_state(ctx, :visits, 0) + 1
    :ok = Context.set_state(ctx, :visits, visits)

    %{
      session_id: ctx.session_id,
      request_id: ctx.request_id,
      visits: visits
    }
  end)

Use session state when the value belongs to the client conversation, not to one request and not to the whole server. HTTP, stdio, and normal in-process calls use ctx.state_scope == :session by default.

Pass state_scope: :request when application values must reset for every operation. In that mode, set_state/4, get_state/3, and delete_state/2 operate only on request-local storage; they never write a session backend entry. HTTP still completes the normal initialize lifecycle and keeps a stable, non-null ctx.session_id, negotiated version, client information, capabilities, subscriptions, and task ownership.

FastestMCP exposes three related APIs:

  • Context.set_state/4
  • Context.get_state/3
  • Context.delete_state/2

The older put_session_state/3 and get_session_state/3 helpers still work, but set_state and get_state are now the preferred interface.

By default, session state is stored in a per-server in-memory backend. You can swap that backend at server startup with session_state_store: {module, opts}.

One important detail:

Context.set_state(ctx, :current_upload, socket, serializable: false)

serializable: false keeps the value request-scoped instead of writing it to the session backend. Use that for values that should stay local to the current call and should not be serialized or shared across requests.

Application Sessions

Application sessions hold application-owned state independently of the MCP transport session. They use the same configured SessionStateStore, but their keys live in a separate hashed namespace.

Use the authenticated caller's private bucket when state should follow the same principal across modern HTTP, stdio, legacy sessions, and background work:

alias FastestMCP.ApplicationSession

FastestMCP.add_tool(server, "remember_preference", fn %{"theme" => theme}, ctx ->
  session = ApplicationSession.current!(ctx)
  :ok = ApplicationSession.put(session, :theme, theme)
  %{"stored" => true}
end)

Use an explicit session when the application needs an opaque handle:

{:ok, session} = ApplicationSession.create(ctx)
session_id = ApplicationSession.id(session)

# In a later authenticated request:
{:ok, session} = ApplicationSession.fetch(ctx, session_id)
{:ok, theme} = ApplicationSession.get(session, :theme, "system")

Explicit sessions are scoped to the verified principal. Unknown, terminated, and foreign identifiers return the same invalid-parameter error. Termination deletes the entire explicit-session namespace.

Anonymous explicit sessions are disabled by default. Opt in only when the random session identifier is intended to act as a bearer capability:

FastestMCP.server("app", application_sessions: [allow_anonymous: true])

ApplicationSession.current/1 always requires an authenticated principal, even when anonymous explicit sessions are enabled. The application should use a globally unambiguous verified principal, such as {issuer, subject}.

Request State

Request state is scratch storage for the current operation only.

FastestMCP uses it internally for features such as dependency caching and progress helpers, but it is also available to application code through:

  • Context.put_request_state/3
  • Context.get_request_state/3
  • Context.delete_request_state/2

Use request state when a helper inside the current call stack needs to share data without writing to the session.

Dependencies

Dependencies are resolved from the context and cached once per request or background task:

server =
  FastestMCP.server("context")
  |> FastestMCP.add_dependency(:clock, fn -> DateTime.utc_now() end)
  |> FastestMCP.add_tool("time", fn _arguments, ctx ->
    %{now: Context.dependency(ctx, :clock)}
  end)

Use dependencies for application services or request-scoped resource handles. The dedicated guide covers cleanup behavior and resolver shapes in more detail:

Lifespan Context

Values created at startup are available through ctx.lifespan_context:

server =
  FastestMCP.server("context")
  |> FastestMCP.add_lifespan(fn _server ->
    %{"config" => %{"region" => "eu-west-1"}}
  end)
  |> FastestMCP.add_tool("config", fn _arguments, ctx ->
    ctx.lifespan_context
  end)

Use lifespan context for runtime-wide state that should exist once per server instance, not once per request.

Auth State

Authenticators write normalized auth results back onto the context:

  • ctx.principal
  • ctx.auth
  • ctx.authenticated
  • ctx.capabilities
  • ctx.verified_scopes
  • ctx.verified_audiences
  • Context.client_id/1

That gives tools, prompts, middleware, and providers one consistent view of the authenticated caller.

Context.client_id/1 currently derives from auth or principal data when it is available. When auth does not provide one, it falls back to negotiated clientInfo.name from the MCP initialize handshake for the current session.

HTTP Context

The context also carries an immutable HTTP request snapshot when the operation came from HTTP:

  • Context.http_request/1
  • Context.http_headers/2
  • Context.access_token/1
  • Context.request_context/1

Use these helpers when handler behavior legitimately depends on request metadata. Keep that explicit; avoid pretending the transport does not exist when it actually matters.

Incoming Authorization is deliberately absent from the public HTTP header snapshots and from context inspection. Authentication still receives the raw header at the transport boundary. Context.access_token/1 remains the narrow, explicit accessor for live-request code that needs the bearer token, while the transport credential is not copied into background or detached task context.

Background Task Context

When an operation runs as a background task, the context reflects that:

  • Context.background_task?/1
  • Context.task_id/1
  • Context.origin_request_id/1
  • Context.task_store/1

This is why the same handler code can still report progress, ask for elicitation, or access task metadata after the original request has returned.

Logging

Context-driven server logging uses Context.log/4:

alias FastestMCP.Context

server =
  FastestMCP.server("context")
  |> FastestMCP.add_tool("run", fn _arguments, ctx ->
    :ok = Context.log(ctx, :info, "Starting work")
    :ok = Context.debug(ctx, "Debug detail")
    %{status: "ok"}
  end)

Supported levels are:

  • :debug
  • :info
  • :notice
  • :warning
  • :error
  • :critical
  • :alert
  • :emergency

FastestMCP also exposes convenience wrappers:

  • Context.debug/3
  • Context.info/3
  • Context.warning/3
  • Context.error/3

Progress Reporting

Progress reporting is available through Context.report_progress/4:

alias FastestMCP.Context

server =
  FastestMCP.server("context")
  |> FastestMCP.add_tool(
    "slow",
    fn _arguments, ctx ->
      Context.report_progress(ctx, 10, 100, "Starting")
      Context.report_progress(ctx, 60, 100, "Halfway")
      Context.report_progress(ctx, 100, 100, "Done")
      :done
    end,
    task: true
  )

This is most useful in background tasks or active HTTP requests that provide a progress token.

Sampling and Elicitation

Several higher-level features are just context operations:

  • Context.progress/1
  • Context.report_progress/4
  • Context.log/4
  • Context.send_notification/3
  • Context.sample/3
  • Context.elicit/4
  • Context.elicit_url/4
  • Context.require_url_elicitation!/4
  • Context.list_roots/2
  • Context.cached_roots/1
  • Context.list_peer_tasks/2
  • Context.ping_peer/2

Sampling lets the server ask the connected client model to generate content. Form elicitation asks for schema-validated structured input, while URL elicitation coordinates an identity-bound out-of-band interaction. On the legacy profile these peer operations use the session coordinator over HTTP or stdio. Modern handlers return InputRequiredResult and the client performs the corresponding MRTR interaction without a session.

Context.sample/3, Context.elicit/4, and Context.elicit_url/4 return an immediate result by default. With task: true, sampling and elicitation return a %FastestMCP.PeerTask{} only when the client negotiated the exact requester task capability.

Protocol delivery helpers report failures explicitly. In particular, Context.log/4, Context.report_progress/4, and Context.send_notification/3 can return delivery, lifecycle, rate, or state errors. Custom notifications cannot use reserved standard MCP method names.

See:

Legacy Client Roots and Peer Ping

On 2025-11-25, Context.list_roots/2 requests the connected client's current filesystem roots after verifying the negotiated roots capability. Successful results are parsed into %FastestMCP.Root{} values and cached on the exact session:

roots = Context.list_roots(ctx)

if Enum.any?(roots, &FastestMCP.Root.contains?(&1, "file:///workspace/app/mix.exs")) do
  %{inside_declared_root: true}
end

Pass refresh: true to bypass the cache. A negotiated notifications/roots/list_changed refreshes the cache under runtime supervision. Only canonical file:// roots are accepted; FastestMCP.Root.safe_realpath/2 adds symlink-aware containment for paths on the server's local filesystem.

Context.ping_peer/2 sends an outbound MCP ping through the same session path and returns :ok only for the standard empty-object result.

Core 2026-07-28 has no independent roots callback or ping method. Modern tools, prompts, and resource reads request roots through an InputRequiredResult; the connected client reuses its configured roots handler during that MRTR round.

When the client negotiated tasks.list, Context.list_peer_tasks/2 returns %{items: tasks, next_cursor: cursor} for tasks owned by that peer. Continue with the opaque cursor: only; a page_size: option is ignored and never sent on the wire.

Nested Resource and Prompt Access

FastestMCP exposes nested resource and prompt helpers directly on the context:

  • Context.list_resources/1
  • Context.read_resource/2
  • Context.list_prompts/1
  • Context.render_prompt/3

Those helpers preserve the current auth, request metadata, task context, and legacy session when one component needs to call another surface inside the same server.

Example:

alias FastestMCP.Context

server =
  FastestMCP.server("nested-context")
  |> FastestMCP.add_resource("config://release", fn _arguments, _ctx ->
    %{name: "fastest_mcp", version: "0.1.0"}
  end)
  |> FastestMCP.add_tool("describe_release", fn _arguments, ctx ->
    config = Context.read_resource(ctx, "config://release")
    %{config: config}
  end)

Resource Update Notifications

If a handler changes data that subscribed clients read through resources, it can emit an update directly from the context:

alias FastestMCP.Context

server =
  FastestMCP.server("resource-updates")
  |> FastestMCP.add_tool("refresh", fn _arguments, ctx ->
    :ok = Context.notify_resource_updated(ctx, "config://release")
    %{ok: true}
  end)

That produces notifications/resources/updated for subscribed initialized HTTP or stdio sessions with a deliverable output sink.

Session Visibility

Context also owns session-local visibility rules:

  • Context.enable_components/2
  • Context.disable_components/2
  • Context.reset_visibility/1

These rules let one legacy session reveal or hide tools, resources, resource templates, and prompts without mutating the global registry for every client.

Selectors support:

  • names
  • keys
  • tags
  • components
  • version
  • match_all: true

Visibility changes can produce session-specific:

  • notifications/tools/list_changed
  • notifications/resources/list_changed
  • notifications/prompts/list_changed

when the visible set actually changes for that session.

Legacy Direct Notifications

Context.send_notification/3 lets a legacy handler send a raw MCP notification over the active client session stream:

alias FastestMCP.Context

server =
  FastestMCP.server("context-notifications")
  |> FastestMCP.add_tool("announce", fn _arguments, ctx ->
    {:ok, _delivery} =
      Context.send_notification(
        ctx,
        "com.example/notifications/build_completed",
        %{"buildId" => "build-42"}
      )

    %{ok: true}
  end)

That is mainly useful for advanced runtime integrations and custom session-stream behavior. Standard MCP notification names are reserved; use the typed resource, visibility, progress, logging, cancellation, task, roots, and elicitation helpers for those methods.

Current Compatibility Boundary

FastestMCP makes a few deliberate choices:

  • explicit handler ctx is the primary style
  • Context.current/0 and current!/0 are process-local convenience helpers, not the main programming model
  • ctx.server is the server accessor; there is no separate ctx.fastestmcp field

Choosing The Right Lifetime

Use:

  • request state for temporary scratch data
  • session state for conversation state
  • dependencies for request-scoped services
  • lifespan for server-wide startup state
  • task metadata for long-running background execution

Why This Shape

FastestMCP keeps context explicit because OTP lifetimes matter.

Request data, session data, auth state, startup state, and background task state should not all feel like the same invisible dependency. The context makes those boundaries visible, which keeps transport behavior, supervision, and cleanup rules easier to understand.