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.

Requirements

  • Python 3.11+

  • Install extras for FastAPI and MCP:

# pip
pip install 'actingweb[fastapi]'

# or with Poetry
poetry add actingweb -E fastapi

Minimal App

# app_mcp.py
import os
from datetime import datetime
from fastapi import FastAPI
from actingweb.interface import ActingWebApp, ActorInterface
from actingweb.mcp import mcp_tool, mcp_prompt

api = FastAPI(title="My MCP Server")

# Configure ActingWeb
aw = (
    ActingWebApp(
        aw_type="urn:actingweb:example.com:mcp",
        database="dynamodb",
        fqdn=os.getenv("APP_HOST_FQDN", "localhost:5000"),
    )
    .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")
    # Configure OAuth2 for real auth in production (example only)
    # .with_oauth(client_id=os.getenv("OAUTH_CLIENT_ID"), client_secret=os.getenv("OAUTH_CLIENT_SECRET"))
)

# Lifecycle example
@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)

# Integrate all ActingWeb routes on FastAPI app
aw.integrate_fastapi(api)

# Run: uvicorn app_mcp:api --reload --port 5000

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)}]}

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 and notifications/initialized are unauthenticated). 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 — obtain one via the OAuth2 flow first, 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"}'

curl -s http://localhost:5000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <token>' \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"create_note","arguments":{"title":"Hello","content":"World"}}}'

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.

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

  • For production, enable OAuth2 with Google/GitHub and ensure /mcp returns 401 with a proper WWW-Authenticate header for unauthenticated clients.

  • Use the unified access control to filter tools/prompts per trust relationship.