Skip to content
API Reference
Build tools

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, if you pin both dependencies:

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

Your server keeps working, but keycardai-mcp-fastmcp is retired and receives no further releases, including security patches. Pinning buys time; it isn’t a supported configuration.

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 is retired: 0.21.0 remains on PyPI so existing pins keep resolving, and its keycardai.mcp.integrations.fastmcp imports still forward to keycardai.fastmcp with 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 0.21.0 installed, the old path still resolves and emits a DeprecationWarning, but 0.21.0 is the final release.

  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, as long as you pin. keycardai-mcp-fastmcp==0.19.0 with fastmcp<3.0.0 keeps working. Without a pin, pip resolves 0.21.0, which forwards to keycardai-fastmcp and pulls FastMCP 3.1 or later.

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.