Skip to content

Model Context Protocol (MCP)

Padwan AI ships two MCP client transports — McpStreamable (HTTP) and McpStdio (subprocess) — both implementing the MCP 2025-11-25 spec. They expose the same tools interface, but their internal concurrency models differ in important ways.

Quick start

from padwan_ai import McpStreamable, McpStdio

# Streamable HTTP — remote MCP server (with optional bearer token)
async with McpStreamable(url="https://mcp.example.com/mcp", token="sk-...") as mcp:
    for tool in mcp.tools:
        print(tool.name, tool.description)
    result = await mcp.tools[0].handler({"query": "test"})

# Stdio — local subprocess
async with McpStdio(command="uvx", args=["my-mcp-server"]) as mcp:
    tool = next(t for t in mcp.tools if t.name == "search")
    result = await tool.handler({"query": "hello"})

Feature comparison

Feature McpStreamable McpStdio
Tool discovery ✅ ✅
Tool calls ✅ ✅
Session management ✅ —
SSE streaming ✅ —
Reconnectable listener ✅ (Last-Event-ID) —
Bearer token auth ✅ (token, on_auth refresh) —
Progress notifications ✅ (on_progress) ✅ (on_progress)
Ping ✅ ✅
Request cancellation ✅ (cancel()) ✅ (cancel())

Constructor parameters

McpStreamable

Parameter Default Description
url required MCP server endpoint.
token None Bearer token, sent as Authorization: Bearer <token>.
on_progress None Callback for notifications/progress; also makes tools/call send _meta.progressToken.
on_auth None Callback to refresh token on HTTP 401 — see Token refresh on 401.
client_name / client_version "padwan-ai" / package version Sent as clientInfo in initialize.
name_prefix None Namespace for this transport's tools; used by AgentSession to avoid name collisions.

McpStdio

Parameter Default Description
command required Executable to spawn.
args [] Arguments passed to command.
env None Subprocess environment. Replaces the process environment entirely, it does not merge into it — pass os.environ \| {...} to extend rather than overwrite. None inherits the parent's environment as-is.
cwd None Working directory for the subprocess.
on_progress None Same as McpStreamable.on_progress.
client_name / client_version same defaults Same as McpStreamable.
name_prefix None Same as McpStreamable.

McpStreamable — dual-channel architecture

The streamable HTTP transport runs two concurrent communication channels against the same endpoint:

flowchart LR
    subgraph client["McpStreamable client"]
        Caller["User code<br/>tool.handler"]
        RPC["_rpc<br/>foreground POST"]
        Listen["_listen<br/>background GET task"]
        Tools["tools list<br/>(_tools)"]
    end

    subgraph server["MCP server"]
        Endpoint["/mcp"]
    end

    Caller -->|1 await| RPC
    RPC -->|2 POST JSON-RPC| Endpoint
    Endpoint -->|3 response JSON or SSE| RPC
    RPC -->|4 result| Caller

    Listen -.->|GET SSE persistent| Endpoint
    Endpoint -.->|server-pushed notifications| Listen
    Listen -.->|tools list_changed, refresh| Tools
    Listen -.->|progress, on_progress callback| Caller

The foreground channel (_rpc) handles request/response: you await tool.handler(...), it POSTs a JSON-RPC request, gets back either a single JSON response or an SSE stream containing the response. This is what user code interacts with directly.

The background channel (_listen) is a persistent GET against the same /mcp endpoint, opened during __aenter__ and torn down in __aexit__. It receives unsolicited server pushes — things the server wants to tell you without being asked, like notifications/tools/list_changed (server's tool list changed, we should re-fetch) or out-of-band notifications/progress for long-running operations. It is opened without a read timeout: a quiet stream is normal, and timing it out would count as a listener failure.

This separation means the listener can deliver notifications even while you have no in-flight RPC. Without it, a server that wanted to tell you "my tools changed" would have to wait until you happened to ask for something.

Notifications aren't limited to the background channel: if a POST's response is itself an SSE stream, _rpc also handles tools/list_changed and progress events that arrive inline before the response.

Setting on_progress makes tools/call send _meta.progressToken with the request, so a spec-compliant server knows to emit notifications/progress for that call. Without on_progress, no token is sent and no progress notifications arrive.

McpStreamable — full lifecycle

sequenceDiagram
    participant App as User code
    participant Client as McpStreamable
    participant Listener as _listen task
    participant Server as MCP server

    Note over App,Server: aenter
    App->>Client: async with McpStreamable
    Client->>Server: POST initialize
    Server-->>Client: result plus MCP-Session-Id header
    Client->>Server: POST notifications/initialized
    Client->>Server: POST tools/list
    Server-->>Client: tool list
    Client->>Listener: spawn background task
    Listener->>Server: GET /mcp (Accept text/event-stream)
    Server-->>Listener: SSE stream open

    Note over App,Server: Operation
    App->>Client: await tool.handler(args)
    Client->>Server: POST tools/call
    Server-->>Client: result (JSON or SSE)
    Client-->>App: return result

    Server-->>Listener: notifications/progress
    Listener->>App: on_progress(event)

    Server-->>Listener: notifications/tools/list_changed
    Listener->>Server: POST tools/list (refresh)
    Server-->>Listener: updated tool list

    Note over App,Server: aexit
    App->>Client: exit context
    Client->>Listener: cancel task
    Client->>Server: close session

The key thing to notice: the background _listen task is spawned during __aenter__ and lives for the entire duration of the async with block. It runs in parallel with all your RPC calls and can deliver notifications at any moment.

Session recovery and cleanup

If a request gets HTTP 404 with an active session (the server forgot it), the client re-initializes once and retries the request. On __aexit__, the client sends DELETE to end the session server-side.

Token refresh on 401

Set on_auth to recover from expired bearer tokens. This applies to request/response RPCs only (initialize, tools/list, tools/call, ping) — when one returns HTTP 401, the client invokes the callback (sync or async), stores the returned token, and retries the request exactly once. The notifications/initialized POST, cancel(), and the background listener's GET don't call on_auth; a 401 on the listener GET instead burns one of its reconnect attempts.

async def refresh(transport: McpStreamable) -> str:
    return await oauth.fetch_token(...)


async with McpStreamable(url="...", token=initial_token, on_auth=refresh) as mcp:
    ...

Without on_auth, a 401 raises RuntimeError.

Reconnection with Last-Event-ID

If the SSE stream drops mid-session, _listen automatically reconnects and tells the server to resume from the last event it successfully processed. This uses the standard SSE Last-Event-ID header.

sequenceDiagram
    participant Listener as _listen task
    participant Server as MCP server

    Listener->>Server: GET /mcp (no Last-Event-ID)
    Server-->>Listener: event id="evt-1"
    Note over Listener: _last_event_id = "evt-1"
    Server-->>Listener: event id="evt-2"
    Note over Listener: _last_event_id = "evt-2"
    Server--xListener: connection drops

    Note over Listener: retries < max_retries<br/>sleep(_retry_ms)

    Listener->>Server: GET /mcp<br/>Last-Event-ID: evt-2
    Server-->>Listener: event id="evt-3"<br/>(resumes after evt-2)

The retry budget allows up to 5 consecutive failed reconnects (not configurable) before the listener gives up; a clean stream end counts as one retry too, and a successful reconnect resets the counter back to 0. The inter-attempt delay is controlled by the server via the SSE retry: field — if the server sends retry: 5000, the next reconnect waits 5 seconds. The client falls back to _DEFAULT_RETRY_MS = 3000 ms otherwise.

Once the 5 consecutive failures are exhausted, the listener logs a warning and stops. is_open stays True — the transport is still usable for RPCs — but push notifications (progress events, tools/list_changed) no longer arrive.

If the server returns 405 Method Not Allowed on the GET, the listener exits cleanly — that signals "this server doesn't support push notifications," which is allowed by the spec.

McpStdio — single-pipe architecture

The stdio transport launches the MCP server as a child process and communicates over its stdin/stdout. There's only one bidirectional pipe (NDJSON-framed), so the concurrency model is different from streamable HTTP:

flowchart LR
    subgraph client["McpStdio parent process"]
        Caller["User code<br/>tool.handler"]
        RPC["_rpc<br/>writes to stdin"]
        Reader["_reader task<br/>reads stdout"]
        Pending["_pending<br/>id to Future map"]
        OnProg["on_progress callback"]
    end

    subgraph child["MCP server subprocess"]
        Stdin["stdin"]
        Stdout["stdout"]
    end

    Caller -->|await| RPC
    RPC -->|register Future by id| Pending
    RPC -->|write JSON line| Stdin
    Stdout -->|read line| Reader
    Reader -->|match id, resolve Future| Pending
    Pending -->|future result| Caller
    Reader -->|notification without id| OnProg

Because both directions share a single pipe, there's a single background reader task parsing NDJSON lines as they arrive. Each outgoing request gets a unique JSON-RPC id, and the reader uses that id to look up the right asyncio.Future and resolve it with the response. Notifications (messages without an id) are dispatched directly to the appropriate handler — tools/list_changed triggers a refresh, progress invokes the user's on_progress callback. Server-initiated requests (e.g. a server ping) and messages with an unrecognized id fall into the same branch as notifications and are silently ignored — the client never answers them.

Compared to McpStreamable:

  • No session management — the connection is the process lifetime
  • No reconnection logic — if the subprocess dies, the connection is dead, all pending futures get an exception
  • No HTTP semantics — no headers, no auth, no Last-Event-ID. The protocol is just newline-delimited JSON-RPC over pipes
  • One reader, many waiters — the reader is a fan-out point that resolves multiple pending futures from a single byte stream

Cancellation

Both transports support cancel(request_id), which sends a notifications/cancelled message to the server. The server is expected to abort the in-flight operation and stop sending progress events for it. This is best-effort — the spec doesn't require the server to actually stop, just to acknowledge the request.

cancel(request_id) is low-level: request ids are generated internally (uuid4().hex for McpStreamable, an incrementing counter for McpStdio) and never exposed to callers, so there's no supported way to obtain a request_id to pass it.

The user-facing way to cancel is to cancel the asyncio.Task that's awaiting tool.handler(...). The transport catches CancelledError, sends the cancellation notification, and re-raises.

See also