Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier Subscribe
Front Page / AI Tools / Deep Dive

Build an SEC EDGAR MCP Server for Agentic Disclosure Monitoring

Financial teams in 2026 run agentic AI for continuous disclosure monitoring, and SEC EDGAR is the data source - keyless but governed by a 10 requests/second cap and a mandatory descriptive User-Agent. This guide builds edgar-mcp, a TypeScript MCP server wrapping EDGAR's full-text search, submissions, XBRL company facts, and RSS filing feeds with inputSchema contracts, caching, rate-limit compliance, and an agentic 8-K alert workflow.

Deepak Bagada

Deepak Bagada

CEO, SaaSNext

Aug 16, 2026 Published
|
Aug 16, 2026 Updated
|
9 Minutes Reading Time
Core Takeaways for Founders & Builders
  • SEC EDGAR is keyless but enforces 10 requests/second per IP across all endpoints with a mandatory descriptive User-Agent - build rate limiting and caching into the MCP server itself.
  • A TypeScript MCP server exposes typed tools: CIK resolution, XBRL company facts, 8-K/10-K/10-Q filing lookup, EFTS full-text search, and RSS filing streaming.
  • Continuous disclosure monitoring means polling the RSS stream, resolving CIKs, enriching with submissions and facts, then letting the agent assess materiality.
  • Retry rules matter: a 403 is a ~10-minute block, so back off 60 * 2^n seconds with jitter and never retry a 403 immediately.

By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.

In 2026, financial teams run continuous disclosure monitoring with agentic AI — watching for 8-Ks, material changes, and XBRL data updates the moment they hit the wire. The data source is SEC EDGAR, and here is the catch: EDGAR is completely free and keyless, but it is one of the strictest public APIs on the internet. Every request must carry a descriptive User-Agent (CompanyName email@domain.com), you are limited to 10 requests per second per IP across every EDGAR domain, and violations get your IP blocked for about 10 minutes. Get the rate limiting wrong and your "real-time" monitor is a ten-minute blackout window. The MCP directory tracks a wave of EDGAR MCP servers — strong community ones exist — but production disclosure monitoring needs a gateway you control: your cache, your rate budget, your audit trail, your alerting loop.

This guide builds edgar-mcp, a TypeScript MCP server using the official MCP SDK, wrapping EDGAR's full-text search, submissions, XBRL company facts, and RSS filing feeds into five typed agent tools with inputSchema definitions, rate-limit compliance (8 of the 10 req/s budget), caching, and an agentic alert workflow. The governance discipline is the one we document across the AI workflows library: an agent is only as reliable as the data pipe you give it.

Why a gateway for a free, keyless API

EDGAR needs no API key, which makes it look easy — and then the 403s start:

  • Rate-limit enforcement is a server responsibility, not a prompt responsibility. An LLM cannot "remember" to stay under 10 req/s across 50 parallel tool calls. The gateway serializes everything through one limiter.
  • Caching is the cheapest monitoring win. Company facts and filings barely change intraday; a TTL cache cuts upstream load by an order of magnitude and makes the block risk vanish.
  • Your User-Agent identity is your reputation. A generic python-requests UA gets you blocked along with the whole cloud IP range. A gateway sets one descriptive identity for the entire team.
  • Alerting wants a stream, not scraping. Polling the RSS feed at a low rate is the monitored, compliant path; scraping the search API on a timer is not.

The tool surface

Tool Endpoint What it returns
resolve_cik company_tickers.json CIK for a ticker or company name
get_company_facts data.sec.gov/api/xbrl/companyfacts Normalized XBRL fundamentals (revenue, net income, assets, EPS)
get_filings data.sec.gov/submissions Recent filings filtered by form (8-K, 10-K, 10-Q) and date
search_filings efts.sec.gov/LATEST/search-index Full-text search across all filings
stream_filings EDGAR RSS / Atom feed New filings for a form type since a timestamp

Step 1: The TypeScript MCP server

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

const USER_AGENT = process.env.SEC_USER_AGENT ??
  "DailyAIWorld Disclosures admin@dailyaiworld.com";
const MAX_RPS = Number(process.env.SEC_MAX_RPS ?? 8);

// Sliding-window rate limiter targeting 8 of EDGAR's 10 req/s budget.
class RateLimiter {
  private hits: number[] = [];
  constructor(private maxPerSecond: number) {}
  async acquire(): Promise<void> {
    const now = Date.now();
    this.hits = this.hits.filter((t) => now - t < 1000);
    if (this.hits.length >= this.maxPerSecond) {
      const wait = 1000 - (now - this.hits[0]) + 25;
      await new Promise((r) => setTimeout(r, wait));
    }
    this.hits.push(now);
  }
}
const limiter = new RateLimiter(MAX_RPS);

// TTL cache: tickers 24h, submissions 1h, facts 12h, search 30m.
const cache = new Map<string, { value: unknown; expires: number }>();
async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
  const hit = cache.get(key);
  if (hit && hit.expires > Date.now()) return hit.value as T;
  const value = await fn();
  cache.set(key, { value, expires: Date.now() + ttlMs });
  return value;
}

async function secFetch(url: string): Promise<any> {
  await limiter.acquire();
  const res = await fetch(url, {
    headers: { "User-Agent": USER_AGENT, "Accept-Encoding": "gzip, deflate" },
    signal: AbortSignal.timeout(30000),
  });
  if (res.status === 403) throw new Error("EDGAR_RATE_BLOCKED");
  if (!res.ok) throw new Error("EDGAR_HTTP_" + res.status);
  return res.json();
}

const server = new Server({ name: "edgar-mcp", version: "1.0.0" },
  { capabilities: { tools: {} } });

type ToolDef = { description: string; inputSchema: object; handler: (a: any) => Promise<string> };
const tools = new Map<string, ToolDef>();

function register(name: string, description: string, inputSchema: object,
                  handler: (a: any) => Promise<string>) {
  tools.set(name, { description, inputSchema, handler });
}

register(
  "resolve_cik",
  "Resolve a ticker or company name to an SEC CIK (10-digit, zero-padded).",
  {
    type: "object",
    properties: {
      ticker: { type: "string", description: "Stock ticker, e.g. AAPL" },
      company: { type: "string", description: "Company name fragment, e.g. Apple" },
    },
  },
  async (args) => {
    const map = await cached("tickers", 86400000,
      () => secFetch("https://www.sec.gov/files/company_tickers.json"));
    const rows = Object.values(map) as any[];
    const q = (args.ticker ?? "").toLowerCase() || (args.company ?? "").toLowerCase();
    const hits = rows.filter((r) =>
      r.ticker.toLowerCase() === q || r.title.toLowerCase().includes(q)).slice(0, 5);
    return JSON.stringify(hits.map((r) => ({
      ticker: r.ticker, company: r.title,
      cik: String(r.cik_str).padStart(10, "0"),
    })), null, 2);
  },
);

register(
  "get_company_facts",
  "Latest XBRL fundamentals (revenue, net income, total assets, EPS) with filing citation.",
  {
    type: "object",
    properties: {
      cik: { type: "string", description: "Zero-padded CIK from resolve_cik" },
    },
    required: ["cik"],
  },
  async (args) => {
    const cik = String(args.cik).padStart(10, "0");
    const facts = await cached("facts:" + cik, 43200000,
      () => secFetch("https://data.sec.gov/api/xbrl/companyfacts/CIK" + cik + ".json"));
    const gaap = facts.facts["us-gaap"] ?? {};
    const latest = (tag: string) => {
      const units = gaap[tag]?.units;
      if (!units) return null;
      const unit = Object.keys(units)[0];
      const rows = (units[unit] ?? [])
        .filter((v: any) => v.form && v.val != null)
        .sort((a: any, b: any) => (b.end ?? "").localeCompare(a.end ?? ""));
      const row = rows[0];
      return row ? { value: row.val, unit, periodEnd: row.end, form: row.form, fy: row.fy } : null;
    };
    return JSON.stringify({
      entityName: facts.entityName, cik: facts.cik,
      revenue: latest("Revenue"), netIncome: latest("NetIncomeLoss"),
      totalAssets: latest("Assets"), epsBasic: latest("EarningsPerShareBasic"),
    }, null, 2);
  },
);

register(
  "get_filings",
  "List a company's recent filings, filtered by form type (8-K, 10-K, 10-Q) and date range.",
  {
    type: "object",
    properties: {
      cik: { type: "string", description: "Zero-padded CIK from resolve_cik" },
      forms: { type: "array", items: { type: "string" }, description: "e.g. [\"8-K\", \"10-Q\"]" },
      since: { type: "string", description: "YYYY-MM-DD" },
      limit: { type: "integer", default: 20 },
    },
    required: ["cik"],
  },
  async (args) => {
    const cik = String(args.cik).padStart(10, "0");
    const sub = await cached("subs:" + cik, 3600000,
      () => secFetch("https://data.sec.gov/submissions/CIK" + cik + ".json"));
    const recent = sub.filings.recent;
    const forms = new Set(args.forms ?? []);
    const since = args.since ?? "1900-01-01";
    const out = [];
    for (let i = 0; i < recent.form.length && out.length < (args.limit ?? 20); i++) {
      if (forms.size && !forms.has(recent.form[i])) continue;
      if (recent.filingDate[i] < since) continue;
      out.push({
        form: recent.form[i], filingDate: recent.filingDate[i],
        accessionNumber: recent.accessionNumber[i],
        primaryDoc: recent.primaryDocument[i],
        url: "https://www.sec.gov/Archives/edgar/data/" + cik + "/" +
             recent.accessionNumber[i] + "/" + recent.primaryDocument[i],
      });
    }
    return JSON.stringify(out, null, 2);
  },
);

register(
  "search_filings",
  "Full-text search across all SEC filings (EFTS) by query, form, and date range.",
  {
    type: "object",
    properties: {
      query: { type: "string", description: "Keywords or quoted phrase, e.g. material weakness" },
      forms: { type: "array", items: { type: "string" }, description: "e.g. [\"10-K\"]" },
      since: { type: "string", description: "YYYY-MM-DD" },
      limit: { type: "integer", default: 10 },
    },
    required: ["query"],
  },
  async (args) => {
    const params = new URLSearchParams({ q: args.query, size: String(args.limit ?? 10) });
    if (args.forms?.length) params.set("forms", args.forms.join(","));
    if (args.since) { params.set("dateRange", "custom"); params.set("startdt", args.since); }
    const data = await cached("search:" + params.toString(), 1800000,
      () => secFetch("https://efts.sec.gov/LATEST/search-index?" + params.toString()));
    const hits = (data.hits?.hits ?? []).map((h: any) => ({
      form: h._source.form, entityName: h._source.entity_name,
      fileDate: h._source.file_date, accessionNumber: h._source.accession_number,
      fileUrl: h._source.file_url,
    }));
    return JSON.stringify({ total: data.hits?.total?.value ?? hits.length, hits }, null, 2);
  },
);

register(
  "stream_filings",
  "Newest filings for a form type from the SEC RSS/Atom feed, filtered by timestamp.",
  {
    type: "object",
    properties: {
      forms: { type: "array", items: { type: "string" }, description: "e.g. [\"8-K\"]" },
      since: { type: "string", description: "ISO timestamp; only entries newer than this are returned" },
      limit: { type: "integer", default: 10 },
    },
  },
  async (args) => {
    const form = (args.forms ?? ["8-K"])[0];
    const url = "https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&type=" +
                encodeURIComponent(form) + "&output=atom&count=" + (args.limit ?? 10);
    await limiter.acquire();
    const res = await fetch(url, {
      headers: { "User-Agent": USER_AGENT },
      signal: AbortSignal.timeout(30000),
    });
    if (res.status === 403) throw new Error("EDGAR_RATE_BLOCKED");
    const xml = await res.text();
    const since = args.since ? Date.parse(args.since) : 0;
    const entries = [];
    const re = /<entry>([\\s\\S]*?)<\\/entry>/g;
    let m;
    while ((m = re.exec(xml))) {
      const entry = m[1];
      const title = entry.match(/<title>(.*?)<\\/title>/)?.[1] ?? "";
      const updated = entry.match(/<updated>(.*?)<\\/updated>/)?.[1] ?? "";
      const link = entry.match(/<link href="(.*?)"/)?.[1] ?? "";
      if (Date.parse(updated) >= since) entries.push({ title, updated, link });
    }
    return JSON.stringify({ count: entries.length, entries }, null, 2);
  },
);

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [...tools.entries()].map(([name, t]) => ({
    name, description: t.description, inputSchema: t.inputSchema,
  })),
}));

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const tool = tools.get(req.params.name);
  if (!tool) throw new Error("Unknown tool: " + req.params.name);
  const text = await tool.handler(req.params.arguments ?? {});
  return { content: [{ type: "text", text }] };
});

const transport = new StdioServerTransport();
await server.connect(transport);

Three decisions matter in this server. The rate limiter targets 8 req/s — EDGAR allows 10, but the headroom absorbs bursts from a team sharing one NAT egress. The cache keys by URL with per-endpoint TTLs, so repeat queries hit memory instead of the SEC. And the User-Agent identifies the team, not a scraping tool — the difference between being treated as a partner and being blocked with the cloud IP range.

Step 2: inputSchema definitions published to agents

{
  "resolve_cik": {
    "type": "object",
    "properties": {
      "ticker": { "type": "string", "description": "Stock ticker, e.g. AAPL" },
      "company": { "type": "string", "description": "Company name fragment, e.g. Apple" }
    }
  },
  "get_filings": {
    "type": "object",
    "properties": {
      "cik": { "type": "string", "description": "Zero-padded CIK from resolve_cik" },
      "forms": { "type": "array", "items": { "type": "string" }, "description": "8-K, 10-K, 10-Q" },
      "since": { "type": "string", "description": "YYYY-MM-DD" },
      "limit": { "type": "integer", "default": 20 }
    },
    "required": ["cik"]
  },
  "search_filings": {
    "type": "object",
    "properties": {
      "query": { "type": "string", "description": "Keywords or quoted phrase" },
      "forms": { "type": "array", "items": { "type": "string" } },
      "since": { "type": "string", "description": "YYYY-MM-DD" },
      "limit": { "type": "integer", "default": 10 }
    },
    "required": ["query"]
  }
}

Agents pick tools off these descriptions, so be explicit about the output contract — a monitor agent reading stream_filings must know it returns titles and links, not full text. The descriptions are the difference between a tool that gets used correctly and one that gets abused into N+1 calls.

Step 3: Wire into Claude Desktop and Cursor

{
  "mcpServers": {
    "edgar-mcp": {
      "command": "npx",
      "args": ["tsx", "/opt/edgar-mcp/src/index.ts"],
      "env": {
        "SEC_USER_AGENT": "DailyAIWorld Disclosures admin@dailyaiworld.com",
        "SEC_MAX_RPS": "8"
      }
    }
  }
}

The same mcpServers block works for Claude Desktop, Cursor, or any MCP client. Run it under npx tsx for TypeScript; for production, compile with tsc and point node at the built index.js. The User-Agent is the only credential this server needs — treat it as a team identity, not a default.

Step 4: Rate-limit compliance and caching (the part everyone gets wrong)

The SEC's fair-access rules are simple and unforgiving:

Rule Detail
Rate limit 10 requests/second per IP across all EDGAR domains
User-Agent Required — CompanyName email@domain.com, no generic strings
Block 403 for ~10 minutes; every EDGAR domain fails during a block
Daily limit None, as long as you stay under 10 req/s

Operating discipline:

  1. Target 8 req/s, not 10. A burst across parallel agents instantly blows 10; 8 leaves headroom.
  2. Cache aggressively. company_tickers.json for 24h, submissions for 1h, company facts for 12h, full-text searches for 30 minutes. Continuous monitoring becomes a handful of requests a minute.
  3. Never set a generic User-Agent. Mozilla/5.0 and python-requests get whole cloud IP ranges blocked. One team identity per deployment.
  4. Use the RSS stream for monitoring, not the search API. Poll the browse-edgar Atom feed once every few minutes — far under the budget — and let stream_filings be the only poller.

Retry rules and error handling

The golden rule for EDGAR: a 403 is not a transient error — it is a ten-minute sentence. Retrying immediately on 403 makes the block longer.

Status Meaning Retry policy
403 Rate-limit block Do NOT retry immediately. Back off 60 * 2^n seconds (60s, 120s, 240s)
429 Rate-limit (rare) Back off 2^n + 1 seconds
5xx Server error Back off 2^n seconds, max 3 attempts
404 No filing / bad CIK No retry — return a structured empty result
Timeout (30s) Network stall Back off 10 * 2^n seconds

Add jitter to every retry so a fleet of agents does not synchronize into a thundering herd, and surface EDGAR_RATE_BLOCKED as a structured error string so the agent pauses the whole pipeline instead of hammering a blocked IP.

Example: agentic 8-K alert workflow

A compliance agent watches five companies for material events. On a five-minute timer it runs:

[
  { "tool": "stream_filings", "args": { "forms": ["8-K"], "since": "2026-08-16T05:00:00Z", "limit": 50 } },
  { "tool": "resolve_cik", "args": { "ticker": "NVDA" } },
  { "tool": "get_filings", "args": { "cik": "0001045810", "forms": ["8-K"], "since": "2026-08-16", "limit": 5 } },
  { "tool": "get_company_facts", "args": { "cik": "0000320193" } }
]

For each new 8-K in the stream that matches a watched ticker, the agent pulls the primary document link, summarizes the triggered items (Item 1.01 material agreements, Item 8.01 other events) with the LLM, flags materiality, and pushes an alert to Slack or email with the SEC URL attached. The poller makes one RSS request every five minutes; everything else is a cached read. That is continuous disclosure monitoring inside EDGAR's rules — and the AI workflows library has the full alerting-loop pattern.

Frequently Asked Questions

Q: Is SEC EDGAR data free and keyless?

A: Yes. EDGAR requires no API key, but every request must send a descriptive User-Agent and stay under 10 requests/second, or your IP gets a roughly ten-minute block.

Q: What is EDGAR's exact rate limit?

A: 10 requests/second per IP across data.sec.gov, efts.sec.gov, and www.sec.gov, with no daily cap if you comply. Target 8 req/s and add exponential backoff on 403s.

Q: Why build a gateway when community sec-edgar-mcp servers exist?

A: Community servers are a great start. A gateway gives you your own cache, one team User-Agent identity, per-agent audit, and a controlled alerting loop — without third-party lock-in or per-call fees.

Q: Which endpoint should I use for what?

A: The RSS/Atom feed for streaming new filings, the submissions API for a company's filing history, the EFTS full-text search for topic discovery, and the companyfacts XBRL API for fundamentals.

Q: How do I avoid getting blocked?

A: A descriptive User-Agent, a limiter at 8 req/s, TTL caching, RSS polling instead of search polling, and never retrying a 403 immediately — wait at least 60 seconds with exponential backoff.

Closing thoughts

EDGAR is one of the strictest major public APIs on the internet, and that strictness is exactly why an MCP gateway earns its keep: one rate limiter, one cache, one User-Agent identity, and typed tools an agent can call without learning SEC rate rules the hard way. Stream new filings, resolve CIKs, enrich with XBRL facts, and let agents flag what matters. Keep the MCP directory close as the financial-MCP category grows, and follow the latest AI news for the next disclosure-data launch.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

Frequently Asked Questions
Yes. EDGAR requires no API key, but every request must send a descriptive User-Agent and stay under 10 requests/second, or your IP gets a roughly ten-minute block.
10 requests/second per IP across data.sec.gov, efts.sec.gov, and www.sec.gov, with no daily cap if you comply. Target 8 req/s and add exponential backoff on 403s.
Community servers are a great start. A gateway gives you your own cache, one team User-Agent identity, per-agent audit, and a controlled alerting loop - without third-party lock-in or per-call fees.
The RSS/Atom feed for streaming new filings, the submissions API for a company's filing history, the EFTS full-text search for topic discovery, and the companyfacts XBRL API for fundamentals.
A descriptive User-Agent, a limiter at 8 req/s, TTL caching, RSS polling instead of search polling, and never retrying a 403 immediately - wait at least 60 seconds with exponential backoff.
Deepak Bagada
Author Profile

Deepak Bagada

CEO, SaaSNext

Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.

Related Intelligence Analysis

Briefing AI Tools

Vercel AI SDK Tool Calling React: 5 Steps (2026)

Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...

Deepak Bagada Deepak Bagada
12m read
Breaking AI Tools

Fact-Density vs. Word Count: The New SEO for 2026

Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...

Deepak Bagada Deepak Bagada
4m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc