---
title: Migrate to FastMCP 3.0 | Keycard
description: 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.

Tip

**Not using FastMCP?** If your server uses the `keycardai-mcp` package (standard MCP SDK integration), this guide does not apply. Only servers using `keycardai-mcp-fastmcp` are affected.

## 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?

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.

## What changed

### `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

- 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

## Migration steps

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
   ```

## 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.

## FAQ

**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.
