Costruire un agente di ricerca web con il Claude Agent SDK e un budget rigido

Circa 60 righe di Python: Claude Agent SDK più il server MCP di FastAF per ricerca, lettura delle pagine e Google Scholar. Un’esecuzione reale è costata 11 crediti.

A un agente di ricerca servono tre cose: un modo per trovare le fonti, un modo per leggerle e un limite a quanto può spendere per fare entrambe. Il Claude Agent SDK ti dà il ciclo. Il server MCP ospitato di FastAF ti dà i tool. Questo articolo li collega in circa 60 righe di Python, li fa girare su una domanda vera e mostra quanto è costato.

Cosa ti serve prima di cominciare?

Python 3.10 o successivo, l’SDK e due chiavi:

python3 -m venv .venv && source .venv/bin/activate
pip install claude-agent-sdk
export ANTHROPIC_API_KEY=...   # come da quickstart dell'Agent SDK
export FASTAF_API_KEY=...      # dalla tua dashboard FastAF

Il pacchetto è claude-agent-sdk (versione 0.2.168 su PyPI, caricata il 15 ottobre 2026) e include il binario di Claude Code, quindi non c’è altro da installare. Su una macchina di sviluppo usa anche un eventuale login di Claude Code già presente, ed è così che è stata fatta l’esecuzione qui sotto. La panoramica dell’Agent SDK di Anthropic, verificata il 19 ottobre 2026, dice che i prodotti costruiti sull’SDK devono autenticarsi con una chiave API e non con il login di claude.ai, quindi in produzione usa ANTHROPIC_API_KEY.

Il lato FastAF è un solo URL: https://api.fastaf.co/mcp, Streamable HTTP, con la tua chiave in un header Bearer. Espone 15 tool; questo agente ne usa tre. search_web e search_scholar costano 5 crediti l’uno, scrape_page costa 1 credito per una pagina statica. La documentazione MCP elenca gli altri.

L’agente

import asyncio, json, os, sys
from claude_agent_sdk import (
    query, ClaudeAgentOptions, HookMatcher, AssistantMessage, UserMessage,
    ResultMessage, ToolUseBlock, ToolResultBlock,
)

MAX_CALLS = 6
SYSTEM = f"""You are a research agent. Answer only from what the tools return.
Use at most {MAX_CALLS} tool calls, one at a time. If a tool returns RATE_LIMITED, retry it.
search_web and search_scholar find sources; scrape_page reads the 1-3 best ones.
Answer in under 250 words. Cite every claim as [n] and list the URLs at the end."""

calls = 0
async def cap_calls(input_data, tool_use_id, context):
    global calls
    calls += 1
    if calls <= MAX_CALLS:
        return {}
    return {"hookSpecificOutput": {
        "hookEventName": input_data["hook_event_name"],
        "permissionDecision": "deny",
        "permissionDecisionReason": "Tool budget used up. Answer with what you have.",
    }}

options = ClaudeAgentOptions(
    model="claude-sonnet-5-5",
    system_prompt=SYSTEM,
    mcp_servers={"fastaf": {
        "type": "http",
        "url": "https://api.fastaf.co/mcp",
        "headers": {"Authorization": f"Bearer {os.environ['FASTAF_API_KEY']}"},
    }},
    tools=[],                                     # nessun tool integrato (Bash, Read, ...)
    allowed_tools=[f"mcp__fastaf__{t}" for t in ("search_web", "scrape_page", "search_scholar")],
    permission_mode="dontAsk",                    # tutto ciò che non è consentito viene negato
    setting_sources=[],                           # ignora le impostazioni locali di Claude Code
    env={"ENABLE_CLAUDEAI_MCP_SERVERS": "false"}, # niente connettori di claude.ai
    hooks={"PreToolUse": [HookMatcher(matcher="mcp__fastaf__.*", hooks=[cap_calls])]},
    max_turns=10,
    max_budget_usd=0.50,
)

async def main(question):
    credits = 0
    async for msg in query(prompt=question, options=options):
        if isinstance(msg, AssistantMessage):
            for b in msg.content:
                if isinstance(b, ToolUseBlock):
                    print(f"-> {b.name.removeprefix('mcp__fastaf__')} {b.input}")
        elif isinstance(msg, UserMessage) and isinstance(msg.content, list):
            for b in msg.content:
                if isinstance(b, ToolResultBlock):
                    text = b.content if isinstance(b.content, str) else b.content[0]["text"]
                    if b.is_error:
                        print(f"   error: {text}")
                    else:
                        data = json.loads(text)
                        credits += data["credits_used"]
                        print(f"   {data['credits_used']} credits, {data['credits_remaining']} left")
        elif isinstance(msg, ResultMessage):
            print("\n" + (msg.result or f"stopped: {msg.subtype}"))
            print(f"\nturns={msg.num_turns} fastaf_credits={credits} model_usd={msg.total_cost_usd:.4f}")

asyncio.run(main(sys.argv[1]))

Le opzioni che contano, secondo il riferimento dell’SDK Python e la guida MCP, entrambi verificati il 19 ottobre 2026:

  • I tool MCP si chiamano mcp__<server>__<tool>, quindi la chiave "fastaf" produce mcp__fastaf__search_web.
  • allowed_tools si limita ad approvare in automatico; non nasconde gli altri 12 tool di FastAF. È permission_mode="dontAsk" a negare tutto ciò che non è nella lista.
  • tools=[] toglie i tool integrati. Nella nostra prima esecuzione il messaggio di init elencava ancora i connettori di claude.ai (fra cui Gmail) presi dal login locale, ed è per questo che ENABLE_CLAUDEAI_MCP_SERVERS è impostato a false.
  • Ogni risultato di un tool FastAF è un JSON con credits_used e credits_remaining, quindi lo script somma la spesa reale invece di stimarla.

Cosa ha fatto davvero?

Eseguito il 19 ottobre 2026 con la domanda “What changed in the Model Context Protocol (MCP) specification in 2026, and what does recent academic research say about MCP security risks? Cite sources.” Output accorciato:

-> search_web {'query': 'Model Context Protocol specification 2026 changelog new version', 'language': 'en'}
-> search_scholar {'query': 'Model Context Protocol security risks attacks', 'year_from': 2025, 'results': 8}
   5 credits, 890 left
   5 credits, 885 left
-> scrape_page {'url': 'https://blog.modelcontextprotocol.io/posts/2026-07-28/'}
   1 credits, 884 left

The 2026-07-28 revision is the main 2026 release. It followed the November 2025 release [1].
- Stateless core: The initialize/initialized handshake and the Mcp-Session-Id header are gone. [...] [1]
- Routing headers: Mcp-Method and Mcp-Name are now required on Streamable HTTP. [...] [1]
- Authorization hardening: Clients must validate the RFC 9207 iss parameter. [...] [1]
[...]
- A survey in ACM Transactions on Software Engineering and Methodology (TOSEM) identifies 16 threat scenarios. [2]
- An arXiv study ran 847 attack scenarios. [...] This is a preprint, and I only saw its abstract snippet. [3]
[...]
[1] https://blog.modelcontextprotocol.io/posts/2026-07-28/
[2] https://dl.acm.org/doi/abs/10.1145/3796519
[3] https://arxiv.org/abs/2601.17549

turns=4 fastaf_credits=11 model_usd=0.0578

Tre chiamate ai tool, 11 crediti FastAF, circa 20 secondi e $0,0578 di costo del modello secondo la stima dell’SDK. Abbiamo verificato le affermazioni sulla spec confrontandole con la pagina scaricata, e i numeri dei paper con gli snippet di Scholar: ci sono tutti. L’agente ha anche segnalato di aver visto solo gli abstract, che è esattamente il comportamento che vuoi. Per le domande che ruotano attorno ai paper, la pagina della Google Scholar API descrive i campi che restituisce search_scholar.

Una nota onesta: la nostra chiave di test condivide un account con concorrenza 1 con altri job, e i primi tentativi hanno preso RATE_LIMITED sulla maggior parte delle chiamate (le chiamate fallite non vengono mai addebitate). Per l’esecuzione qui sopra abbiamo aggiunto un secondo hook di debug che aspetta uno slot libero. Con una chiave tua non ti servirà, ma prima leggi la sezione successiva.

Come limiti quanto spende un agente di ricerca?

Tre livelli, dal più morbido al più rigido:

  1. max_turns delimita il ciclo. A 10, l’SDK si ferma con subtype error_max_turns qualunque cosa abbia in mente il modello. max_budget_usd fa lo stesso per il costo del modello, anche se la documentazione avverte che la stima può sforare.
  2. Un tetto alle chiamate nel prompt è una preferenza. L’abbiamo visto con i nostri occhi: il prompt diceva “one at a time”, e in diverse esecuzioni il modello ha comunque mandato due chiamate nello stesso turno, una volta tre letture di pagina insieme. Su un account Free (concorrenza 1) la chiamata in più prende RATE_LIMITED. L’hook PreToolUse trasforma il tetto in una regola: con MAX_CALLS = 1 e il prompt che diceva ancora 6, l’hook ha negato tre scrape, l’agente ha risposto con la sua unica ricerca e l’esecuzione è costata 5 crediti.
  3. Il saldo dell’account è il tetto rigido. Ogni chiamata prenota il suo costo in anticipo, e un saldo vuoto restituisce 402 insufficient_credits. Il saldo appartiene all’account ed è condiviso da tutte le sue chiavi, quindi una chiave separata non ti dà un budget separato. Carica l’account con quanto sei disposto a perdere per colpa di un loop andato storto.

L’articolo sul tetto di spesa approfondisce il lato del wrapper, e il grounding con una SERP API tratta lo stesso schema cerca-poi-leggi senza un ciclo agentico.

La versione breve

Punta mcp_servers su https://api.fastaf.co/mcp con un header Bearer, consenti tre tool, imposta permission_mode="dontAsk" e aggiungi un hook PreToolUse che conta le chiamate. Una risposta con fonti su un tema del 2026 è costata 11 crediti, cioè circa 1.800 risposte così con un pacchetto Starter da $10. Nei prezzi trovi il costo di ogni tool, e con la registrazione ricevi 500 crediti gratis per provarlo tu stesso.