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.
Who is affected
Section titled “Who is affected”Any MCP server built with:
keycardai-mcp-fastmcp(any version)fastmcp>=2.x,<3.0.0
Can I stay on FastMCP 2.x?
Section titled “Can I stay on FastMCP 2.x?”Yes. Pin your SDK dependency:
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.
What changed
Section titled “What changed”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.
What stays the same
Section titled “What stays the same”- All Keycard SDK imports (
AuthProvider,AccessContext,ClientSecret, etc.) - The
@grantdecorator API AuthProviderconfiguration (zone_id, mcp_server_name, etc.)AccessContextmethods (.access(),.has_errors(),.get_errors())mcp.run(transport="streamable-http")startup
Migration steps
Section titled “Migration steps”-
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# orpip install -U fastmcp keycardai-mcp-fastmcp -
Update tool functions that use
@grantAdd
awaitto everyctx.get_state("keycardai")call and ensure the function isasync 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_tokenreturn 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_tokenreturn call_api(token, query) -
Update chained expressions
If you chain
.access()directly afterctx.get_state(), wrap with parentheses:# Beforetoken = ctx.get_state("keycardai").access("https://api.example.com").access_token# Aftertoken = (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 -
Update test mocks
If your tests mock
ctx.get_state, make it async:from unittest.mock import AsyncMock# Beforemock_ctx = Mock(spec=Context)mock_ctx.get_state.return_value = mock_access_ctx# Aftermock_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_contextwith mock_access_context(resource_tokens={"https://api.example.com": "test_token"}):result = await my_tool(mock_ctx, "test query") -
Run and verify
Terminal window # Start the serveruv run python -m your_server# Or run testsuv run pytest
Common gotchas
Section titled “Common gotchas”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.