Skip to main content
Glama
chidrupa99

northbridge-diligence

by chidrupa99
README.md
# Northbridge Diligence — SEC EDGAR MCP + Screening Skill

**Chidrupa Mamunooru** · Delivery Engineer take-home

A two-layer tool for Northbridge Capital Partners' deal team:

- **Data layer (MCP server)** — wraps the SEC EDGAR APIs into a focused set of tools a model can call to answer diligence questions, with every number traced to its source filing.
- **Intelligence layer (skill)** — sits on top and turns a company name into a **first-pass screening memo** an analyst would hand to a deal lead.

The goal is the one the client stated: *"get this data via Claude so our analysts save time, and the deal team can trust those numbers."*

Trust is enforced structurally, not by asking the model nicely. Three things are load-bearing:

1. **Every figure carries its filing** — accession number, form type, period end, XBRL tag, and a resolvable EDGAR URL.
2. **The code computes; the model narrates.** Ratios, the judgment of whether a ratio is *meaningful*, and the risk flags are all produced in Python against fixed thresholds. The skill is forbidden from doing arithmetic or inventing a flag.
3. **The behaviour is pinned by tests** — 146 offline tests plus a golden-set regression, so a tag-mapping tweak that would silently change a margin fails the build instead.

---

## Architecture

```
company name ──► [ company-screen skill ]         (intelligence layer)
                        │  selects tools, narrates the result, writes the memo
                        │  does NOT compute ratios or decide risk
                        ▼
             [ northbridge-diligence MCP server ]  (data layer, src/northbridge_diligence/server.py)
                        │  thin MCP shim: decorators + uniform error envelope
                        ▼
             [ edgar_client.py ]                    (all logic: HTTP, XBRL, math, flags)
                        │
                        ▼
                 SEC EDGAR REST APIs
```

`edgar_client.py` holds **all** logic so it is unit-testable and reusable off-server; `server.py` is a thin registration layer. The sample memos were assembled following the skill's template from the tool functions' outputs — the same functions the MCP server exposes to a Claude client, without the intermediate protocol round-trip.

### One fetch, not eighteen

Financial data comes from `data.sec.gov/api/xbrl/companyfacts/CIK##########.json` — **one request returns every XBRL fact the filer has ever reported**. An earlier design called `companyconcept` once per curated line item — 17 of those, plus the ticker-map lookup — so 18 requests per screen, 18 chances to hit a rate limit, and 18 partial-failure modes.

A full screen is now **two HTTP calls**: the ticker→CIK map (cached) and one `companyfacts` blob (cached). A test asserts this and asserts no `companyconcept` URL is ever requested, so the property can't regress.

The same blob also hands us every prior-year comparative for free — a FY2025 10-K carries FY2023 and FY2024 columns — so a five-year history needs no extra requests.

---

## Setup

**Prerequisites:** Python **3.10 or newer**, and Claude Code or Claude Desktop. Nothing else — SEC EDGAR is public, so there is no account, API key or cost.

### 1. Get the code and install it

```bash
git clone https://github.com/chidrupa99/northbridge-diligence.git
cd northbridge-diligence
python3 scripts/doctor.py                    # runs on ANY Python; checks yours is >= 3.10 first
python3 -m venv .venv
source .venv/bin/activate                    # Windows: .venv\Scripts\activate
python -m pip install --upgrade pip          # editable installs need pip >= 21.3
pip install -e .
export EDGAR_USER_AGENT="Your Org you@example.com"    # Windows: set EDGAR_USER_AGENT=...
python scripts/doctor.py                     # 11 checks; each failure prints its own fix
```

`export` sets an **environment variable** — a value the terminal remembers and passes to programs it launches. SEC returns HTTP 403 to unidentified clients, so this is required. It is a contact string, not a credential.

### 2. Install the plugin

```
/plugin marketplace add /absolute/path/to/northbridge-diligence
/plugin install northbridge-diligence
```

Restart, and that is the install. The plugin ships the MCP server config **and** the skill together, so the two cannot end up on different Claude surfaces — which is the most common way this tool gets installed wrong.

Confirm with `python scripts/doctor.py`: check 11 reports `wired: Plugin (bundled)`.

> [!IMPORTANT]
> **On Windows**, edit one line in `plugin/.mcp.json` before installing: change the command to `${CLAUDE_PLUGIN_ROOT}/../.venv/Scripts/edgar-mcp.exe`. A single bundled config cannot cover both platforms — the interpreter path differs and there is no conditional syntax.

### 3. Use it

Say: ***"Screen Beyond Meat for the deal team"***. See [Triggering the skill](#triggering-the-skill) for what else fires it and how to tell it worked.

---

> [!NOTE]
> **Not using Claude Code?** Plugins are a Claude Code feature, so two surfaces need a different route.
>
> **Claude Desktop chat (Cowork)** — register the server by hand in `claude_desktop_config.json`, and enable the skill for your claude.ai account via **Customize** in the sidebar. Cowork sessions do *not* read `~/.claude/skills/`. **Quit the app before editing its config** — it flushes its own preferences over your write while running.
>
> **claude.ai web and mobile** — not possible. `edgar-mcp` speaks MCP over stdio, so the client launches it as a local child process, and a process in Anthropic's cloud cannot spawn a binary on your laptop. An account-synced skill will still *trigger* there but its tools are unreachable, which looks installed and produces nothing.
>
> Full walkthrough for both — per-platform config paths, the merge case when other MCP servers are already registered, the surface matrix, and hand-installing the skill for Claude Code — is in **[DEPLOYMENT.md](DEPLOYMENT.md)**.


- **Installing for a team?** → [DEPLOYMENT.md](DEPLOYMENT.md) — security posture, egress requirements, troubleshooting
- **Extending or maintaining it?** → [DEVELOPING.md](DEVELOPING.md) — test harness, fixtures, tuning knobs, invariants

---

## Triggering the skill

> [!IMPORTANT]
> **There is no command to remember.** Say it in plain language:
>
> ### *"Screen Beyond Meat for the deal team"*
>
> Any ticker or company name works — `BYND`, `Target`, `TGT`, *"size up Dollar General"*, *"run a first pass on Casey's"*.

The skill triggers on the **request**, not on a keyword. Its frontmatter `description` is what Claude matches against, so all of these fire it:

| What an analyst actually types | Fires? |
|---|---|
| "Screen Beyond Meat for the deal team" | ✓ |
| "Can you size up Tractor Supply as a comp?" | ✓ |
| "Run a first pass on Casey's General Stores" | ✓ |
| "Pull the financials on TGT" | ✓ |
| "What do we think of Ollie's Bargain Outlet?" | ✓ |
| "What's Apple's stock price today?" | ✗ — not in EDGAR; this reads filings, not market data |

### How to tell it worked

A correct result has two tells:

1. **`[S1]`-style source markers throughout** — on every figure in the memo
2. **A Sources table at the bottom**, mapping each marker to a filing accession number and URL

> [!WARNING]
> **Figures without source markers mean the skill is not being used.** Claude answered from its own knowledge instead of calling the tools, and those numbers are not traceable to a filing. Two causes, in order of likelihood:
>
> 1. **Surface mismatch** — the MCP server and the skill landed on different clients. Installing [the plugin](#2-install-the-plugin) makes this impossible; if you registered by hand instead, check the surface matrix in the *Not using Claude Code?* section and complete both cells of *one* row. `python scripts/doctor.py` check 11 reports which surfaces are wired.
> 2. **Skill not copied, or client not restarted** — check `~/.claude/skills/company-screen` exists and restart the client.

That second tell is the whole reason attribution is structural rather than prompted: a memo either carries citations on every number or it visibly does not, and a reader can tell in one glance which one they are holding.

If the company name is ambiguous — *"Delta"*, *"American"* — the skill returns candidates and asks which you mean rather than guessing. That is deliberate: screening the wrong "Bank of X" is a silent and expensive error.

See [`samples/BYND_screening_memo.md`](samples/BYND_screening_memo.md) for what a finished result looks like.

---

## The tools — what each does and *why*

Scoping the toolset was the main judgment call. The principle: **one tool = one diligence question an analyst actually asks**, each returning source-attributed data, nothing that overlaps.

| Tool | What it does | Why it exists |
|---|---|---|
| `resolve_company` | ticker/name → CIK; flags ambiguous names | Everything keys off CIK. Ambiguity is surfaced (not guessed) because screening the wrong "Bank of X" is a silent, costly error. Every tool raises the *same* disambiguation payload, so the model never has to learn two shapes. |
| `get_company_profile` | identity, SIC industry, fiscal year-end, latest 10-K/10-Q | Orients a screen (who/what/where) and anchors the fiscal calendar before pulling numbers. |
| `get_key_financials` | curated multi-year IS/BS/CF, each value source-tagged | The financial-trajectory + capital-structure backbone. Returns `reference_fiscal_year`, `xbrl_tags_used` and `mixed_tag_basis` so the reader can see *how* the series was assembled. |
| `compute_screening_metrics` | growth, margins, leverage, liquidity — **computed in code** — plus `flags`, `data_quality`, and the `thresholds` used | The client must *trust the numbers*. LLM arithmetic isn't trustworthy, so ratios are calculated in Python, returned with the sourced inputs, and each marked `meaningful: true|false`. |
| `list_filings` | recent filings + direct EDGAR URLs | Source citation, latest annual/quarterly report, and recent 8-K events worth a second look. |
| `scan_disclosure_signals` | full-text sweep for going-concern doubt, material weaknesses, customer concentration and goodwill impairment — with a **computed** boilerplate-vs-signal verdict | The numbers cannot show what a company is *worried about*. Two things make this more than a search box: the phrasing is calibrated against real filings, and the judgment of whether a hit means anything is computed rather than narrated — language present in every annual report is template text, language that comes and goes is news. Deliberately kept **out** of `flags`, which stays reserved for red flags code can verify arithmetically. |
| `get_risk_factors` | Item 1A from the latest 10-K | The "risk section worth a second look," and the verification step for anything `scan_disclosure_signals` reports as present. Conservative extraction — returns the source URL and a note rather than a guess if it can't isolate the section. |
| `get_financial_concept` | any one metric/US-GAAP tag as a time series | Escape hatch for questions the curated set doesn't cover (R&D, capex). Keeps the curated tools focused while staying flexible. Tag names are validated against a strict pattern before they reach a URL. |

### The second scoping decision: ~500 concepts down to 17

Scoping happened twice — once at the tool surface above, and once at the data layer. The second is less visible and arguably more consequential.

Filers do not report a common set of concepts. Measured across seven filers in different industries: Beyond Meat reports 376 US-GAAP concepts, Apple 503, JPMorgan 917. Between them they use **2,220 distinct concepts, of which only 49 are common to all seven** — a shared core of roughly 2%, and mostly plumbing (share counts, tax line items). Almost nothing you would build a screen on.

So `CONCEPT_MAP` curates that down to **17 concepts a first-pass PE screen actually turns on**, each mapped to an *ordered* list of candidate US-GAAP tags:

| Statement | Concepts |
|---|---|
| Income | `revenue` · `cost_of_revenue` · `gross_profit` · `operating_income` · `interest_expense` · `net_income` |
| Balance sheet | `total_assets` · `total_liabilities` · `current_assets` · `current_liabilities` · `stockholders_equity` · `cash` · `long_term_debt` · `current_debt` |
| Cash flow | `operating_cash_flow` · `depreciation_amortization` · `capex` |

17 concepts, 33 candidate tags — 11 of the 17 need more than one spelling, because filers disagree:

```python
"revenue": [
    "RevenueFromContractWithCustomerExcludingAssessedTax",  # post-ASC 606: Apple, Target, Tesla
    "Revenues",                                             # JPMorgan, Pfizer, Realty Income
    "RevenueFromContractWithCustomerIncludingAssessedTax",
    "SalesRevenueNet",                                      # legacy, pre-2018
],
```

The order encodes preference, not just alternatives: the modern tag wins where both exist, and the legacy tag fills the years before the transition.

**No single revenue tag covers all seven filers above.** The two leading tags cover six each — but not the same six. The contract-revenue tag misses JPMorgan; `Revenues` misses Beyond Meat. That is why this is a list rather than a string, and why the merge happens per fiscal year rather than per tag.

Curation is not a ceiling. Anything outside the 17 stays reachable through `get_financial_concept`, by friendly name or raw tag — so the curated set keeps the common path focused without making the uncommon question impossible.

### Attribution model

Every financial datapoint is a `SourcedValue` carrying `period_end`, `fiscal_year`, `form`, `accession`, `xbrl_tag`, `filed`, a resolvable `source_url`, and — when the figure was later revised — `restated` plus `originally_reported`. The skill renders these as `[S#]` markers mapping to a Sources table. **If a number has no source, it does not go in the memo.**

---

## The design decision the whole thing turns on: code computes, the model narrates

The first version of this tool put the interesting judgment in the skill prompt: *"flag negative equity rather than quoting the exploded ratio," "call out current ratio below 1."* It produced good memos. It was also unfalsifiable — two runs could disagree, and nobody could point at the line that decided.

So the judgment moved into Python:

**Ratio meaningfulness.** `_guarded_ratio` returns `(value, meaningful, caveat)`. A negative denominator, a zero denominator, or a denominator near zero relative to the numerator all yield `meaningful: false` with an explanation. Beyond Meat's debt/equity is arithmetically −417.0; the tool returns that number marked unmeaningful with *"denominator is negative — the ratio is not interpretable; report the negative balance itself instead."* The skill is instructed never to quote an unmeaningful figure.

**Risk flags.** `_detect_flags` returns a structured list — `code`, `severity`, `message`, `evidence` (SourcedValues) — against the thresholds in one `THRESHOLDS` dict that is *returned with the response*, so the reader always sees the bar that fired:

`NEGATIVE_EQUITY` · `EARNINGS_QUALITY` · `LIQUIDITY` · `LEVERAGE` · `COVERAGE` · `NEGATIVE_EBITDA` · `REVENUE_DECLINE` · `CASH_BURN` · `STALE_DATA` · `MISSING_DATA` · `TAG_DISCONTINUED` · `MIXED_TAG_BASIS` · `RESTATED`

The payoff is that an analyst can re-run `compute_screening_metrics("BYND")` and get byte-identical flags. That is not a property a prompt can have. The skill's remaining job — deciding what leads the memo, what a deal lead actually needs to know — is exactly the part that *should* be a language model's.

---

## XBRL is messier than it looks — what the client handles

These are the cases that separate a working screen from a demo. Each is covered by a test named after the failure it prevents.

**Filers switch tags mid-history.** Given the tag ladders above, the naive implementation is first-tag-wins: try the modern tag, and if it returns anything, stop. That silently truncates history at the ASC 606 transition — Ford came back with 10 years instead of 19, Target 10 instead of 18. Fixed by merging candidates **per fiscal year** rather than per tag. Where a series spans more than one tag, `mixed_tag_basis` says so and an info flag fires.

**Fiscal years aren't calendar years.** Target's fiscal 2009 ended 2010-01-30. Taking `end[:4]` labels it 2010 and puts two "2009"s in the series. The fix reads the filer's own `fy` label out of the facts — within one accession, the fact with the latest period end *is* that report's own year — and carries the offset over to comparatives the index didn't cover directly. A test pins `2010-01-30 → FY2009`.

**Filers abandon tags.** Target stopped tagging `GrossProfit` after FY2017. A positional slice (`series[-5:]`) happily returned FY2013–2017 gross profit and set it beside FY2021–2025 revenue — which would have produced a gross margin dividing an eight-year-old numerator by a current denominator. Every series is now windowed **by fiscal year** against a `reference_fiscal_year` derived from anchor items, and each point-in-time metric reads its exact year or nothing. An abandoned tag becomes a visible gap plus a `TAG_DISCONTINUED` flag, never a stale number.

**Filers restate.** When the same period appears in multiple filings, `RESTATEMENT_POLICY` (default `as_last_reported`) picks one, and the value carries `restated: true` with `originally_reported` so the change is auditable rather than invisible.

**Currency.** Units are chosen preferring USD and never mixed; if line items span currencies, every metric is marked unmeaningful rather than quietly dividing dollars by euros.

### Other error & edge-case handling

- **Missing SEC header → 403:** detected and turned into an actionable message.
- **Bad ticker / private company:** clear "no SEC filer matched" (EDGAR only covers registered filers).
- **Ambiguous names:** returns candidates instead of guessing, from every tool.
- **Rate limits & transient failures:** self-throttled to ~8 req/s (under SEC's 10/s), with exponential-backoff retries on 429/5xx that honour `Retry-After`. 403/404 are *not* retried — they won't get better.
- **Caching:** a bounded, TTL'd, thread-safe cache (64 entries, 1h) so a deal sprint's repeat screens are instant and gentle on EDGAR. `STATS` exposes requests/cache hits/retries.
- **Messy 10-K HTML:** risk-factor extraction is conservative; failure returns the source URL for manual review.
- **Uniform error envelope** at the server layer (`{"error": ..., "recoverable": ...}`) so one bad call never crashes the model's turn.

---

## Tests

146 offline tests plus a golden-set regression, in about a second. Three things about them are deliberate:

**Recorded fixtures, not hand-written mocks.** Real EDGAR responses for two filers chosen for what they break. Beyond Meat gives negative equity, negative EBITDA and positive net income on a loss-making operating business; Target gives a January fiscal year end, a mid-history tag switch, and an abandoned `GrossProfit`. Hand-written mocks never invent a January-FYE retailer that stops tagging gross profit — real filings do, which is the point.

**Named after failure modes, not functions** — `test_january_year_end_uses_the_filers_own_label_not_the_calendar_year`, `test_abandoned_tag_becomes_a_gap_not_a_stale_number`, `test_a_full_screen_costs_two_http_calls`. EDGAR rarely crashes you; it hands you a plausible wrong number, so each test pins one specific way that happens.

**The golden set is the behavioural contract.** It snapshots entire screen outputs and, on failure, names the field — `flags lost: ['LIQUIDITY']` — rather than dumping a 200-line dict. I verified the harness actually bites by moving the current-ratio threshold from 1.0 to 0.5 and confirming it reported exactly that.

One limit worth stating: the suite is offline, so it verifies *logic*, not *installation* — it passes with EDGAR unreachable and no contact header set. `scripts/doctor.py` covers that gap. Running and extending the suite is documented in [DEVELOPING.md](DEVELOPING.md).

---

## Design decisions & seams

**What I deliberately left out** (scope discipline — each is a defensible *next* addition, not an oversight):

- **Public comps / peer benchmarking.** High value, but it needs a peer-selection method (SIC is too crude) and multiplies API load. It's the first thing I'd add (see below), built on `get_key_financials`.
- **Open-ended full-text search.** `scan_disclosure_signals` wraps EDGAR's FTS endpoint, but only behind curated phrases with a computed verdict. A general "search filings for X" tool was deliberately not exposed: an arbitrary phrase gives the model no way to tell boilerplate from news, which is the entire difficulty. `extra_phrases` is the escape hatch, and it carries the caveat.
- **Quarterly / TTM data.** The screen is annual-first for signal clarity. The plumbing (`annual_series`) generalizes to quarterly with a filter change.
- **Insider / ownership (Forms 3/4/5) and institutional holdings.** Useful for a deeper look, noise for a first pass.

**Where the seams are (known limitations):**

- **US-GAAP XBRL only.** Foreign private issuers (20-F / IFRS) and non-XBRL filers won't populate `get_key_financials`; the tool says so rather than returning partial nonsense.
- **"Total debt" is approximated** as long-term + current debt tags; finance-lease and other debt-like items aren't fully assembled. Flagged where it matters.
- **EBITDA is a proxy** — operating income plus D&A from the cash flow statement. It is not any filer's adjusted definition and shouldn't be compared to one.
- **Thresholds are one global set.** A 4.0x debt/EBITDA bar means different things in software and in distribution. Industry-relative bands are a real improvement, but they need a defensible peer set first — which is the comps tool.
- **Risk-factor extraction is heuristic** (heading match on messy HTML); it degrades to "here's the source, read it yourself" instead of fabricating. The heuristic has been through one real failure: anchoring on the *last* heading match returned 2,958 characters of the wrong section on Dollar General's 10-K — where Item 1C cross-references Item 1A — and reported it as a successful extraction. It now uses the widest start/end pairing to identify the real end marker, then takes the last heading before it, which excludes both contents rows and later cross-references. A filer with a stranger document structure could still defeat it.
- **Covenant compliance is not searchable.** A covenant pack was built and then removed: `"covenant violation"`, `"waiver from our lenders"` and `"not in compliance with the covenants"` each returned zero hits even against a genuinely distressed filer, while bare `"covenant"` returned 84 (Beyond Meat) and 274 (Target) of pure boilerplate. A pack that always reports "absent" is worse than no pack, because absence is written into the memo as a finding. Stated as a gap instead.
- **Disclosure phrases trade precision for recall, on purpose.** The first version used the full formal wording — `"material weakness in our internal control over financial reporting"` — which matched **0** Target filings while `"material weakness"` matched 23. An over-specific phrase produces a false `absent`, and that is the most damaging error this tool can make. Precision is recovered downstream by the boilerplate classification and the requirement to read the filing.

---

## Sample output

Two real screens, generated live from EDGAR, each as Markdown and a self-contained HTML one-pager. The brief asks for one; a second is included because a single company cannot show that the tool *discriminates*.

**[Beyond Meat](samples/BYND_screening_memo.md) — the distress case.** Five flags fire. It demonstrates the thing that separates a real screen from a naive one: FY2025 **net income is positive (+$219M) while the operating business lost −$334M**. A naive tool reports "profitable." This one raises an `EARNINGS_QUALITY` flag naming the ~$553M of profit that came from outside the operating business, marks debt/equity unmeaningful because equity is negative, and tells the deal lead to identify the non-operating item first — because that item is *not* isolable from XBRL, and saying so is more useful than guessing at it.

**[Target](samples/TGT_screening_memo.md) — the healthy case.** Every solvency flag stays silent, which is the harder thing to demonstrate. Revenue is flat over five years while operating income fell 43%, so the finding is a margin problem inside a sound balance sheet. The one flag that does fire — `LIQUIDITY`, current ratio 0.94 — is shown in the memo to be an artefact of applying one global threshold to a retailer, where payables structurally exceed receivables, rather than a finding. That is a seam this README already declares, caught working in public. Target also exercises the January fiscal year end, a mid-history tag switch across six series, and a gross margin derived because `GrossProfit` has been untagged since FY2017.

---

## What I'd build next (another week)

1. **Comps tool.** Assemble a peer set (SIC + size band, human-overridable) and return side-by-side growth, margin and leverage with the same attribution. A margin falling 240bp means one thing alone and another against five peers — and it's what would make industry-relative thresholds defensible instead of one global set.

2. **Linkbase-aware statements.** Statement structure lives in each filing's `_pre.xml` and `_cal.xml`, not in `companyfacts`. `_cal.xml` declares which lines sum to which subtotals, giving a free reconciliation check — JPMorgan's net interest income ($95.4bn) plus noninterest income ($87.0bn) ties exactly to $182.4bn of revenue. `_pre.xml` explains gaps: JPMorgan's income statement has no operating income line at all, which turns a bare `MISSING_DATA` flag into a structural answer. It would surface candidates for a human, never auto-substitute — mapping pretax income onto operating income would break the report-gaps-never-estimate invariant, and for a bank the difference is the loan loss provision.

3. **A covenant signal that actually works.** Exact-phrase search failed and was cut (see seams). The tractable version reads the debt footnote and Item 7 liquidity discussion directly instead of pattern-matching the filing — it targets the one capital-structure question this screen cannot answer.

4. **Year-over-year risk-factor diff.** A *newly added* risk factor is a far stronger signal than any standing one. The section extractor already spans Items 1A/3/7/9A, so the plumbing exists; the open problem is aligning risk factors across years in a way that survives rewording.

5. **Widen the golden set.** 15–20 filers covering a foreign issuer, a recent IPO, a restatement and a spin-off, run in CI. Trust needs tests, and the tests need coverage.

TDQS

A4.4/5.0

Scored across 8 tools

Disambiguation5/5

Each tool has a clearly distinct and well-defined purpose: resolving company identifiers, retrieving profile info, financial data, computed metrics, filing lists, risk text, flexible concept access, and disclosure language scanning. No overlap or ambiguity.

Naming Consistency5/5

All tool names follow a consistent verb_noun pattern in snake_case (e.g., resolve_company, get_company_profile, compute_screening_metrics). The verbs are descriptive, and the pattern is uniform, making intention clear.

Tool Count5/5

With 8 tools, the server is well-scoped for its purpose of SEC-based financial due diligence. Each tool addresses a necessary step without being excessive or minimal.

Completeness5/5

The tool set covers the full workflow: company identification, profile, financials, computed metrics, filing access, risk factors, and disclosure signals. The escape hatch for arbitrary concepts fills any gaps, making it comprehensive.

Maintenance

ActivityStale
ResponsivenessNo issues