MCP Lifecycle Management

Context manager and cleanup for MCP connections

MCP connections in PraisonAI Agents support context managers and explicit shutdown() so subprocesses, streams, and sockets close reliably.

MCP(...) accepts three equivalent construction forms — see Three equivalent forms.

python
from praisonaiagents import Agent, MCP

with MCP("uvx mcp-server-time") as mcp:
    agent = Agent(name="TimeAgent", instructions="Report the current time.", tools=mcp)
    agent.start("What time is it in UTC?")

The user runs an agent inside an MCP context manager; connections shut down cleanly when the session ends.

Diagram

🤖 Agent

MCP

with MCP …

shutdown

✅ Clean exit

Quick Start

  1. Simple Usage
    python
    from praisonaiagents import Agent, MCP
    
    with MCP("uvx mcp-server-time") as mcp:
        agent = Agent(
            name="TimeAgent",
            instructions="Get the current time",
            tools=mcp
        )
        print(agent.start("What time is it?"))
    # Connection closed automatically
  2. With Configuration
    python
    import os
    from praisonaiagents import Agent, MCP
    
    with MCP(
        command="npx",
        args=["-y", "@modelcontextprotocol/server-brave-search"],
        env={"BRAVE_API_KEY": os.getenv("BRAVE_API_KEY")},
        timeout=30
    ) as mcp:
        agent = Agent(name="SearchAgent", tools=mcp)
        agent.start("Search for Python tutorials")

How It Works

Diagram
MCP ConnectionAgentUserMCP ConnectionAgentUserwith MCP(…) as mcpStart subprocess / open streamagent.start(request)Call toolsTool resultsResponse__exit__ calls shutdown()
PhaseWhat happens
1. EnterThe with block opens the MCP connection
2. UseThe agent calls MCP-provided tools
3. Exitshutdown() closes subprocesses, streams, and sockets

Agent-Managed Cleanup

When you pass an MCP client through the constructor (tools=[MCP(...)]), agent.close() and agent.aclose() now walk the agent’s tools and shut down anything exposing .shutdown() / .aclose() — so the MCP subprocess and its background thread are cleaned up with the agent.

python
from praisonaiagents import Agent
from praisonaiagents.mcp import MCP

agent = Agent(
    name="Coder",
    tools=[MCP("uvx some-mcp-server")],
    instructions="Use MCP tools",
)
try:
    agent.start("do work")
finally:
    agent.close()   # now correctly shuts down MCP subprocesses/threads
                    # (async: `await agent.aclose()`)

Manual Cleanup

For cases where a context manager is not suitable:

python
from praisonaiagents import MCP

mcp = MCP("uvx mcp-server-time")

try:
    tools = mcp.get_tools()
finally:
    mcp.shutdown()

Request Cancellation

An MCP client can abort an in-flight request by sending a notifications/cancelled notification naming the requestId it wants to stop.

Diagram
ServerClientServerClientA cancel that arrives before the taskis registered is honoured immediately aftertools/call (id=42)notifications/cancelled (requestId=42)task.cancel()-32603 “Request cancelled”
  1. Send the request

    The client calls a tool with a JSON-RPC id it can reference later.

    json
    { "jsonrpc": "2.0", "id": 42, "method": "tools/call", "params": { "name": "search" } }
  2. Cancel it

    The client sends a cancellation notification with the same id as requestId.

    json
    { "jsonrpc": "2.0", "method": "notifications/cancelled", "params": { "requestId": 42 } }
  3. Receive the cancelled response

    The server cancels the running task and replies with a JSON-RPC error.

    json
    { "jsonrpc": "2.0", "id": 42, "error": { "code": -32603, "message": "Request cancelled" } }
BehaviourWhat happens
ResponseCancelled id-bearing requests return -32603 "Request cancelled"; notifications receive nothing
OrderingA cancel that arrives before the task is registered is stashed and applied immediately after registration
OverflowUp to 10,000 pending cancel ids are kept in FIFO order — the oldest are evicted first, so a burst never drops the newest cancel

Lifecycle Methods

Authenticating the HTTP transport

When api_key is configured on the MCP HTTP-stream server, all of GET, POST, and DELETE require:

Authorization: Bearer <key>

Comparison uses constant-time hmac.compare_digest (timing-attack resistant). Missing or wrong tokens return 401 Unauthorized with {"error": "Unauthorized"}. DELETE returning 401 instead of 405 prevents information disclosure about whether sessions exist.

bash
curl -H "Authorization: Bearer ${MCP_API_KEY:?Set MCP_API_KEY}" https://localhost:8080/mcp

__enter__ / __exit__

Context manager protocol for automatic resource management:

python
with MCP(command) as mcp:
    pass
# __exit__ calls shutdown() automatically

shutdown()

Explicitly close all connections and cleanup resources:

python
mcp = MCP("uvx mcp-server-time")
mcp.shutdown()

shutdown() closes an MCP connection. For stdio transports it enqueues a sentinel on the runner queue, joins the background thread, and lets the transport terminate its child process. For HTTP-stream / WebSocket transports it dispatches to close() (or aclose() if awaitable). It is idempotent — a second call is a no-op — and it never raises: cleanup errors are swallowed as best-effort.

TransportClient method invokedCleanup performed
Stdiosubprocess terminate + stream closeSentinel → thread join → subprocess terminate
SSEHTTP stream closeStream close
HTTP Streamclose() (fallback)Session close
WebSocketclose() (fallback)Socket close

Idempotency & retry

shutdown() is safe to call multiple times:

python
from praisonaiagents import MCP

mcp = MCP("uvx mcp-server-time")
mcp.shutdown()
mcp.shutdown()   # no-op — self.runner is None

If an in-flight tool call keeps the runner thread alive past the join timeout, the runner reference is kept so a later shutdown() can retry the join. The thread is a daemon, so a surviving runner never blocks interpreter exit.

__del__

Destructor ensures cleanup even if shutdown() was not called:

python
mcp = MCP("uvx mcp-server-time")
del mcp  # __del__ runs a non-blocking cleanup

Garbage-collection safety

A collected MCP never blocks the garbage collector.

Diagram

Lock free

Lock busy

🗑️ GC

⚙️ del

📡 Signal runner
no join

🔓 Release names
non-blocking

✅ Released

📥 Queue release

🔁 Next lock holder
drains queue

shutdown() / with blockCollected without cleanup (__del__)
Stdio runner threadSignalled, then joined; subprocess terminatedSignalled only, no join (daemon thread exits on its own)
Server-name registrationReleased under the lockReleased without waiting; queued if the lock is busy
WebSocket transportWaits for the close to finishclose(wait=False) only schedules the close

Connection Types

MCP supports multiple connection types, all with proper cleanup:

python
# Stdio
with MCP("uvx mcp-server-time") as mcp: ...

# SSE
with MCP("http://localhost:8080/sse") as mcp: ...

# HTTP Stream — pass the full endpoint URL your server exposes (e.g. /mcp)
with MCP("http://localhost:8080/mcp") as mcp: ...

# WebSocket
with MCP("ws://localhost:8080") as mcp: ...

All four transports now close uniformly on shutdown(). HTTP-stream and WebSocket close via the close() fallback inside MCP.shutdown() — previously these two leaked subprocesses/threads on shutdown, while stdio and SSE cleaned up correctly.

Long-running processes

Gateways, HTTP invoke APIs, Jupyter kernels, and CI test runners create many Agent(mcp=...) instances over their lifetime. Every stdio MCP spawns a child process (npx / node) and a daemon runner thread. If those aren’t torn down when the agent is done, the host process leaks one child per MCP per agent.

Diagram
npx / nodeMCPAgentLong-lived Processnpx / nodeMCPAgentLong-lived Processloop[Each request]With PRAgent(tools=[MCP(…)])__init__spawn stdio subprocessagent.start(…)agent.close()shutdown()terminate (thread join + stream close)

The recommended pattern for long-lived hosts is a try/finally around every agent that carries MCP tools:

python
from praisonaiagents import Agent, MCP

def handle_request(prompt: str) -> str:
    agent = Agent(
        name="Assistant",
        tools=[MCP("uvx mcp-server-time")],
    )
    try:
        return agent.start(prompt)
    finally:
        agent.close()   # walks tools, calls MCP.shutdown() on each

Async equivalent:

python
import asyncio
from praisonaiagents import Agent, MCP

async def handle_request(prompt: str) -> str:
    async with Agent(
        name="Assistant",
        tools=[MCP("uvx mcp-server-time")],
    ) as agent:
        return await agent.achat(prompt)

Crash Recovery

A crashed stdio server respawns on the next call, up to 3 times, and the call that saw the crash is not replayed. See Auto-respawn on crash.


Best Practices

Always use a context manager

Prefer with MCP(...) as mcp: — cleanup runs even when an exception is raised.

Handle exceptions inside the block

Wrap tool calls in try/except inside the with block; __exit__ still closes the connection.

Nest multiple MCP instances carefully

Open each MCP in its own with block or nest them — both instances shut down in reverse order.

Pass secrets via env, not inline

Use the env= parameter with os.getenv(...) rather than hard-coding API keys in recipe files.

Close every agent in long-running hosts

Gateways, API servers, and notebooks accumulate leaked node / npx children if MCP.shutdown() (or agent.close()) is never called. Wrap each request or session in try/finally and call agent.close() — or use with MCP(...) as mcp: for per-MCP scope. Since PR #5078, shutdown() reliably terminates the stdio subprocess and joins the runner thread.

Related