Skip to content
API Reference
Guides
MCP Servers

Migrate to FastMCP 3.0

Upgrade your Keycard-protected MCP server from FastMCP 2.x to 3.0

FastMCP 3.0 introduces async state management, which affects how your MCP tools interact with the Keycard SDK. This guide covers what changed and how to migrate your existing server.

Any MCP server built with:

  • keycardai-mcp-fastmcp (any version)
  • fastmcp>=2.x,<3.0.0

Yes. Pin your SDK dependency:

pyproject.toml
dependencies = [
"keycardai-mcp-fastmcp==0.19.0",
"fastmcp>=2.14.0,<3.0.0",
]

Your server will continue to work. The keycardai-mcp-fastmcp 0.19.x line will receive security patches only for 90 days after the 3.0-compatible release.

ctx.get_state() and ctx.set_state() are now async

Section titled “ctx.get_state() and ctx.set_state() are now async”

This is the only breaking change that affects Keycard users. In FastMCP 3.0, state access requires await:

# Before (FastMCP 2.x)
access_context = ctx.get_state("keycardai")
# After (FastMCP 3.0)
access_context = await ctx.get_state("keycardai")

This means any tool function that accesses ctx.get_state("keycardai") must be async def.

  • All Keycard SDK imports (AuthProvider, AccessContext, ClientSecret, etc.)
  • The @grant decorator API
  • AuthProvider configuration (zone_id, mcp_server_name, etc.)
  • AccessContext methods (.access(), .has_errors(), .get_errors())
  • mcp.run(transport="streamable-http") startup
  1. Update dependencies

    pyproject.toml
    dependencies = [
    "fastmcp>=3.0.0",
    "keycardai-mcp-fastmcp>=0.20.0", # FastMCP 3.0-compatible
    ]

    Then re-install:

    Terminal window
    uv sync
    # or
    pip install -U fastmcp keycardai-mcp-fastmcp
  2. Update tool functions that use @grant

    Add await to every ctx.get_state("keycardai") call and ensure the function is async def:

    # Before
    @mcp.tool()
    @auth_provider.grant("https://api.example.com")
    def my_tool(ctx: Context, query: str):
    access_context = ctx.get_state("keycardai")
    if access_context.has_errors():
    return {"error": access_context.get_errors()}
    token = access_context.access("https://api.example.com").access_token
    return call_api(token, query)
    # After
    @mcp.tool()
    @auth_provider.grant("https://api.example.com")
    async def my_tool(ctx: Context, query: str):
    access_context = await ctx.get_state("keycardai")
    if access_context.has_errors():
    return {"error": access_context.get_errors()}
    token = access_context.access("https://api.example.com").access_token
    return call_api(token, query)
  3. Update chained expressions

    If you chain .access() directly after ctx.get_state(), wrap with parentheses:

    # Before
    token = ctx.get_state("keycardai").access("https://api.example.com").access_token
    # After
    token = (await ctx.get_state("keycardai")).access("https://api.example.com").access_token
    # Or assign first (cleaner)
    access_context = await ctx.get_state("keycardai")
    token = access_context.access("https://api.example.com").access_token
  4. Update test mocks

    If your tests mock ctx.get_state, make it async:

    from unittest.mock import AsyncMock
    # Before
    mock_ctx = Mock(spec=Context)
    mock_ctx.get_state.return_value = mock_access_ctx
    # After
    mock_ctx = Mock(spec=Context)
    mock_ctx.get_state = AsyncMock(return_value=mock_access_ctx)

    Or use the SDK’s built-in test utility:

    from keycardai.mcp.integrations.fastmcp import mock_access_context
    with mock_access_context(resource_tokens={"https://api.example.com": "test_token"}):
    result = await my_tool(mock_ctx, "test query")
  5. Run and verify

    Terminal window
    # Start the server
    uv run python -m your_server
    # Or run tests
    uv run pytest

Sync functions that call ctx.get_state() — You cannot await inside a def. Change it to async def. Since most tool functions already make async HTTP calls, this is usually a natural fit.

Forgetting await — If you see <coroutine object Context.get_state at 0x...> instead of your AccessContext, you forgot the await.

serializable=False — If you store custom objects in state with ctx.set_state(), FastMCP 3.0 requires JSON-serializable values by default. Pass serializable=False for non-serializable objects. The Keycard SDK handles this internally for AccessContext, but if you’re setting your own state values, you may need to add this parameter.

Will my server break if I don’t upgrade? No. If you don’t upgrade keycardai-mcp-fastmcp, pip will keep you on FastMCP 2.x. Nothing changes.

Can I run 2.x and 3.x servers side by side? Yes. Each server is an independent process with its own dependencies. One server can use FastMCP 2.x while another uses 3.x.

Do I need to change anything in Keycard Console? No. The Keycard configuration (zones, resources, scopes) is unchanged. This migration is purely a code change in your server.