Skip to content
API Reference

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.

The FastMCP 3.x integration ships as keycardai-fastmcp. The old keycardai-mcp-fastmcp package remains as a deprecated bridge: its keycardai.mcp.integrations.fastmcp imports keep working but emit a DeprecationWarning. Migrating to FastMCP 3.0 is the natural moment to switch.

  • All Keycard SDK names (AuthProvider, AccessContext, ClientSecret, etc.); only the import path changes
  • 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

    Replace keycardai-mcp-fastmcp with keycardai-fastmcp:

    pyproject.toml
    dependencies = [
    "fastmcp>=3.1.0",
    "keycardai-fastmcp", # replaces keycardai-mcp-fastmcp
    ]

    Then re-install:

    Terminal window
    uv sync
    # or
    pip install -U fastmcp keycardai-fastmcp
  2. Update imports

    # Before
    from keycardai.mcp.integrations.fastmcp import AccessContext, AuthProvider
    # After
    from keycardai.fastmcp import AccessContext, AuthProvider

    If you keep keycardai-mcp-fastmcp installed, the old path still works through the bridge and emits a DeprecationWarning.

  3. 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)
  4. 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
  5. 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.fastmcp.testing import mock_access_context
    with mock_access_context(resource_tokens={"https://api.example.com": "test_token"}):
    result = await my_tool(mock_ctx, "test query")
  6. 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.