MCP Quickstart

This quickstart gets a minimal MCP-enabled ActingWeb server running with FastAPI, adds one MCP tool and one MCP prompt using the correct decorators, and shows how to test with simple JSON‑RPC calls.

Note

This is a two-stage recipe. Stage 1 (below) gets the server responding to initialize with no authentication. Every other MCP method — tools/list, tools/call, prompts/list, etc. — requires an OAuth2 bearer token; there is no dev bypass. Stage 2 is configuring real OAuth2 credentials and obtaining a token — see Stage 2: OAuth2 and a Bearer Token below. Do not expect tools/list to return anything but 401 until you’ve done both stages.

Requirements

  • Python 3.11+

  • A database. For local development, start DynamoDB Local:

    docker compose -f docker-compose.test.yml up dynamodb-test
    

    See Quickstart for PostgreSQL and other backend options. Without a running database, actor creation and every actor-scoped MCP call fail.

  • Install extras for FastAPI and MCP:

# pip
pip install 'actingweb[fastapi]'

# or with Poetry
poetry add actingweb -E fastapi

Minimal App

The full application code below is examples/mcp_quickstart.py, included directly by this page (not copied), so what you read here is exactly what runs.

aw = (
    ActingWebApp(
        aw_type="urn:actingweb:example.com:mcp",
        database="dynamodb",
        fqdn=os.getenv("APP_HOST_FQDN", "localhost:5000"),
        # Defaults to http:// for local development, matching the uvicorn
        # server started below (plain HTTP, no TLS). ActingWebApp defaults
        # to https:// -- left as that default here, the actor URLs and the
        # OAuth2 redirect_uri generated for Stage 2 would point at
        # https://localhost:..., which nothing on this server answers on.
        # Set APP_HOST_PROTO=https:// in production, matching your real proto.
        proto=os.getenv("APP_HOST_PROTO", "http://"),
    )
    .with_web_ui(True)
    # MCP is on by default. Optionally set the server name announced in
    # the initialise handshake -- some clients use this as the default
    # tool prefix (e.g. "myapp:create_note").
    .with_mcp(server_name="myapp")
    # Every MCP method beyond initialize requires an OAuth2 bearer token --
    # see the "Two Stages" note in docs/guides/mcp-quickstart.rst before
    # expecting tools/list or tools/call to work. Set these to test past
    # stage 1.
    .with_oauth(
        client_id=os.getenv("OAUTH_CLIENT_ID", ""),
        client_secret=os.getenv("OAUTH_CLIENT_SECRET", ""),
    )
)


@aw.lifecycle_hook("actor_created")
def init_actor(actor: ActorInterface, **kwargs):
    actor.properties.email = actor.creator
    actor.properties.created_at = datetime.now().isoformat()


# MCP tool: attach to an action hook and expose with @mcp_tool
@aw.action_hook("create_note")
@mcp_tool(description="Create a new note for this actor")
def create_note_tool(actor: ActorInterface, action_name: str, data: dict):
    title = data.get("title", "Untitled")
    content = data.get("content", "")
    key = f"note_{datetime.now().isoformat()}"
    actor.properties[key] = {"title": title, "content": content}
    return {"status": "ok", "note": key}


# MCP prompt: attach to a method hook and expose with @mcp_prompt
@aw.method_hook("analyze_notes")
@mcp_prompt(description="Summarize notes for this actor")
def analyze_notes_prompt(actor: ActorInterface, method_name: str, params: dict):
    notes = [v for k, v in actor.properties.items() if k.startswith("note_")]
    return f"Found {len(notes)} notes. Titles: " + ", ".join(
        n.get("title", "Untitled") for n in notes
    )


Wire it up and run it:

# examples/mcp_quickstart.py's __main__ block does this:
from fastapi import FastAPI

api = FastAPI(title="My MCP Server")
aw.integrate_fastapi(api)
# then: uvicorn.run(api, host="0.0.0.0", port=5000)
python examples/mcp_quickstart.py
# or: uvicorn myapp:api --reload --port 5000, against your own app module

Note

Async Hook Support: MCP tools and prompts can be async functions for optimal performance. FastAPI automatically uses AsyncMCPHandler which executes async hooks natively in the event loop without thread pool overhead. This enables true concurrent execution and significantly better performance for I/O-bound operations (database queries, API calls, etc.).

# Async MCP tool - optimal for I/O operations
@aw.action_hook("fetch_external_data")
@mcp_tool(description="Fetch data from external API")
async def fetch_data_tool(actor: ActorInterface, action_name: str, data: dict):
    async with aiohttp.ClientSession() as session:
        async with session.get(f"https://api.example.com/data/{data['id']}") as resp:
            result = await resp.json()
    return {"content": [{"type": "text", "text": str(result)}]}

Stage 1: Testing with JSON‑RPC

Call initialize (no auth required):

curl -s http://localhost:5000/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"curl"}}}'

Every other method — tools/list, prompts/list, tools/call, etc. — requires an OAuth2 bearer token (only initialize, notifications/initialized and ping — a liveness check — are unauthenticated). An unauthenticated GET /mcp is a 401 challenge too, not a discovery document. There is no dev bypass; with_devtest(True) does not open the MCP endpoint. Sending these without a token returns HTTP 401 with a WWW-Authenticate: Bearer header:

# 401 without a bearer token — see Stage 2 below to obtain one, then:
curl -s http://localhost:5000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <token>' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'

# Send MCP-Protocol-Version, or the request negotiates 2025-03-26 and the
# response carries no structuredContent even when your hook sets it.
curl -s http://localhost:5000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <token>' \
  -H 'MCP-Protocol-Version: 2025-06-18' \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"create_note","arguments":{"title":"Hello","content":"World"}}}'

A bearer token that authenticates but does not resolve to a trust relationship now returns an empty tools/list (not an error) and a -32003 on tools/call/prompts/get/resources/read — see Troubleshooting if that happens unexpectedly after upgrading.

Tip

To test tool/prompt logic without standing up an OAuth2 client, call the hooks directly in a unit test rather than over HTTP — e.g. app.hooks.execute_action_hooks("create_note", actor, {...}). Real MCP clients (ChatGPT, Claude) perform the OAuth2 flow and send the bearer token automatically.

Note that this checks your hook’s return value, not the JSON the client receives: it bypasses format_call_tool_result, so it cannot see whether structuredContent is emitted or suppressed. To assert on the wire shape, test the formatter as well — see testing-the-wire-shape in Building MCP Applications with ActingWeb.

Stage 2: OAuth2 and a Bearer Token

tools/list, tools/call, and every other MCP method beyond initialize need a real bearer token. Getting one takes two steps:

  1. Configure a login provider. examples/mcp_quickstart.py already calls .with_oauth(...), reading OAUTH_CLIENT_ID / OAUTH_CLIENT_SECRET from the environment — set these to real Google or GitHub OAuth2 app credentials. See OAuth2 Client Management System for creating one. Sign in through the web UI (http://localhost:5000/) once this is set, so an actor exists that an MCP client can be scoped to.

  2. Register an MCP client and obtain a token for it. MCP clients (ChatGPT, Claude, or a script) authenticate as a specific actor via a separate OAuth2 client registration and authorization-code flow, not your own login credentials. See Building MCP Applications with ActingWeb’s “OAuth2ClientManager Interface” and “Usage in AI Assistants” sections for the registration and token-exchange steps, and OAuth2 Client Management System for the underlying client storage model.

This is real infrastructure (a real OAuth2 provider, a running server reachable at its redirect URI) — there is no local-only shortcut that produces a valid bearer token without it.

Connecting a Real MCP Client

Once you have a bearer token (or a client that can obtain one via OAuth2), point an MCP client at http://localhost:5000/mcp (or your deployed URL).

Clients with native remote-MCP support (recent Claude.ai / Claude Desktop custom connectors, ChatGPT custom connectors): add the server URL directly in the client’s connector settings. The client performs the OAuth2 authorization-code flow itself and attaches the resulting bearer token to every request — you do not handle tokens manually.

Clients that expect a local (stdio) MCP server — most desktop MCP host configurations, including older Claude Desktop versions — need a proxy that speaks stdio on one side and Streamable HTTP + OAuth2 on the other. The mcp-remote npm package is the common choice:

{
  "mcpServers": {
    "myapp": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://localhost:5000/mcp"]
    }
  }
}

mcp-remote opens a browser for the OAuth2 flow on first connection and caches the resulting token. Check your specific client’s documentation for where this configuration file lives and whether it has since added native remote support, since this changes quickly across MCP clients.

Tool Safety Annotations

IMPORTANT: For production MCP servers, always add safety annotations to your tools. ChatGPT and other MCP clients use these to evaluate server safety:

@aw.action_hook("search")
@mcp_tool(
    description="Search your notes",
    annotations={
        "readOnlyHint": True,       # Only reads, never modifies
        "destructiveHint": False,   # Doesn't delete data
    }
)
def search(actor, action_name, data):
    pass

@aw.action_hook("delete_note")
@mcp_tool(
    description="Delete a note permanently",
    annotations={
        "destructiveHint": True,    # Destroys data - needs confirmation
        "readOnlyHint": False,
    }
)
def delete_note(actor, action_name, data):
    pass

Key annotations:

  • destructiveHint: True - Tool can permanently delete/destroy data

  • readOnlyHint: True - Tool only reads, never modifies data

  • idempotentHint: True - Same input always gives same result

  • openWorldHint: True - Tool accesses external services

See the MCP Applications Guide for complete documentation.

Recommendations

Where to Go Next