MCP Lifecycle: Init, Discovery, Invocation
A session runs initialise → capability negotiation → listing → invocation over a transport such as stdio or streamable HTTP, with auth and error handling at each step.
Session phases
Every session begins with an initialize handshake. The client sends its protocol version and capabilities (for example whether it can handle sampling requests or roots); the server responds with its own version and which primitives it offers. If versions are incompatible, the client should fail loudly here — not three tool calls later.
After notifications/initialized, the client discovers what is available: tools/list, resources/list, prompts/list. These are paginated, and servers can send notifications/tools/list_changed so the client re-fetches. Only after discovery does the client convert tools into model-facing definitions.
Invocation is tools/call with a name and arguments validated against the schema. The result is a list of content blocks (text, image, embedded resource) plus an isError flag. Application-level failures ("repository not found") travel inside the result; protocol failures travel as JSON-RPC errors.
Transports
stdio: the host spawns the server as a child process and exchanges newline-delimited JSON-RPC over stdin/stdout. Zero network setup, inherits the user's local credentials and filesystem — ideal for desktop tools and IDEs, and the reason a local file server can read your repo.
Streamable HTTP: the server is a remote endpoint; requests are HTTP POSTs and the server may stream responses (and server-initiated messages) using server-sent events. This is the shape for shared, multi-tenant servers and needs real authentication.
Transport choice is an operational decision, not a semantic one: the messages are identical. What changes is who holds credentials, how you observe traffic, and what latency you pay — a stdio round trip is sub-millisecond; a remote HTTP call is tens of milliseconds plus the upstream API.
Authentication, permissions and errors
For remote servers the spec leans on OAuth 2.1 with PKCE: the client obtains a token scoped to the server, and the server uses it (or exchanges it) for upstream access. The important architectural point is that the token belongs to the user, not the agent — the agent can only do what that person could do, and every call is attributable in the audit log.
Permissions are layered: the upstream API scope, the server's allow-list of tools, and the host's per-tool policy (auto-run, ask, deny). Design for the innermost layer to be tightest (Permissions, Authentication and Authorisation).
Errors come in two flavours. JSON-RPC errors (-32602 invalid params, -32601 method not found) indicate a protocol or contract bug and should be surfaced to engineers. Tool results with isError: true are domain failures the model can reason about — "no repository named acme/biling; did you mean acme/billing?" — and follow the retry rules in Tool Errors, Retries and Timeouts.
1async def call_tool(session, name: str, args: dict) -> str:2 try:3 result = await session.call_tool(name, args) # JSON-RPC round trip4 except McpProtocolError as e: # contract bug: log, do not retry blindly5 log.error("mcp protocol error", tool=name, code=e.code)6 raise7 text = "\n".join(b.text for b in result.content if b.type == "text")8 if result.isError: # domain failure: give it to the model9 return f"TOOL_ERROR: {text[:500]}"10 return text[:4000] # cap what enters the contextKey points
- Order: initialise → capability negotiation → list → call; fail fast on version mismatch.
- stdio for local, credential-inheriting servers; streamable HTTP for remote shared ones.
- Remote auth uses OAuth 2.1 tokens scoped to the user, keeping actions attributable.
- Protocol errors and tool
isErrorresults are different channels with different handling. - Cap and sanitise tool results before they enter the context.
- Listen for
list_changednotifications instead of caching tool lists forever.
When to use — and when not to
- You are wiring an MCP client and need a mental model of the session states.
- Debugging "tool not found" or "invalid params" — check which phase failed.
- Deciding between local stdio servers and hosted HTTP servers.
- Do not build a custom transport; the two standard ones cover local and remote.
- Do not skip capability negotiation to save a round trip — you lose version safety.
- Do not treat a remote server's token as a shared service credential; per-user scoping is the point.
Failure modes
- Cached tool list goes stale after a server deploy; calls hit renamed tools.
- Retrying JSON-RPC errors as if they were transient, masking a schema bug.
- Unbounded tool results (a 30k-line log) blow the context window.
- stdio server inherits broad local permissions the user did not intend to grant.
- Missing timeouts on remote calls leave the agent loop hanging (
agent-loop-not-terminating).
Tradeoffs
Standardised messages make traces uniform, which helps debugging once tracing is in place.