Build a web research agent with the Claude Agent SDK and a hard budget

About 60 lines of Python: Claude Agent SDK plus the FastAF MCP server for search, page reads and Google Scholar. One real run cost 11 credits.

A research agent needs three things: a way to find sources, a way to read them, and a limit on how much it can spend doing both. The Claude Agent SDK gives you the loop. The hosted FastAF MCP server gives you the tools. This post wires them together in about 60 lines of Python, runs it on a real question, and shows what it cost.

What do you need before you start?

Python 3.10 or newer, the SDK, and two keys:

python3 -m venv .venv && source .venv/bin/activate
pip install claude-agent-sdk
export ANTHROPIC_API_KEY=...   # per the Agent SDK quickstart
export FASTAF_API_KEY=...      # from your FastAF dashboard

The package is claude-agent-sdk (version 0.2.168 on PyPI, uploaded 15 October 2026), and it bundles the Claude Code binary, so there is nothing else to install. On a development machine it also picks up an existing Claude Code login, which is how the run below was made. Anthropic’s Agent SDK overview, checked 19 October 2026, says products built on the SDK must use API key authentication rather than claude.ai login, so ship with ANTHROPIC_API_KEY.

The FastAF side is one URL: https://api.fastaf.co/mcp, Streamable HTTP, with your key as a Bearer header. It exposes 15 tools; this agent uses three. search_web and search_scholar cost 5 credits each, scrape_page costs 1 credit for a static page. The MCP docs list the rest.

The agent

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=[],                                     # no built-in tools (Bash, Read, ...)
    allowed_tools=[f"mcp__fastaf__{t}" for t in ("search_web", "scrape_page", "search_scholar")],
    permission_mode="dontAsk",                    # everything not allowed is denied
    setting_sources=[],                           # ignore local Claude Code settings
    env={"ENABLE_CLAUDEAI_MCP_SERVERS": "false"}, # no claude.ai connectors
    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]))

The options that matter, per the Python SDK reference and the MCP guide, both checked 19 October 2026:

  • MCP tools are named mcp__<server>__<tool>, so the key "fastaf" gives mcp__fastaf__search_web.
  • allowed_tools only auto-approves; it does not hide the other 12 FastAF tools. permission_mode="dontAsk" is what denies anything not on the list.
  • tools=[] removes the built-in tools. In our first run the init message still listed claude.ai connectors (Gmail among them) from the local login, which is why ENABLE_CLAUDEAI_MCP_SERVERS is set to false.
  • Every FastAF tool result is JSON carrying credits_used and credits_remaining, so the script sums real spend instead of estimating it.

What did it actually do?

Run on 19 October 2026 with the question “What changed in the Model Context Protocol (MCP) specification in 2026, and what does recent academic research say about MCP security risks? Cite sources.” Trimmed output:

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

Three tool calls, 11 FastAF credits, about 20 seconds, and $0.0578 of model cost as estimated by the SDK. We checked the spec claims against the scraped page and the paper numbers against the Scholar snippets: all of them are there. The agent also flagged that it only saw abstracts, which is the behaviour you want. For paper-heavy questions, the Google Scholar API page covers the fields search_scholar returns.

One honest note: our test key shares a concurrency-1 account with other jobs, and the first attempts got RATE_LIMITED on most calls (failed calls are never charged). For the run above we added a second debug hook that waits for a free slot. On your own key you will not need it, but read the next section first.

How do you cap what a research agent spends?

Three layers, from soft to hard:

  1. max_turns bounds the loop. At 10, the SDK stops with subtype error_max_turns no matter what the model plans. max_budget_usd does the same for model cost, though the docs warn the estimate can overshoot.
  2. A tool-call cap in the prompt is a preference. We saw this directly: the prompt said “one at a time”, and in several runs the model still sent two calls in the same turn, once three page reads at once. On a Free account (concurrency 1) the extra call gets RATE_LIMITED. The PreToolUse hook turns the cap into a rule: with MAX_CALLS = 1 and the prompt still saying 6, the hook denied three scrapes, the agent answered from its one search, and the run cost 5 credits.
  3. The account balance is the hard cap. Every call reserves its cost up front, and an empty balance returns 402 insufficient_credits. The balance belongs to the account and is shared by all its keys, so a separate key does not give you a separate budget. Fund the account with what you are willing to lose to a bad loop.

The spending cap post goes deeper on the wrapper side, and grounding with a SERP API covers the same search-then-read pattern without an agent loop.

The short version

Point mcp_servers at https://api.fastaf.co/mcp with a Bearer header, allow three tools, set permission_mode="dontAsk", and add a PreToolUse hook that counts calls. A cited answer on a 2026 topic took 11 credits, which is about 1,800 such answers from a $10 Starter pack. Pricing has every tool’s cost, and /signup gives you 500 free credits to run it yourself.