All Posts
TutorialStock Research AgentClaude MCPSEC Filings

Build a Stock Research Agent With SEC Citations

Build an AI stock research agent with Claude and the StockFit MCP server: every number traces to an SEC accession number, and code verifies each claim.

Published October 19, 202614 min readStockFit Engineering
Build a Stock Research Agent With SEC Citations

A stock research agent is only as good as its least checkable number. Ask a language model for a company's latest free cash flow and you get a fluent, confident figure that could come from the 10-K, from last year, or from nowhere. This tutorial builds the alternative in three small Python files: Claude researches a company through the StockFit MCP server, writes a brief in which every number is a claim that traces to an SEC accession number, and a verifier re-checks each claim against StockFit and against SEC EDGAR's own XBRL API before the brief is allowed to ship.

We ran it on Microsoft on October 19, 2026. The agent made 6 tool calls and wrote 50 claims; the verifier traced all 50 to two 10-K filings and five Form 4 filings. The first run taught us more than the second: the verifier rejected 42 of 49 claims, and not one of those numbers was wrong.

Why AI agents get financial numbers wrong

A language model generates likely text; it does not look anything up. Microsoft's fiscal 2026 ended June 30, 2026, and its 10-K reached EDGAR on July 29, 2026. A model whose training data stops before that date cannot know the numbers, and a model that answers anyway is producing plausible text, not data. Retrieval helps less than you would hope: in FinanceBench, a benchmark of questions about public companies, GPT-4-Turbo with a retrieval system answered 81% of 150 sampled questions incorrectly or refused to answer them.

A sharper prompt does not fix this. What fixes it is a data source whose answers carry their own receipts, plus code that checks them. For financial data from SEC filings, a receipt has a precise shape:

  • The accession number of the filing the value came from, the ID EDGAR assigns to every 10-K, 10-Q, and Form 4.
  • The form type and filing date, so the agent knows whether it is quoting an annual report or a quarterly one, and what was public when.
  • The XBRL concept, such as us-gaap:NetIncomeLoss, so anyone can look the same fact up in SEC's structured data.
  • A flag for computed values. Free cash flow, margins, and growth rates appear in no filing; they are arithmetic on values that do.

The division of labor in the rest of this post follows from that. Claude does the company research and the writing. Your code owns the contract: every number in the final report is a claim, every claim names its source, and nothing ships until the sources check out. It is the same principle behind the AI economic model API, which refuses to publish a claim whose quote it cannot find in the filing.

Step 1: Connect Claude to SEC filing data over MCP

The Model Context Protocol (MCP) lets an AI agent call tools that a server exposes. StockFit runs one at https://api.stockfit.io/mcp, the same financial data MCP server that works in Claude Desktop, Claude Code, Cursor, VS Code, and ChatGPT. For interactive use, one command connects Claude Code, and you sign in through the browser:

bash
claude mcp add --transport http stockfit https://api.stockfit.io/mcp

An agent you deploy needs code, and code needs an API key. , export it as STOCKFIT_API_KEY, and save this helper as stockfit_mcp.py. It is the whole MCP client the verifier will need later, written against the Python standard library:

python
import json
import os
import urllib.request

MCP_URL = "https://api.stockfit.io/mcp"


def call_tool(name, arguments):
    body = json.dumps({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {"name": name, "arguments": arguments},
    }).encode()
    request = urllib.request.Request(MCP_URL, data=body, method="POST", headers={
        "Authorization": f"Bearer {os.environ['STOCKFIT_API_KEY']}",
        "Content-Type": "application/json",
        "Accept": "application/json, text/event-stream",
        "User-Agent": "stock-research-agent/1.0",
    })
    with urllib.request.urlopen(request, timeout=60) as response:
        raw = response.read().decode()
    # Streamable HTTP answers as server-sent events: the JSON-RPC message sits on the data: lines.
    payload = "".join(line[5:].strip() for line in raw.splitlines() if line.startswith("data:"))
    message = json.loads(payload or raw)
    if "error" in message:
        raise RuntimeError(message["error"]["message"])
    result = message["result"]
    if result.get("isError"):
        raise RuntimeError(result["content"][0]["text"])
    return json.loads(result["content"][0]["text"])

Two details are easy to miss. The server speaks streamable HTTP, so the JSON-RPC reply arrives as a server-sent event and the client reads it from the data: lines. The request must accept both application/json and text/event-stream, or the server answers with a 406. With the client in place, ask the server which tools your key can call:

python
from stockfit_mcp import call_tool

plan = call_tool("list_available_tools_of_current_plan", {})
print(plan["currentTier"], plan["accessibleCount"], "of", plan["totalTools"], "tools callable")
text
pro 124 of 124 tools callable

The server lists 125 tools: 124 data tools plus list_available_tools_of_current_plan, which returns the data tools your plan unlocks (the key above is on the Professional plan, so all 124). The research agent in this post needs 5 of them, and it should see only those five. A small tool surface keeps the model on task and the prompt short.

Step 2: Read fundamentals where every value cites its filing

Before handing the tools to a model, call them yourself and learn the shape of the evidence. The income statement tool returns normalized line items per fiscal year. Pass includeSourceDetails and every sourced value also names its XBRL concept and unit:

python
from stockfit_mcp import call_tool

rows = call_tool("financials_income_statement", {
    "symbol": "MSFT", "period": "annual", "limit": 3, "includeSourceDetails": True,
})
for row in rows:
    for accession, source in row["sources"].items():
        print(row["fiscalYear"], accession, source["type"], source["dateFiled"], "fields:", len(source["facts"]))
text
2026 0001193125-26-323660 10-K 2026-07-29 fields: 23
2025 0000950170-25-100235 10-K 2025-07-30 fields: 25
2024 0000950170-24-087843 10-K 2024-07-30 fields: 22
2024 0000950170-25-100235 10-K 2025-07-30 fields: 1

Look at the last two lines. Microsoft's fiscal 2024 row cites two 10-Ks: 22 fields come from the fiscal 2024 10-K, and one comes from the fiscal 2025 10-K. That field is total operating expenses: Microsoft's fiscal 2024 10-K did not tag it as us-gaap:OperatingExpenses, and the fiscal 2025 10-K did, in its prior-year column. SEC's own XBRL data agrees: it holds the $61.6 billion fiscal 2024 figure only under the 2025 and 2026 filings. A citation per row would be wrong for that one value. The citation has to live on the value.

Here is the fiscal 2026 row, trimmed to four fields:

json
{
  "period": "2026-06-30",
  "fiscalYear": 2026,
  "fiscalPeriod": "FY",
  "dateFiled": "2026-07-29",
  "facts": {
    "revenue": 331839000000,
    "operatingIncome": 155237000000,
    "netIncome": 133749000000,
    "epsDiluted": 17.95
  },
  "sources": {
    "0001193125-26-323660": {
      "type": "10-K",
      "dateFiled": "2026-07-29",
      "amendment": false,
      "facts": {
        "revenue": {
          "concept": "us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax",
          "unit": "USD"
        },
        "operatingIncome": {
          "concept": "us-gaap:OperatingIncomeLoss",
          "unit": "USD"
        },
        "netIncome": {
          "concept": "us-gaap:NetIncomeLoss",
          "unit": "USD"
        },
        "epsDiluted": {
          "concept": "us-gaap:EarningsPerShareDiluted",
          "unit": "USD/shares"
        }
      }
    }
  },
  "derived": [
    "sellingGeneralAndAdministrativeExpenses",
    "costAndExpenses",
    "depreciationAndAmortization",
    "ebit",
    "ebitda"
  ]
}

Three parts of this structured data matter to an agent. sources maps each accession number to the form type, filing date, and the exact fields that filing supplied. derived lists the values StockFit computed, such as EBITDA, which have no filing of their own. And the concept is the bridge to SEC: us-gaap:NetIncomeLoss plus the accession is enough to find the same fact in SEC's EDGAR APIs without trusting StockFit at all. The cash flow statement tool returns the same shape. To turn an accession into a link a reader can open, resolve it with the filing lookup:

json
{
  "type": "10-K",
  "accessionNumber": "0001193125-26-323660",
  "url": "https://www.sec.gov/Archives/edgar/data/789019/000119312526323660/msft-20260630.htm",
  "dateFiled": "2026-07-29",
  "xbrl": true,
  "amendment": false,
  "periodEndDate": "2026-06-30",
  "documents": []
}

Filing date and accession number are also what make data point-in-time: the brief can state what was public on a given day, the property our post on point-in-time data for backtesting depends on.

Step 3: Add insider trades from Form 4 filings

Fundamentals say how the business did. Insider activity adds what officers and directors did with their own shares, and every trade arrives on a Form 4 with its own accession number. The insider transactions tool takes a date window, and filter: significant keeps only open-market purchases (code P) and sales (code S) and drops grants, option exercises, and shares withheld for taxes:

python
from stockfit_mcp import call_tool

trades = call_tool("insider_transactions", {
    "symbol": "MSFT", "filter": "significant", "startDate": "2026-07-21", "endDate": "2026-10-19", "pageSize": 250,
})["data"]
for t in trades:
    print(t["transactionDate"], t["transactionCode"], t["ownerName"], t["shares"], t["accessionNumber"])
text
2026-10-13 S Hood Amy 3387 0000789019-26-000226
2026-10-13 S Hood Amy 2927 0000789019-26-000226
2026-10-13 S Hood Amy 991 0000789019-26-000226
2026-09-14 S Hood Amy 4339 0000789019-26-000212
2026-09-14 S Hood Amy 10433 0000789019-26-000212
2026-09-14 S Hood Amy 9821 0000789019-26-000212
2026-09-14 S Hood Amy 4305 0000789019-26-000212
2026-09-14 S Hood Amy 8834 0000789019-26-000212
2026-09-14 S Hood Amy 3942 0000789019-26-000212
2026-09-01 S Nadella Satya 1040 0000789019-26-000161
2026-09-01 S Nadella Satya 5080 0000789019-26-000161
2026-09-01 S Nadella Satya 24723 0000789019-26-000161
2026-09-01 S Nadella Satya 32901 0000789019-26-000161
2026-09-01 S Nadella Satya 4520 0000789019-26-000161
2026-09-01 S Nadella Satya 7067 0000789019-26-000161
2026-09-01 S Nadella Satya 9834 0000789019-26-000161
2026-09-01 S Nadella Satya 1360 0000789019-26-000161
2026-08-05 S Althoff Judson 10000 0000789019-26-000143
2026-08-04 S Numoto Takeshi 4810.353 0000789019-26-000141

Nineteen sales, no purchases, five Form 4 filings. Note how many lines share an accession number: Satya Nadella's single Form 4 for September 1, 2026 reports eight sales at different weighted-average prices, 86,525 shares in total. Each row carries the link to the filing itself:

json
{
  "ownerName": "Nadella Satya",
  "officerTitle": "Chief Executive Officer",
  "transactionDate": "2026-09-01",
  "transactionCode": "S",
  "shares": 24723,
  "pricePerShare": 500.4678,
  "acquiredDisposed": "D",
  "sharesAfter": 542444.534,
  "accessionNumber": "0000789019-26-000161",
  "filingUrl": "https://www.sec.gov/Archives/edgar/data/789019/000078901926000161/form4.xml",
  "filingDate": "2026-09-02 00:00:00"
}

That one-filing, many-lines shape is about to matter. For the full set of Form 4 endpoints and how they compare with other vendors, see our insider trading API comparison.

Step 4: Make the agent write a cited research brief

Now hand the five tools to Claude. The system prompt is the contract: it defines three kinds of claim and tells the model exactly where each claim's receipt comes from.

python
import json
import os
import sys
from datetime import date

import anthropic

SYSTEM = """You are a stock research agent. You write short, factual research briefs on US-listed companies.

Rules:
1. Every number you state comes from a StockFit tool result in this conversation. Never use numbers from memory.
2. A value read from a financial statement is a "reported" claim. Copy the accession number that the tool's sources block lists for that exact field.
3. A number you compute (growth, margin, free cash flow) is a "derived" claim. Give its formula over the ids of other claims. Fields a tool lists under "derived" are computed too, so never cite an accession for them.
4. An insider trade is an "insider" claim. Copy the Form 4 accession number from insider_transactions.
5. Label periods by fiscalYear and fiscalPeriod (for example FY2026), never by the period key.
6. Write percentages in percent units: 17.8 means 17.8%.
7. If a tool returns no value, say so. Do not estimate.

Reply with JSON only:
{"ticker": str, "summary": str, "claims": [
  {"id": str, "text": str, "kind": "reported", "tool": str, "field": str, "fiscalYear": int, "fiscalPeriod": str, "value": number, "accession": str},
  {"id": str, "text": str, "kind": "derived", "formula": str, "value": number},
  {"id": str, "text": str, "kind": "insider", "accession": str, "transactionDate": "YYYY-MM-DD", "transactionCode": "P" | "S", "shares": number}
]}
Every number in summary and text must match one claim's value at the precision shown."""

TOOLS = [
    "company_details",
    "financials_income_statement",
    "financials_cash_flow_statement",
    "insider_transactions",
    "filings_search_by_accession_number",
]

Rule 2 is the one that matters most: copy the accession from sources, never infer it. The fiscal 2026 10-K also prints fiscal 2025 revenue as a comparative, so "cite the newest filing" is a plausible guess, and it is not the filing the tool returned for that value. Rule 5 makes the agent and the verifier match periods on the same key, fiscalYear and fiscalPeriod; when a brief needs the exact period end date, it comes from the filing record (periodEndDate above).

The request uses Anthropic's MCP connector, which lets the Messages API call a remote MCP server directly. Anthropic's side holds the MCP connection, your StockFit key travels as the bearer token, and the toolset allowlists the five tools:

python
ticker = sys.argv[1]
client = anthropic.Anthropic()
messages = [{
    "role": "user",
    "content": f"Today is {date.today()}. Research brief on {ticker}: latest fiscal year versus the prior year, "
               "cash generation, and open-market insider trades in the last 90 days.",
}]
calls = []
while True:
    response = client.beta.messages.create(
        model="claude-opus-5-5",
        max_tokens=16000,
        system=SYSTEM,
        messages=messages,
        mcp_servers=[{
            "type": "url",
            "url": "https://api.stockfit.io/mcp",
            "name": "stockfit",
            "authorization_token": os.environ["STOCKFIT_API_KEY"],
        }],
        tools=[{
            "type": "mcp_toolset",
            "mcp_server_name": "stockfit",
            "default_config": {"enabled": False},
            "configs": {name: {"enabled": True} for name in TOOLS},
        }],
        betas=["mcp-client-2025-11-20"],
    )
    calls += [{"tool": b.name, "input": b.input} for b in response.content if b.type == "mcp_tool_use"]
    if response.stop_reason != "pause_turn":
        break
    messages.append({"role": "assistant", "content": response.content})

if response.stop_reason != "end_turn":
    sys.exit(f"stopped early: {response.stop_reason}")
text = "".join(b.text for b in response.content if b.type == "text")
brief = json.loads(text[text.index("{"):text.rindex("}") + 1])
with open(f"{ticker}-brief.json", "w") as f:
    json.dump({**brief, "toolCalls": calls}, f, indent=2)
print(f"{len(calls)} tool calls, {len(brief['claims'])} claims -> {ticker}-brief.json")

We gave Claude this prompt and the same five tools on October 19, 2026 (in a Claude Agent SDK session connected to the same MCP server; agent.py is the Messages API equivalent). It made 6 tool calls (one company lookup, two statements, one insider query, and two filing lookups) and returned 50 claims: 14 reported, 17 derived, and 19 insider trades. The tool-call log is the agent's audit trail:

json
[
  {
    "tool": "company_details",
    "input": {
      "symbol": "MSFT"
    }
  },
  {
    "tool": "financials_income_statement",
    "input": {
      "symbol": "MSFT",
      "period": "annual",
      "limit": 2
    }
  },
  {
    "tool": "financials_cash_flow_statement",
    "input": {
      "symbol": "MSFT",
      "period": "annual",
      "limit": 2
    }
  },
  {
    "tool": "insider_transactions",
    "input": {
      "symbol": "MSFT",
      "filter": "significant",
      "startDate": "2026-07-21",
      "endDate": "2026-10-19",
      "pageSize": 250
    }
  },
  {
    "tool": "filings_search_by_accession_number",
    "input": {
      "accessionNumber": "0001193125-26-323660"
    }
  },
  {
    "tool": "filings_search_by_accession_number",
    "input": {
      "accessionNumber": "0000950170-25-100235"
    }
  }
]

This is the summary it wrote, verbatim:

MSFT FY2026 vs FY2025: revenue grew 17.79% to USD 331,839,000,000 and operating income grew 20.78%, lifting operating margin to 46.78% from 45.62%. Net income grew 31.34% and diluted EPS 31.6% to USD 17.95, helped by a USD 15,598,000,000 swing in non-operating income to USD 10,697,000,000. Cash generation: operating cash flow grew 34.35% to USD 182,935,000,000, but capital expenditure rose 79.62% to USD 115,948,000,000, so free cash flow fell 6.46% to USD 66,987,000,000, or 20.19% of revenue versus 25.42% in FY2025. Insiders: open-market trades since late July were all sales with no purchases; officers sold 150,314.353 shares, led by CEO Satya Nadella with 86,525 and CFO Amy Hood with 48,979, plus Judson Althoff with 10,000 and Takeshi Numoto with 4,810.353.

And three of the claims behind it, one of each kind:

json
{
  "ticker": "MSFT",
  "claims": [
    {
      "id": "capex_fy2026",
      "text": "Capital expenditure was USD 115,948,000,000 in FY2026.",
      "kind": "reported",
      "tool": "financials_cash_flow_statement",
      "field": "capitalExpenditure",
      "fiscalYear": 2026,
      "fiscalPeriod": "FY",
      "value": 115948000000,
      "accession": "0001193125-26-323660"
    },
    {
      "id": "fcf_fy2026",
      "text": "Free cash flow (operating cash flow minus capital expenditure) was USD 66,987,000,000 in FY2026.",
      "kind": "derived",
      "formula": "ocf_fy2026 - capex_fy2026",
      "value": 66987000000
    },
    {
      "id": "ins_nadella_3",
      "text": "CEO Satya Nadella sold 24,723 shares in an open-market sale in September.",
      "kind": "insider",
      "accession": "0000789019-26-000161",
      "transactionDate": "2026-09-01",
      "transactionCode": "S",
      "shares": 24723
    }
  ]
}

The brief found a real story in the cash flow statements: net income rose 31.3% and operating cash flow 34.4%, but capital expenditure rose 79.6% to $115.9 billion, so free cash flow fell 6.5% to $67.0 billion. It also noticed that net income outgrew operating income and pointed to the line behind it: non-operating income went from -$4.9 billion to $10.7 billion, both values cited to their 10-Ks.

Microsoft cash generation, FY2025 vs FY2026 (fiscal years ended June 30)
Net income, operating cash flow, and capital expenditure as filed in the 10-Ks (accessions 0000950170-25-100235 and 0001193125-26-323660); free cash flow is derived as operating cash flow minus capital expenditure.

Data as filed, not investment advice. Microsoft is the worked example because its fiscal year ends on June 30 and its fiscal 2026 10-K is recent; the same code runs on any ticker StockFit covers.

Step 5: Verify every number before the brief ships

A citation nobody checks is decoration. The verifier re-derives every claim from scratch, through the same MCP server and then through SEC. It checks reported claims at two levels: StockFit must serve the claimed value and list the cited accession as the source of that exact field, and SEC's companyconcept API must hold the same value under the same accession and XBRL concept.

python
import functools
import json
import re
import sys
import urllib.request

from stockfit_mcp import call_tool

SEC_HEADERS = {"User-Agent": "Your Name you@example.com"}  # SEC asks every client to identify itself
NUMBER = re.compile(
    r"(\$)?(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)\s*(billion|bn|million|mn|b(?![a-z])|m(?![a-z])|%|shares)?", re.I)
SCALE = {"billion": 1e9, "bn": 1e9, "b": 1e9, "million": 1e6, "mn": 1e6, "m": 1e6}  # % stays in percent units


@functools.cache
def statement(tool, ticker, period):
    return call_tool(tool, {"symbol": ticker, "period": period, "limit": 8, "includeSourceDetails": True})


@functools.cache
def sec_facts(cik, concept):
    prefix, name = concept.split(":")
    url = f"https://data.sec.gov/api/xbrl/companyconcept/CIK{cik:010d}/{prefix}/{name}.json"
    with urllib.request.urlopen(urllib.request.Request(url, headers=SEC_HEADERS)) as response:
        units = json.load(response)["units"]
    return {(f["accn"], f["val"]) for facts in units.values() for f in facts}


def tolerance(number_text):
    decimals = len(number_text.split(".")[1]) if "." in number_text else 0
    return 0.5 * 10 ** -decimals


def check_reported(claim, ticker, cik):
    period = "annual" if claim["fiscalPeriod"] == "FY" else "quarter"
    rows = statement(claim["tool"], ticker, period)
    row = next((r for r in rows if r["fiscalYear"] == claim["fiscalYear"] and r["fiscalPeriod"] == claim["fiscalPeriod"]), None)
    if row is None:
        return f"no {claim['fiscalPeriod']}{claim['fiscalYear']} row in {claim['tool']}"
    if row["facts"].get(claim["field"]) != claim["value"]:
        return f"value {claim['value']} != served {row['facts'].get(claim['field'])}"
    source = row["sources"].get(claim["accession"], {}).get("facts", {}).get(claim["field"])
    if source is None:
        return f"{claim['field']} is not sourced from {claim['accession']}"
    if source["concept"].split(":")[0] not in ("us-gaap", "ifrs-full", "dei", "srt"):
        return None  # company extension concepts are not in SEC's companyconcept API
    if (claim["accession"], claim["value"]) not in sec_facts(cik, source["concept"]):
        return f"SEC has no {source['concept']} = {claim['value']} in {claim['accession']}"
    return None

Derived claims are recomputed from the claims they reference, so an error upstream fails everything built on it. Insider claims are matched against the Form 4 rows for that day. And every number written in the text has to match a claim, which is what stops a number the model remembered from slipping into a sentence:

python
def check_derived(claim, values):
    names = set(re.findall(r"[A-Za-z_]\w*", claim["formula"]))
    if not names <= values.keys() or not re.fullmatch(r"[\w\s+\-*/().]+", claim["formula"]):
        return f"formula references unknown claims: {sorted(names - values.keys())}"
    computed = eval(claim["formula"], {"__builtins__": {}}, values)  # only claim ids and arithmetic reach eval
    if abs(computed - claim["value"]) > tolerance(repr(claim["value"])):
        return f"formula gives {computed:.6g}, claim says {claim['value']}"
    return None


@functools.cache
def insider_day(ticker, day):
    return call_tool("insider_transactions", {"symbol": ticker, "startDate": day, "endDate": day, "pageSize": 250})["data"]


def check_insider(claim, ticker):
    rows = insider_day(ticker, claim["transactionDate"])
    lines = [r["shares"] for r in rows
             if r["accessionNumber"] == claim["accession"] and r["transactionCode"] == claim["transactionCode"]]
    if claim["shares"] in lines or claim["shares"] == sum(lines):
        return None  # one transaction line, or the whole filing for that day
    return f"Form 4 {claim['accession']} lines {lines} do not add up to {claim['shares']}"


def orphan_numbers(text, values):
    orphans = []
    for dollar, digits, unit in NUMBER.findall(text):
        if not (dollar or unit or "," in digits or "." in digits):
            continue  # years, form numbers, and ordinals are not claims
        shown = float(digits.replace(",", "")) * SCALE.get(unit.lower(), 1)
        slack = tolerance(digits) * SCALE.get(unit.lower(), 1)
        if not any(abs(abs(v) - shown) <= slack for v in values):
            orphans.append(f"{dollar}{digits}{unit}")
    return orphans
python
def verify(brief):
    ticker = brief["ticker"]
    cik = call_tool("company_details", {"symbol": ticker})["cik"]
    values = {c["id"]: c.get("value", c.get("shares")) for c in brief["claims"]}
    failures = 0
    for claim in brief["claims"]:
        if claim["kind"] == "reported":
            problem = check_reported(claim, ticker, cik)
        elif claim["kind"] == "derived":
            problem = check_derived(claim, values)
        else:
            problem = check_insider(claim, ticker)
        orphans = orphan_numbers(claim["text"], values.values())
        if orphans:
            problem = problem or f"uncited numbers in text: {orphans}"
        failures += problem is not None
        print(f"{'PASS' if problem is None else 'FAIL'}  {claim['id']:<16} {problem or claim['text']}")
    print(f"\n{len(brief['claims']) - failures} of {len(brief['claims'])} claims verified")
    orphans = orphan_numbers(brief["summary"], values.values())
    if orphans:
        print(f"Summary has uncited numbers: {orphans}")
    return failures == 0 and not orphans


if __name__ == "__main__":
    with open(sys.argv[1]) as f:
        sys.exit(0 if verify(json.load(f)) else 1)

The first run: 42 failures, no wrong numbers

Our first agent run used the prompt without rule 6. It produced 49 claims from 11 tool calls, and the first version of the verifier passed 7 of them:

text
FAIL  rev_fy2026     uncited numbers in text: ['$331.8']
FAIL  rev_growth     uncited numbers in text: ['17.8%']
FAIL  ins_03         Form 4 0000789019-26-000161 shows 86525 shares, claim says 1040
...
7 of 49 claims verified

Every rejected value was correct. All 42 failures were rules the contract never stated. The agent wrote $331.8B, and the verifier only knew "billion". It stored a 17.8% growth rate as 17.788687, and the verifier expected a fraction, 0.178. It cited each Form 4 transaction line, and the verifier summed the whole filing.

First verification pass: 42 of 49 claims failed, 0 numbers were wrong
Every failure was an unstated rule in the contract between the agent and the verifier. After the three fixes, all claims passed.

The fixes went where each gap lived. Units are a convention, so the prompt now states it (rule 6) and the verifier reads percentages in percent units. Number formats are the verifier's problem, so it learned abbreviations. Granularity was ambiguous, so an insider claim may now name one transaction line or the whole filing for that day. The second run, with the prompt shown above, produced 50 claims and all of them passed:

text
PASS  rev_fy2026       Revenue was USD 331,839,000,000 in FY2026.
PASS  rev_fy2025       Revenue was USD 281,724,000,000 in FY2025.
PASS  opinc_fy2026     Operating income was USD 155,237,000,000 in FY2026.
...
PASS  rev_growth       Revenue grew 17.79% from FY2025 to FY2026.
PASS  fcf_fy2026       Free cash flow (operating cash flow minus capital expenditure) was USD 66,987,000,000 in FY2026.
...
PASS  insider_sold_total Officers sold 150,314.353 shares in total in open-market sales since late July; the insider tool returned no open-market purchases (code P) for the window.

50 of 50 claims verified

Expect the same pattern in your own agent. Most failures in a new citation pipeline are contract bugs, not model errors, and a verifier is how you find them before your users do.

What the verifier catches

To see the failure path, we planted four classic agent mistakes in a copy of the real brief: fiscal 2025 revenue cited to the newest 10-K, free cash flow cited as if a filing reported it, two digits of net income transposed, and a true figure that no claim backs, added to the summary the way a model adds a remembered number (Microsoft's fiscal 2026 dividends plus buybacks):

text
FAIL  rev_fy2025       revenue is not sourced from 0001193125-26-323660
FAIL  ni_fy2026        value 133794000000 != served 133749000000
FAIL  ni_growth        formula gives 31.387, claim says 31.34
FAIL  fcf_fy2026       freeCashFlow is not sourced from 0001193125-26-323660

46 of 50 claims verified
Summary has uncited numbers: ['48,716,000,000']

The transposed net income also fails the derived growth rate built on it. The last line is the instructive one: $48,716,000,000 is correct, but no claim backs it, so it does not ship. The verifier never judges whether a number sounds right. It judges whether the number is cited, and whether the citation holds.

Guardrails before the agent goes to production

  • Block on failure. A brief with one failed claim goes back to the agent with the verifier's output, or to a person. Do not ship partial truth.
  • Keep human review for judgment. The verifier checks numbers, not reasoning. Statements about risk factors, strategy, or causes still need a reader.
  • Respect the data sources. SEC asks clients to declare a User-Agent and stay under 10 requests per second. The verifier caches statements, concepts, and insider days, so the 50-claim Microsoft brief cost 8 StockFit calls and 7 SEC requests.
  • Plan for plan limits. On the Free plan the statement tools return the latest fiscal year only (300 requests a day), so a year-over-year brief needs Starter or higher, and insider transactions need the Stock or Professional plan. list_available_tools_of_current_plan tells the agent what it may call.
  • Mind extension concepts. SEC's companyconcept API covers standard taxonomies only, so a value mapped from a company-specific concept is checked against StockFit alone. Log those claims separately.
  • Store the brief with its tool-call log. The claims, the accessions, and the calls that produced them are the audit trail if anyone asks where a number came from.

The same pattern scales to a multi-agent system: a research agent per company, one verifier in front of the final report. If you would rather ask questions than write code, the StockFit ChatGPT app runs on the same MCP server, and company details is the free starting point for any ticker.

FAQ

Q.How do I build a stock research agent with Claude?
Connect Claude to a financial data MCP server, give the agent a small allowlist of tools, and make the system prompt a contract: every number is a claim that names its source. This tutorial uses the Messages API MCP connector with the StockFit MCP server at https://api.stockfit.io/mcp, five tools (company details, income statement, cash flow statement, insider transactions, filing lookup), and a Python verifier that re-checks every claim before the brief ships.
Q.How do you stop an AI agent from hallucinating financial numbers?
Do not rely on the prompt alone. Require every number to come from a tool result with its source attached, then verify in code: re-fetch each reported value, recompute each derived one, and reject any number in the text that no claim backs. When we planted mistakes in a real brief, it rejected a transposed net income figure, a value cited to the wrong 10-K, free cash flow cited as if a filing reported it, and a correct figure that no claim backed.
Q.What is an SEC accession number and why should an AI agent cite it?
It is the unique ID EDGAR assigns to every filing, such as 0001193125-26-323660 for Microsoft's fiscal 2026 10-K. It identifies one specific document, so a claim that cites it can be checked by anyone. StockFit's statement tools attach accession numbers per value, and with includeSourceDetails the XBRL concept as well, which is enough to find the same fact in SEC's own data.
Q.Can Claude call a remote MCP server from the Messages API?
Yes. The MCP connector (beta header mcp-client-2025-11-20) takes an mcp_servers entry with the server URL and an authorization token, plus an mcp_toolset entry in tools that can allowlist specific tools. Anthropic makes the MCP connection server-side, and the response includes mcp_tool_use and mcp_tool_result blocks you can log.
Q.Can I verify StockFit numbers against SEC EDGAR myself?
Yes, and the verifier in this post does it automatically. Request a statement with includeSourceDetails to get each value's XBRL concept and accession number, then fetch that concept from SEC's companyconcept API and look for the same value under the same accession. All 14 reported values in our Microsoft brief matched SEC exactly.
Q.Which StockFit plan does this stock research agent need?
The income statement, cash flow statement, company details, and filing lookup tools work on every plan, including Free, but Free returns only the latest fiscal year, so year-over-year comparisons need Starter ($19/mo, $15/mo billed annually) or higher. The insider transactions tool needs the Stock plan ($59/mo, $39/mo billed annually) or Professional ($99/mo, $69/mo billed annually).
Q.Does the same agent work in Claude Code, Claude Desktop, or Cursor?
The tools do. The StockFit MCP server works in Claude Desktop, Claude Code, Cursor, VS Code, and ChatGPT with an OAuth sign-in, so you can run the same research prompt interactively. The verifier is plain Python, so it can check a brief no matter which client produced it.
Q.Why did the verifier reject a correct number in the first run?
Because the contract between the agent and the verifier left rules unstated: number formats ($331.8B versus 331.8 billion), percent units (17.788687 versus 0.178), and whether an insider claim covers one Form 4 line or the whole filing. All 42 first-run failures were of that kind. Stating the percent convention in the prompt, and teaching the verifier abbreviations and line-level Form 4 claims, fixed them.

Ready to build?

Free API key, no credit card.