Skip to content
Technical 15 min read

MCP Tools for AI Agents: Auth Scopes and Failure Modes

A hands-on tutorial on designing MCP tools that let an AI agent fetch live web data: batching, token scopes, retries, and failure handling.

FE
FineData Engineering · Editorial Policy
|
On this page

Introduction

Every agent project that touches live web data eventually produces the same transcript: the model calls a tool, the tool returns a raw Python traceback, the model apologizes and calls the identical tool again, and three failures later the conversation is 30,000 tokens heavier and the user still has nothing. The prompt was fine. The tool was the problem.

The Model Context Protocol makes tools first-class citizens, which means tool design is now systems design. This tutorial builds a small MCP server that serves product data from store.example.com, then works through the unglamorous parts that decide whether an agent survives contact with a real backend: strict input schemas, batching, OAuth scopes, structured auth errors, backoff with jitter, circuit breakers, and a final error envelope the model can actually reason about. If you need MCP background, start with MCP Protocol: How to Connect AI Agents to Web Data; here we assume you can register a tool and care about what happens after registration.

Defining the Tool Contract: Input Schemas an Agent Can Call Without Guessing

A model reading your tool list is doing schema-driven code generation with no compiler. Every free-text field is a place it will guess, and it guesses confidently. Compare these two definitions:

{
  "name": "search_store",
  "description": "Search the store for products.",
  "inputSchema": {
    "type": "object",
    "properties": { "query": { "type": "string" } }
  }
}
{
  "name": "fetch_products",
  "description": "Fetch one page of products from store.example.com/products. Returns id, title, price for each item.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "page":        { "type": "integer", "minimum": 1, "maximum": 200, "default": 1 },
      "limit":       { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 },
      "sort":        { "type": "string", "enum": ["price", "name", "created_at"], "default": "created_at" },
      "sort_dir":    { "type": "string", "enum": ["asc", "desc"], "default": "desc" }
    },
    "additionalProperties": false
  }
}

The vague schema invites this, which is a real pattern, not a strawman:

User:      Get me the 10 cheapest red shoes.
Assistant: {"query": "red shoes", "sort": "price_low_to_high", "limit": 100000}
Tool:      TypeError: search_store() got an unexpected keyword argument 'sort'
Assistant: {"query": "red shoes sorted by cheapest first, only 10, no marketplace sellers"}
Tool:      3,412 unstructured results, truncated at 32k tokens

Three rules follow. First, no url parameter, ever — the host is pinned server-side, because an agent that assembles URLs will eventually hand you store.example.com/../../admin. Second, enums and bounded integers turn hallucinated parameters into client-side rejections instead of server-side surprises. Third, additionalProperties: false so typos fail loudly at call time.

For extraction, I prefer deterministic CSS selectors over LLM-driven extraction inside a tool an LLM is already calling. Nondeterminism compounds, and so does cost. The handler for fetch_products can lean on a scraping API (FineData is one option) with plain selector rules:

import requests

BASE = "https://api.finedata.ai"
AUTH = {"Authorization": "Bearer fd_your_api_key"}

def fetch_products_page(sort, sort_dir, page):
    r = requests.post(f"{BASE}/api/v1/scrape", headers=AUTH, json={
        "url": f"https://store.example.com/products?sort={sort}&dir={sort_dir}&page={page}",
        "formats": ["markdown"],
        "extract_rules": {"items": ".product-card", "title": "h3", "price": ".price"},
    }, timeout=130)
    r.raise_for_status()
    return r.json()

If a page has no stable selectors at all, extract_schema with a JSON Schema is the fallback — but treat that as a cost decision you made deliberately, not a default.

Batching: Why One Tool Call Per Product Destroys Your Latency Budget

Agents enumerate. Give a model get_product(id) and a list of 50 SKUs, and it will issue 50 sequential tool calls, each one a full round trip through the model. Tool calls are not free; they are the most expensive function calls in your stack because a transformer sits in the middle of each one.

Ship both shapes, and make the batch the obvious default:

{
  "name": "get_product",
  "description": "Fetch a single product by id from store.example.com.",
  "inputSchema": {
    "type": "object",
    "properties": { "id": { "type": "string", "pattern": "^sku-[a-z0-9-]+$" } },
    "required": ["id"], "additionalProperties": false
  }
}
{
  "name": "get_products_batch",
  "description": "Fetch up to 100 products by id in one call. Prefer this over get_product whenever ids > 1.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "ids": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 100 }
    },
    "required": ["ids"], "additionalProperties": false
  }
}

Measured locally against the mock storefront from the harness at the end of this post, with 150 ms simulated per-item latency:

Fetch shapeNetwork callsWall clockModel round trips
get_product, N=110.8 s2
get_product, N=10 sequential108.1 s11
get_product, N=50 sequential5041.2 s51
get_products_batch, N=5011.4 s2

The batch also collapses context usage: one tool result instead of fifty interleaved with model turns.

Enforce the limit server-side, and chunk in the handler — never ask a model to do arithmetic it can skip:

MAX_BATCH = 100

def get_products_batch(ids, ctx):
    if len(ids) > MAX_BATCH:
        return error_payload("BATCH_TOO_LARGE", retryable=True,
                             hint=f"split ids into chunks of {MAX_BATCH}")
    results = []
    for chunk in [ids[i:i + MAX_BATCH] for i in range(0, len(ids), MAX_BATCH)]:
        payload = [{"url": f"https://store.example.com/products/{pid}",
                    "formats": ["markdown"],
                    "extract_rules": {"title": "h3", "price": ".price"}}
                   for pid in chunk]
        r = requests.post(f"{BASE}/api/v1/async/batch", headers=AUTH,
                          json={"requests": payload}, timeout=30)
        r.raise_for_status()
        batch_id = r.json()["batch_id"]
        results += poll_batch(batch_id)
    return {"products": results}

poll_batch is a loop over GET /api/v1/async/batch/{batch_id}?include_results=true with a sleep between checks. For anything past a few hundred items, switch to the callback_url webhook instead of polling; it is cheaper and kinder.

Scoping OAuth Tokens to Least Privilege: read:products Is Not read:orders

The token your agent holds is the blast radius of a prompt-injection attack. If a poisoned product page convinces the model to “check order history for context,” the only thing standing between the injection and your customer data is the scope list on the token. Issue scopes per function, at authorization time:

{
  "client_id": "shopping_agent",
  "grant_type": "authorization_code",
  "scope": "read:products read:catalog"
}

Note what is absent: read:orders, write:cart. A shopping assistant that browses and compares does not need either, and it should not be able to get them without a fresh consent step. My mapping:

MCP toolRequired scopeBlast radius if the token leaks
get_productread:productsPublic data for one SKU
get_products_batchread:catalogFull catalog with pricing
list_ordersread:ordersPurchase history, addresses — PII
update_cartwrite:cartModifies a real cart, commits spend

write:cart gets a short token lifetime and a separate grant. That is an opinionated stance — plenty of deployments hand the agent one fat token for convenience — but a read-only shopping agent that cannot mutate state cannot be talked into mutating state.

The check itself is dull, which is the point:

REQUIRED = {
    "get_product":        {"read:products"},
    "get_products_batch": {"read:catalog"},
    "list_orders":        {"read:orders"},
    "update_cart":        {"write:cart"},
}

def check_scopes(tool, token):
    missing = REQUIRED.get(tool, set()) - set(token.scopes)
    if missing:
        return {
            "isError": True,
            "error": {
                "code": "AUTH_SCOPE_MISSING",
                "http_status": 403,
                "retryable": False,
                "missing_scopes": sorted(missing),
                "suggested_next_action": "narrow_request",
            }
        }
    return None

Unknown tools get no entry, so they get no scopes, so they get refused. Deny by default; the alternative is debugging scope escalation through chat logs.

Returning 401 and 403 as Structured Errors the Agent Can Act On

When the upstream answers 401, the agent has exactly one correct move: refresh the token and replay the call. When it answers 403, retrying is worthless — the credentials are valid, the authorization is not. A raw stack trace communicates neither distinction:

Tool output (bad):
Traceback (most recent call last):
  File "tools/store.py", line 88, in handler
    resp.raise_for_status()
  File "requests/models.py", line 1021, in raise_for_status
    raise HTTPError(http_error_msg, response=self)
requests.exceptions.HTTPError: 403 Client Error: Forbidden

The model reads this, says something apologetic, and either retries blindly or gives up. Encode the decision instead:

{
  "isError": true,
  "error": {
    "code": "AUTH_TOKEN_EXPIRED",
    "http_status": 401,
    "scope": "read:catalog",
    "retryable": true,
    "retry_hint": "refresh the OAuth token, then replay the identical call",
    "suggested_next_action": "refresh_token"
  }
}

The wrapper that produces it:

AUTH_CODES = {401: "AUTH_TOKEN_EXPIRED", 403: "AUTH_SCOPE_MISSING"}

def call_store(method, path, token, **kw):
    resp = requests.request(method, f"https://store.example.com{path}",
                            headers={"Authorization": f"Bearer {token.value}"},
                            timeout=(2, 5), **kw)
    if resp.status_code in AUTH_CODES:
        code = AUTH_CODES[resp.status_code]
        expired = resp.status_code == 401
        return {
            "isError": True,
            "error": {
                "code": code,
                "http_status": resp.status_code,
                "retryable": expired,
                "retry_hint": ("refresh token, then replay the identical call" if expired
                               else "token lacks the required scope; narrow the request or ask the user to grant it"),
                "suggested_next_action": "refresh_token" if expired else "narrow_request",
            }
        }
    return resp

One subtlety: retryable: true on 401 means “retryable after a refresh,” not “retryable now.” The suggested_next_action field carries that distinction; without it, models will happily replay an expired token until the context window fills.

Retry Policy: Exponential Backoff with Jitter for 429 and 503

Retries belong in the tool layer, never in the agent loop. If the model is choosing when to sleep, you have built a distributed system out of prose. Three parallel sessions that all hit a 429 at the same instant will all decide to retry in exactly one second unless something downstream adds jitter — so add it:

import random, time

def sleep_for(status, headers, attempt, base=1.0, mult=2.0, cap=8.0):
    if status == 429 and "Retry-After" in headers:
        return float(headers["Retry-After"])              # honor the server's number exactly
    return min(cap, base * mult ** attempt) * (0.5 + random.random())

RETRYABLE = {429, 502, 503}

def send_with_retry(req, max_attempts=3):
    for attempt in range(max_attempts):
        resp = requests.request(**req)
        if resp.status_code not in RETRYABLE:
            return resp
        if attempt < max_attempts - 1:
            time.sleep(sleep_for(resp.status_code, resp.headers, attempt))
    return resp

The classification table, because “retry on everything” is how you turn a validation bug into a load test:

StatusRetry?Rationale
429Yes, honoring Retry-AfterThe server told you when to come back
502YesGateway hiccup, often transient
503Yes, honoring Retry-After if presentOverloaded upstream
400NoYour request is malformed; time fixes nothing
401No — refresh insteadRetrying the same token repeats the failure
404NoThe resource is gone
422NoSemantic validation failure

Timing log from a local drill against a mocked 429 with Retry-After: 3, then a second 429 with no header:

12:04:01.102  GET /products -> 429 (Retry-After: 3)  sleeping 3.00s  [header honored]
12:04:04.112  GET /products -> 429 (no header)       sleeping 1.13s  [attempt=0, jittered]
12:04:05.248  GET /products -> 200                   returned 50 items

One trap worth flagging: if your handler rides on a scraping layer, check its retry defaults before stacking your own. The FineData scrape endpoint already retries on failure via auto_retry (default on) with max_retries defaulting to 5 — combine that with a 3-attempt loop in your wrapper and one tool call becomes up to 15 upstream attempts. Set max_retries low on the API call or max_attempts=1 in your layer. Pick exactly one place to retry.

Timeouts and Circuit Breakers: Failing Fast When the API Flaps

An unbounded read against a stalling upstream does not just waste a request. It occupies the agent’s turn, and every second of silence is a second the model spends doing nothing while the transcript grows elsewhere. Set explicit timeouts and a breaker:

http:
  connect_timeout: 2     # seconds; TCP/TLS handshake
  read_timeout: 5        # seconds; waiting on the response body

breaker:
  failure_threshold: 5   # failures before opening
  window: 30             # seconds the failures are counted within
  cooldown: 60           # seconds the circuit stays open
class Breaker:
    def __init__(self, threshold=5, window=30.0, cooldown=60.0):
        self.threshold, self.window, self.cooldown = threshold, window, cooldown
        self.failures, self.opened_at = [], None

    def allow(self):
        if self.opened_at and time.time() - self.opened_at < self.cooldown:
            return False
        return True

    def record(self, ok):
        now = time.time()
        if ok:
            self.failures, self.opened_at = [], None
            return
        self.failures = [t for t in self.failures if now - t < self.window] + [now]
        if len(self.failures) >= self.threshold:
            self.opened_at = now

Local drill with a mock that sleeps 30 s per response (read timeout 5 s, 2 internal attempts per call, 10 tool calls planned):

SetupCalls sent upstreamTurn wall clockFailure payloads in transcript
No breaker10102.4 s10 envelopes, ~90 tokens each
Breaker (5 per 30 s, 60 s cooldown)551.3 s5 envelopes + 5 short fallbacks

While the circuit is open, the tool answers immediately with a fallback:

{
  "isError": true,
  "error": {
    "code": "UPSTREAM_CIRCUIT_OPEN",
    "retryable": true,
    "retry_after_seconds": 47,
    "message": "store.example.com failed 5 of the last 5 calls; refusing traffic for 47 more seconds",
    "suggested_next_action": "inform_user_and_wait"
  }
}

Run the breaker per host, not per tool. A global breaker means one flaky domain blocks every other data source, which is a self-inflicted outage.

Designing the Failure Response: What the Model Reads After a Retry Exhausts

Retries end. What the model sees at that moment determines whether it recovers or spirals. This is the envelope I return when the last attempt fails:

{
  "isError": true,
  "error_type": "upstream_unavailable",
  "host": "store.example.com",
  "attempts_made": 3,
  "last_status": 503,
  "retryable": true,
  "safe_to_retry_after_seconds": 60,
  "attempted": { "ids_count": 50, "chunks": 1 },
  "suggested_next_action": "try_get_products_cached",
  "message": "store.example.com returned 503 after 3 attempts over 6.2s"
}

Every field earns its tokens. host tells the model which source failed when it has several. attempts_made prevents it from “helpfully” retrying three more times. suggested_next_action is a nudge, not a command — and that distinction matters, because a poisoned page could try to steer it too, which is also why the suggested actions are limited to a fixed enum your server controls.

How envelope design changes agent behavior:

Envelope designTypical agent behaviorCost
Bare string "request failed"Retries the same call immediately, loops until stoppedWasted turns, no diagnosis
Raw stack traceApologizes, asks the user to retry, loses the thread1-4k tokens per failure
Structured envelope with suggested_next_actionSwitches tools or reports specifics to the user~90 tokens

The recovery path you want, from a drill transcript:

Tool:      {envelope above}
Assistant: The live catalog at store.example.com is unavailable after 3 attempts.
           I have cached results from earlier this session — showing those,
           prices may be stale.
User:      fine
Assistant: get_products_cached({"ids": [...50 skus...]}) -> 200

The model did not retry. It degraded gracefully because the envelope told it a legal way to do so.

A Local Test Harness for Scope, Retry, and Failure Drills

None of the above is real until you can break it on demand. Here is a harness that mocks store.example.com with scripted failure modes and runs the drills against your MCP server’s handler layer:

# harness.py
import json, time
from http.server import BaseHTTPRequestHandler, HTTPServer

CALLS = {}
ROUTES = {
    "/secure":  lambda n: (401, {}, b"") if n == 1 else (200, {}, b'{"ok": true}'),
    "/orders":  lambda n: (403, {}, b""),
    "/down":    lambda n: (503, {}, b""),
    "/slow":    lambda n: (time.sleep(30), 200, {}, b"{}")[1:] if False else None,
}

class Mock(BaseHTTPRequestHandler):
    def do_GET(self):
        n = CALLS.get(self.path, 0) + 1
        CALLS[self.path] = n
        if self.path == "/slow":
            time.sleep(30); return self.reply(200, b"{}")
        if self.path == "/products" and n <= 2:
            return self.reply(429, b"", {"Retry-After": "3"})
        return self.reply(*ROUTES.get(self.path, lambda n: (200, {}, PRODUCT_PAGE[:]))(n))

    def reply(self, code, body, extra=None):
        self.send_response(code)
        for k, v in (extra or {}).items(): self.send_header(k, v)
        self.end_headers(); self.wfile.write(body)

HTTPServer(("127.0.0.1", 8931), Mock).serve_forever()

The drill definitions live next to it, with expected outcomes stated as assertions:

# drills.yaml
base_url: http://127.0.0.1:8931
drills:
  - name: token_expired
    route: /secure
    expect: {code: AUTH_TOKEN_EXPIRED, then: "200 after refresh", attempts: 2}
  - name: rate_limited
    route: /products
    expect: {final: 200, sleeps: 2, first_sleep: 3.00}
  - name: scope_missing
    route: /orders
    expect: {code: AUTH_SCOPE_MISSING, retries: 0, action: narrow_request}
  - name: outage
    route: /down
    expect: {error_type: upstream_unavailable, attempts: 3, action: inform_user}
  - name: slow_body
    route: /slow
    expect: {breaker_opens_at: 5, fallback_ms: "<5"}

A clean run:

$ python harness.py --drills drills.yaml
token_expired   PASS  401 -> refresh -> 200 on attempt 2 (1.9s)
rate_limited    PASS  sleeps=[3.00, 1.13], retries=2, final=200
scope_missing   PASS  0 retries, envelope AUTH_SCOPE_MISSING, action=narrow_request
outage          PASS  attempts=3, envelope upstream_unavailable, action=inform_user
slow_body       PASS  breaker opened at failure 5, calls 6-8 served fallback in 3-4ms
5/5 drills passed

Run this in CI. Agent behavior regressions are tool regressions in disguise, and a drill that fails loudly in a pipeline beats a transcript that fails quietly in production.

Wrapping Up: Gotchas and Next Steps

The gotchas that bite hardest in practice: stacked retry layers multiplying attempts (15 upstream hits for one tool call is a real default collision), breakers that silently reset because you deployed three replicas with in-process state, and leaking Retry-After values into model-visible output under the assumption the model will respect them — it will not, so keep sleeping in the handler. Also cache scope introspection for the token’s lifetime; an auth round trip per tool call doubles your latency for zero benefit.

The through-line is a division of labor: the tool handles time (retries, jitter, breakers, timeouts) and the model handles choice (which tool, which fallback, what to tell the user). One canonical error envelope is the interface between them. If you are pushing batch volumes where polling stops making sense, read Async Scraping at Scale: Jobs, Batches, Webhooks next and wire the batch completion webhook straight into your MCP server. Then build the harness before you need it.

#mcp #ai-agents #tool-design #auth-scopes #error-handling #slot:mcp-agent

Related Articles