Skip to main content
Glama
mcp-courtwatch

mcp-courtwatch

by haksanlulz

mcp-courtwatch

MCP server for free U.S. case-law and court-docket search, over CourtListener (the Free Law Project's open legal database). Built for legal-aid orgs, tenant-defense and pro-se litigants, and public-interest lawyers who cannot afford Westlaw or PACER.

It wraps CourtListener's REST API v4, normalizing the raw JSON (caseName, dateFiled, cluster_id, docket_absolute_url, and so on) instead of passing the envelope through; see the field map below.

Tools

Tool

Arguments

Returns

opinion_search

q (required), court, filed_after, filed_before, order_by, limit, cursor

Full-text case-law search (/search/?type=o). Per hit: case name, court, date filed, citations, docket number, snippet, citation count, cluster id, link, and opinions, the {id, type} of each opinion in the case, which is the keyless input to cited_by. Also returns next_cursor (pass it back as cursor for the next page).

docket_lookup

q and/or docket_number (at least one), court, limit, cursor

Docket search (/search/?type=r). Per hit: case name, court, docket number, filed/terminated dates, nature of suit, docket id, link. Also returns next_cursor (pass it back as cursor for the next page).

court_list

jurisdiction, q, limit

Courts and their ids (/courts/), the values used as the court filter above. Optional jurisdiction filter and a name substring filter applied across the full courts table (paged server-side).

case_detail

id (required), type (cluster or opinion)

Full case by id. A cluster (/clusters/{id}/) gives case name, citations, date, judges, and its opinion ids. An opinion (/opinions/{id}/) gives the full opinion text. Requires a token.

citation_lookup

text (required)

Verify citations (POST /citation-lookup/). Pass free text (a brief, a draft) or a single citation string; every citation recognized is checked against the database of real cases. Per citation: FOUND (with matched case name, date, link) or an explicit NOT_FOUND / UNKNOWN_REPORTER flag. Requires a token.

judge_lookup

name_last and/or name_first (at least one), limit

Judges / people (/people/). Per person: id, assembled name, birth and death dates and place, gender, count of positions on file. One page of 20 max; more_available says whether more match.

cited_by

opinion_id (required), order_by, limit, cursor

Every opinion citing a given opinion, via the cites:() search operator. A free citator check (who still relies on this case). Newest-first by default. No treatment classification. Keyless, and the id comes from opinion_search's opinions[].id so the whole chain works without a token (case_detail's sub_opinion_ids is the token-gated alternative).

case_authorities

opinion_id (required), limit, cursor

The authorities an opinion relies on (its table of authorities) with a per-authority citation depth, via /opinions-cited/. Token required. Also returns next_cursor; pass it back as cursor for the next page. /opinions-cited/ defers its count, so the cursor is the only signal that the table is longer than one page.

docket_entries

docket_id (required), limit, cursor

A federal docket's filing history from the RECAP archive: numbered entries, dates, descriptions, archived PACER documents with page counts and availability. Token required. RECAP holds what its users bought from PACER.

oral_arguments

q (required), court, order_by (relevance/newest/oldest), argued_after, argued_before, include_transcript_status, limit, cursor

Oral-argument audio search (type=oa): case, court, argue date, panel judges, duration, audio id, MP3 link, and snippet, an excerpt of the recording's machine-generated Whisper transcript (not a certified transcript), and 708 of CourtListener's audio records are flagged as transcripts that do not match their audio. Each row also carries transcript_status, which reads NOT_CHECKED unless include_transcript_status is set (that costs one extra request per row; see the caveats). Keyless.

oral_argument_transcript

audio_id (required), offset, max_chars

The recording's Whisper transcript text (/audio/{id}/), paged. Returns stt_verdict (CourtListener's own transcription status, mapped), transcript_usable, transcript_chars, truncated, next_offset, and provenance. Only a COMPLETE transcription returns text: one flagged TRANSCRIPTION_DOES_NOT_MATCH_AUDIO has text on the record and it is withheld here. Keyless.

order_by for opinion_search is one of relevance (default), newest, oldest, most_cited. Court jurisdiction codes include F (federal appellate and other), FD (federal district), FB (bankruptcy), S (state), SA (state appellate), SS (state supreme).

Related MCP server: courtlistener-mcp

Data source

  • Base URL: https://www.courtlistener.com/api/rest/v4

  • Auth: a free API token, sent as the header Authorization: Token <token>.

  • Envelope: search and list endpoints return the DRF shape { count, next, previous, results: [...] }. /search/ and /people/ paginate by opaque cursor; /courts/ paginates by page number (?page=N). Detail endpoints return a bare object. POST /citation-lookup/ returns a bare JSON array (one item per citation recognized in the text).

  • Access: /search/, /courts/, /people/, and /audio/{id}/ answer without a token at a low rate limit, so those tools attach the token only when it is set (a token raises the limit). /clusters/{id}/, /opinions/{id}/, /opinions-cited/, /docket-entries/, and POST /citation-lookup/ return HTTP 401 without a token, so case_detail, citation_lookup, case_authorities, and docket_entries require one. The /docket-entries/ filter parameter is docket, not docket_id (the API answers 400 unknown_params otherwise; found live).

  • Citation-lookup limits (server-side): text max 64,000 characters, counted after surrounding whitespace is stripped, since the serializer strips before it validates (enforced pre-flight here with a clear error; the tool never truncates, since a dropped tail would mean unchecked citations); the first 250 citations per call are looked up and any beyond that come back flagged per-item as not checked; rate limit 60 citations/min.

Sources:

Field map (CourtListener to normalized output)

CourtListener field

Normalized field

Where

caseName (fallback: caseNameFull on opinion hits, case_name_full on docket hits)

case_name

opinion_search, docket_lookup

court_id, court

court_id, court

search hits

dateFiled

date_filed

search hits

citation (array)

citations

opinion_search

docketNumber

docket_number

search hits

opinions[].snippet

snippet

opinion_search

opinions[] (id, type)

opinions (array of {id, type})

opinion_search, cited_by

cluster_id, docket_id

cluster_id, docket_id

search hits

absolute_url / docket_absolute_url

absolute_url (made a full link)

search hits

dateTerminated, suitNature

date_terminated, nature_of_suit

docket_lookup

id, full_name, short_name, jurisdiction, citation_string, url

id, full_name, short_name, jurisdiction, citation_string, website

court_list

citations (array of {volume, reporter, page})

citations (formatted strings)

case_detail (cluster)

sub_opinions (array of URLs)

sub_opinion_ids

case_detail (cluster)

plain_text (fallback html_with_citations)

text (+ text_source, text_truncated)

case_detail (opinion)

citation, normalized_citations, start_index, end_index

same names; the two offsets are re-anchored onto the caller's own string

citation_lookup (per citation)

status (200/300/400/404/429), error_message

status + verdict (FOUND, FOUND_MULTIPLE, UNKNOWN_REPORTER, NOT_FOUND, NOT_CHECKED_OVER_CAP) + verified, error_message

citation_lookup (per citation)

clusters (array of cluster objects)

matches (cluster id, case name, date, citations, link)

citation_lookup (per citation)

count on a cites:() search

total_citing (hits normalized exactly as opinion_search hits)

cited_by

cited_opinion / citing_opinion (resource URLs), depth

cited_opinion_id, citing_opinion_id, depth

case_authorities

entry_number, date_filed, description, recap_documents[] (document_number, page_count, is_available, filepath_local)

same names; description_short falls back to short_description

docket_entries

dateArgued, dateReargued, judge, duration, id, download_url

date_argued, date_reargued, judges, duration_seconds, audio_id, download_url

oral_arguments

snippet (the indexed text for type=oa IS the Whisper transcript, so this is machine speech-to-text, not a certified transcript; live 2026-09-14, audio 102928's 497-character snippet is byte-identical to the opening of its stt_transcript)

snippet

oral_arguments

stt_status (0-5)

stt_verdict (TRANSCRIPTION_NEEDED, COMPLETE, TRANSCRIPTION_FAILED, TRANSCRIPTION_DOES_NOT_MATCH_AUDIO, AUDIO_FILE_TOO_BIG, AUDIO_FILE_MISSING) + transcript_usable; on a search row, transcript_status

oral_argument_transcript, oral_arguments

stt_source (1 / 2)

stt_source (OpenAI API whisper-1 / self-hosted Whisper)

oral_argument_transcript

stt_transcript

text (+ transcript_chars, offset, returned_chars, truncated, next_offset)

oral_argument_transcript

Install

Bundle (.mcpb). For a client that installs MCP bundles, build one and open it. No JSON to edit, and the API token is an optional field in the install dialog:

npm ci && npm run mcpb:pack     # writes build/mcp-courtwatch-<version>.mcpb

The bundle is self-contained (manifest, dist/, and the one runtime dependency) and runs node dist/index.js. npm run verify:mcpb packs it, unpacks it, launches it through the manifest's own mcp_config with no token in the environment, and checks that the tools it serves match the ones the manifest declares. CI runs that on every push.

npm. Nothing to clone. Point your MCP client at it and npm fetches it on first run:

{
  "mcpServers": {
    "courtwatch": {
      "command": "npx",
      "args": ["-y", "@haksanlulz/mcp-courtwatch"],
      "env": { "COURTLISTENER_API_TOKEN": "your-courtlistener-token" }
    }
  }
}
git clone https://github.com/haksanlulz/mcp-courtwatch
cd mcp-courtwatch
npm install
npm run build     # emits dist/; the published bin is dist/index.js

npm start runs the TypeScript directly via tsx without building.

API token

case_detail, citation_lookup, case_authorities, and docket_entries need a free CourtListener token, and the other tools run faster (higher rate limit) with one. Create a free account, open Profile then the API page, and copy the token. Docs: https://www.courtlistener.com/help/api/rest/

Expose it as COURTLISTENER_API_TOKEN:

export COURTLISTENER_API_TOKEN=your-token-here   # macOS / Linux
setx COURTLISTENER_API_TOKEN your-token-here      # Windows (new shells)

Without the token, opinion_search, docket_lookup, court_list, judge_lookup, cited_by, oral_arguments, and oral_argument_transcript still work at CourtListener's unauthenticated rate limit. The token-gated tools return a clear error telling you to set the token. The token is never logged.

New-account rate limit, and it has three parts. Fresh CourtListener accounts are throttled at 5 requests/minute (it rises as the account ages); set COURTWATCH_THROTTLE_MS=15000 in the server's env to pace under that; the default spacing is 200ms.

Pacing doesn't buy you the other two. There is also a fixed hourly ceiling and a fixed daily one, and no throttle value spends under either: HTTP 429 ... Rate limit exceeded: 50/hour and HTTP 429 ... Rate limit exceeded: 125/day, both observed live on this repo's own token on 2026-09-14 while correctly paced at 15000ms. The 429 body names the seconds until reset, counting down. A single request against the daily ceiling that day answered {"detail":"Request was throttled. Rate limit exceeded: 125/day. Expected available in 46707 seconds."}, about 13 hours.

npm run smoke makes twelve requests at minimum, eleven checks plus one to resolve a docket id, and up to two more when the docket_entries rung walks past a docket RECAP holds nothing for. So a single run spends a meaningful share of the hour's budget and roughly a tenth of the day's: a second run inside the same hour reports FAIL on whatever the hourly ceiling refuses, and after about ten runs in a day the rung is blocked until the daily one resets. A 429 is not retried when its stated wait is longer than RETRY_WINDOW_MS (retrying would spend two more requests of a budget CourtListener has already said is gone). RETRY_WINDOW_MS is how far into the retry loop the last attempt starts: it walks RETRY_BACKOFF_MS = [500, 2000] under the same deadline test withRetry uses, so with the default three attempts (CL_HTTP_ATTEMPTS) it is 2,500 ms. It is not RETRY_DEADLINE_MS (40,000 ms), which only bounds how late an attempt may start. A 429 that names a wait of 2.5 seconds or less, or names no wait, still gets its three attempts.

There is no workaround but waiting it out, or an account old enough to have higher limits.

Example

Call opinion_search with { "q": "warrantless search", "court": "scotus", "order_by": "most_cited", "limit": 1 } (output captured live, 2026-09-14; counts drift as CourtListener grows):

{
  "query": { "q": "warrantless search", "court": "scotus", "filed_after": null, "filed_before": null, "order_by": "most_cited", "cursor": null },
  "total_matches": 282,
  "returned": 1,
  "dropped_from_this_page": 19,
  "next_cursor": "cz01MTM2JnM9MTA4NTcxJnQ9byZkPTIwMjYtMDktMTQmcD0y",
  "note": "limit kept 1 of the 20 rows CourtListener sent for this page. next_cursor begins at the next page, so paging with it skips the 19 dropped row(s) — raise limit to 20 when you intend to page.",
  "results": [
    {
      "case_name": "Monell v. New York City Dept. of Social Servs.",
      "court": "Supreme Court of the United States",
      "court_id": "scotus",
      "date_filed": "1978-06-06",
      "citations": ["56 L. Ed. 2d 611", "98 S. Ct. 2018", "436 U.S. 658", "1978 U.S. LEXIS 100", "16 Empl. Prac. Dec. (CCH) 8345", "17 Fair Empl. Prac. Cas. (BNA) 873"],
      "docket_number": "75-1914",
      "cite_count": 42979,
      "status": "Published",
      "snippet": "\n436 U.S. 658 (1978)\nMONELL ET AL.\nv.\nDEPARTMENT OF SOCIAL SERVICES OF THE CITY OF NEW YORK ET AL.\nNo. 75-1914.\nSupreme Court of the United States. ...",
      "opinions": [{ "id": 109881, "type": "combined-opinion" }],
      "cluster_id": 109881,
      "docket_id": 266243,
      "absolute_url": "https://www.courtlistener.com/opinion/109881/monell-v-new-york-city-dept-of-social-servs/"
    }
  ],
  "disclaimer": "Raw public court records from CourtListener, reproduced as published. This is not legal advice and is not a substitute for a lawyer. Docket entries record filings, not rulings; the absence of a record is not evidence that nothing happened."
}

The disclaimer is attached to every response, in the payload and not only in the tool description, because a model composing an answer has the payload in hand and may no longer be holding the description.

dropped_from_this_page and the note are why limit: 1 is a bad way to page. /search/ serves one fixed ~20-row page and ignores page_size, so limit slices client-side while next_cursor starts after the whole page, so paging from here would skip the 19 rows the slice dropped. Raise limit to 20 when you intend to page.

Then pass the cluster_id to case_detail ({ "id": 109881 }) for the citations, judges, and opinion ids, or case_detail with { "id": <opinion id>, "type": "opinion" } for the full opinion text. Both need a token. Without one, opinions[0].id from the hit above goes straight to cited_by. For the next page, pass next_cursor back as cursor.

Example: verifying citations before filing

Courts have sanctioned filings built on citations that do not exist. Run a draft's citations through citation_lookup before filing.

Call citation_lookup with { "text": "Tenants are protected here. See Roe v. Wade, 410 U.S. 113 (1973); Smith v. Imaginary, 999 U.S. 9999 (2099)." }. The output below is illustrative of the response shape (the live smoke runs this exact real-plus-fabricated check and fails unless the real one resolves and the fake flags NOT_FOUND):

{
  "query": { "text_chars": 107 },
  "citations_checked": 2,
  "found": 1,
  "not_found": 1,
  "invalid": 0,
  "not_checked": 0,
  "all_verified": false,
  "coverage_note": "Only citations in recognized reporter formats are checked. A citation-like string with an unrecognized or invented reporter is invisible to this check — it is not counted and not flagged. all_verified covers recognized citations only.",
  "warning": "1 of 2 citation(s) did NOT verify: 1 not found in CourtListener (likely fabricated or mis-cited). Do not cite unverified authorities — check them by hand before filing.",
  "results": [
    {
      "citation": "410 U.S. 113",
      "verified": true,
      "verdict": "FOUND",
      "status": 200,
      "error_message": null,
      "normalized_citations": ["410 U.S. 113"],
      "start_index": 45,
      "end_index": 57,
      "matches": [
        {
          "cluster_id": 108713,
          "case_name": "Roe v. Wade",
          "date_filed": "1973-01-22",
          "citations": ["410 U.S. 113", "93 S. Ct. 705", "35 L. Ed. 2d 147"],
          "precedential_status": "Published",
          "citation_count": 12030,
          "judges": "Blackmun",
          "docket_id": 4463,
          "absolute_url": "https://www.courtlistener.com/opinion/108713/roe-v-wade/"
        }
      ]
    },
    {
      "citation": "999 U.S. 9999",
      "verified": false,
      "verdict": "NOT_FOUND",
      "status": 404,
      "error_message": "Citation not found: '999 U.S. 9999'",
      "normalized_citations": ["999 U.S. 9999"],
      "start_index": 86,
      "end_index": 99,
      "matches": []
    }
  ],
  "disclaimer": "Raw public court records from CourtListener, reproduced as published. This is not legal advice and is not a substitute for a lawyer. Docket entries record filings, not rulings; the absence of a record is not evidence that nothing happened."
}

The fabricated citation comes back NOT_FOUND with a top-level warning. Per-citation status mirrors the API's own codes: 200 found, 300 found with multiple matching clusters (FOUND_MULTIPLE: a real citation, ambiguous mapping), 400 unknown reporter, 404 not found, 429 past the 250-citations-per-call cap (NOT_CHECKED_OVER_CAP: split the text and re-run the rest). A lookup that recognizes zero citations says so in a note instead of pretending to have verified anything.

The blind spot to know about: the extractor can only flag what it can recognize. 999 U.S. 9999 is caught (real reporter, fake volume: NOT_FOUND), but a cite with an invented reporter (live example 999 A.D.9th 999) isn't recognized as a citation at all, so it is neither counted nor flagged. Every response carries a coverage_note stating this; treat all_verified as covering recognized citations only.

Worked example: is this tenant case still good law?

A caseworker is writing an intake note on a habitability defense in New York and needs the leading case plus some sign it is still being relied on. No token, no account, three tool calls. Every figure below was captured live on 2026-09-14 and will drift as CourtListener grows.

1. Find the leading case. opinion_search with { "q": "warranty of habitability eviction", "court": "ny", "order_by": "most_cited" } → 8 results. The fields that matter from the first one:

Field

Value

case_name

Park West Management Corp. v. Mitchell

date_filed

1979-06-07

cite_count

393

cluster_id

5683523

opinions[0].id

5532217 (lead-opinion)

cite_count is how many opinions cite it, which is why it sorts first under most_cited.

2. Take the opinion id, not the cluster id. cited_by wants an opinion id, and opinions[0].id above is it. case_detail would also give you one, in sub_opinion_ids, but that endpoint needs a token; the search hit does not.

3. Ask who cites it. cited_by with { "opinion_id": 5532217 } → total_citing 174, newest first:

Field

Value

total_citing

174

results[0].case_name

Fiondella v. 345 W. 70th Tenants Corp.

results[0].date_filed

2023-06-13

results[0].court_id

nyappdiv

What a caseworker can paste into an intake note:

The leading New York case on the warranty of habitability is Park West Management Corp. v. Mitchell, decided 1979-06-07. As of 2026-09-14, CourtListener records 174 opinions citing it, the most recent being Fiondella v. 345 W. 70th Tenants Corp. (App. Div., 2023-06-13). That means the case is still being engaged with by New York courts. It does not mean those 174 opinions agreed with it: cited_by reports who cites a case, never whether they followed, distinguished, or criticized it. Read the citing opinions before relying on any of them, and confirm current law with a lawyer.

That last caveat is not decoration. It rides every cited_by response as a note, because the difference between "cited 174 times" and "good law 174 times" is the whole of a citator's value.

Caveats and verification state

Every tool has been run live against the real API with a real token: npm run smoke, 11/11, 2026-09-14 at COURTWATCH_THROTTLE_MS=15000. That is one real call per tool, all eleven of them since that day. oral_argument_transcript got its own check then and passes on audio 102928 (COMPLETE, 98,073 characters on the record); until then it was verified only by hand, against audio 106247 and 106047, byte-compared with a direct read of /audio/<id>/. A second run inside the same hour will not repeat that result on a new token: the hourly ceiling refuses the rest (and after about ten runs in a day, the daily one does), and those checks read FAIL with the 429, never green (see the rate-limit note in the token section). Two contract facts were only discoverable live and are baked in: the /docket-entries/ filter parameter is docket (an unauthenticated probe cannot see this, since auth is checked before params), and new-account rate limiting is 5 requests/minute (see the token section). Standing caveats that are properties of the API, not gaps in verification:

  • The citation checker has a structural blind spot, named in every payload. It can only check citations whose reporter it recognizes. A fabricated cite with an invented reporter (live example: 999 A.D.9th 999) is not recognized, not counted, and not flagged, so all_verified: true means "every recognized citation resolved" and never "nothing in this text is fake." Every citation_lookup response carries a coverage_note saying exactly this.

  • The clusters returned by citation_lookup do not include the court (in CourtListener's model the court hangs off the docket, not the cluster). For the court, follow the match's absolute_url or pass its cluster_id to case_detail. Deliberately not auto-fetched: a 250-citation brief would fan out into hundreds of extra docket calls.

  • A docket_number argument is sent through the fielded docketNumber:"..." search operator (live: 6 matches where the free-text form matched thousands). Combine with q for case-name context when a docket number alone under-returns.

  • court_list with a name filter walks the full courts table one page at a time (the /courts/ endpoint ignores page_size; ~3,400 courts over ~170 pages). A complete walk is cached in-process for 24 hours, so it happens at most once per day per server process; scope by jurisdiction to avoid it entirely. On a 5-req/min account, a full walk can't finish inside one client timeout; filter by jurisdiction until the account limit rises.

  • opinion_search, docket_lookup, cited_by, and oral_arguments return one fixed /search/ page of ~20 results; for more, pass next_cursor back as cursor. The endpoint ignores page_size, so limit caps at 20 rather than advertising an unreachable number.

  • A limit below the page size drops rows that next_cursor then skips past. The cursor comes from the envelope, so it begins after the whole page, not after the last row returned: limit: 3 against a 20-row page returns 3 and a cursor starting at row 21. Those responses carry dropped_from_this_page and say so in note. Leave limit at 20 when you intend to page.

  • judge_lookup is the same shape one endpoint over and caps at 20 for the same reason: /people/?name_last=Smith and the same query with page_size=50 both return 20 rows with a next (live 2026-09-14). It exposes no cursor, so when more people match than one page holds the response carries more_available: true and a note. returned is that page, never the total. Narrow with name_first.

  • /docket-entries/ and /opinions-cited/ use v4 cursor pagination, which DEFERS the total count instead of omitting it: count arrives as a URL string pointing at the same query with ?count=on, so total_entries / total_authorities come back null with total_reported: false. That means "not reported here", never zero. Re-request the endpoint with ?count=on for a real total. (Verified live 2026-09-14 on /audio/?stt_status=3: count was the literal ?count=on URL, and that URL returned {"count": 708}.)

  • Oral-argument transcripts are machine speech-to-text, and CourtListener says so per record. stt_status is an enum, not a flag, and its failure values are not rare: of 103,278 audio records on 2026-09-14, 708 were status 3 (TRANSCRIPTION_DOES_NOT_MATCH_AUDIO, a Whisper hallucination), 154 status 4 (file over the 25 MB limit), 77 status 5 (no audio file), 46 status 2 (failed) and 16 status 0 (not yet transcribed). oral_argument_transcript maps the enum to a named stt_verdict and returns text only for a completed transcription; a status-3 record still carries text and it's withheld instead of presented as a transcript (audio 106047 holds 9,450 characters that are the case caption repeated over and over). Every response carries provenance saying the text is Whisper output, not a court reporter's certified transcript. Enum names and numbers from the vendor source, cl/audio/models.py.

  • Transcripts are too long to ride a search result, so they are paged: a 6,385-second SCOTUS argument (audio 104586) is 101,707 characters, a 1,051-second circuit argument (audio 106247) is 13,974 (both measured live 2026-09-14). max_chars defaults to 15,000 and caps at 50,000; a truncated page reports truncated: true and a next_offset to resume from.

  • oral_arguments' transcript_status is NOT_CHECKED unless you pass include_transcript_status, which costs one extra API request per result row. The search index does not carry stt_status, and /audio/ has no batch id filter: ?id__in= is rejected as an unknown parameter (the vendor's AudioFilter gives id the INTEGER_LOOKUPS set, which has no in). On a 5-request/minute account, lower limit before turning it on. A check that fails reads CHECK_FAILED, which means not looked up, never that no transcript exists.

Testing

npm test           # offline: vitest, fetch mocked with the documented response shapes (no token needed)
npm run smoke      # live: one real call per tool (needs COURTLISTENER_API_TOKEN; skips cleanly without)
npm run typecheck
npm run verify:pack  # packs, installs into a temp project, spawns the bin shim over real stdio
npm run verify:mcpb  # packs the .mcpb, unpacks it, launches it through the manifest's own mcp_config

Two tiers, split by script rather than by marker. npm test is the offline suite; CI runs it plus npm run typecheck, npm run build, npm run verify:pack and npm run verify:mcpb (ci.yml jobs test, package, consume). npm run smoke is the live upstream contract, token-gated, and not run in CI.

Counts as of 2026-09-25: 224 tests in 4 files (npm test), 2919 lines of app source and packaging scripts, 2678 lines of test source.

find . -path ./node_modules -prune -o -path ./dist -prune -o -path ./build -prune -o -path ./test -prune -o \( -name '*.ts' -o -name '*.mjs' \) -print | grep -v smoke.ts | xargs wc -l
find test -name '*.test.ts' | xargs wc -l

What the tests cover, by layer. test/server.test.ts drives every tool through a real MCP client over an in-memory transport with fetch stubbed, and asserts the outgoing request (path, query params, Authorization header, POST body) and the normalized response shape, plus the retry policy (5xx and 429 retried three times, 4xx and non-JSON not retried, and two exceptions that exist to stop wasting a nonprofit's budget: a 5xx whose DRF detail says review your query is the search parser refusing the query, and a 429 that names a wait longer than RETRY_WINDOW_MS), the response cache, the argument validators that must fail before any network call, and the two shapes that must never be confused with each other, an empty results array (a real answer) versus a body with no results array (an error), which is also pinned as never being cached. The validator tests are table-driven over the population, not one example: every tool that takes an id, against every way the guard can be wrong; every tool that takes an order_by, against the keys a plain object inherits from Object.prototype; every search tool's free-text q, against a trailing backslash. test/throttle.test.ts measures the outbound throttle against a fake clock, asserting call-START timestamps, and pins that an empty or unparseable COURTWATCH_THROTTLE_MS falls back to the 200ms default instead of to no throttle at all (Number("") is 0). test/env-validation.test.ts re-imports the server with a garbage CL_HTTP_ATTEMPTS / CL_CACHE_TTL_MS / CL_CACHE_MAX and asserts each falls back instead of poisoning arithmetic. test/no-http-stack.test.ts pins the dependency surface. The live smoke checks each tool once against the real API.

The offline suite runs with COURTWATCH_THROTTLE_MS=0 (vitest.config.ts): fetch is stubbed, so paying the real 200ms outbound gap bought nothing but 14 seconds of sleeping.

Every fix was mutation-probed: the code was put back to the defect and the fix's own tests had to go red, while neighbouring tests that should not care stayed green. The live smoke's verdict path was fault-injected the same way, and exits 0 on pass, 1 on failure and 2 when nothing could be measured. The per-probe record is in GAUNTLET.md, under Mutation-probe record.

The 60 toHaveBeenCalled* assertions (counted 2026-09-25 with grep -o 'toHaveBeenCalled[A-Za-z]*' test/*.ts | wc -l) each pin a named contract (no request leaves on a validation or missing-token error, retry count, cache hit, two-page walk, one status check per row). Policy: assert behavior and payloads, never merely that a function was called.

AI assistance

This project was built with AI assistance (Claude). Correctness was established by the test suite and typecheck (npm test, npm run typecheck): every tool is driven through a real MCP client over an in-memory transport with fetch stubbed to the documented CourtListener response shapes, and the unauthenticated search / courts / people surfaces were additionally checked against the live API. As of 2026-08-23 every tool, including the token-gated four, has additionally been verified live with a real token (npm run smoke, plus persona-driven scenario probes that surfaced and fixed the docket filter-param and fake-reporter-coverage findings). The author is accountable for what ships here.

License

MIT. See LICENSE. Data from CourtListener / the Free Law Project (public court records and openly licensed legal data). Unofficial, not affiliated with CourtListener or the Free Law Project.

Available Tools

10 tools
case_authoritiesA

The reverse of cited_by: every authority a given opinion RELIES ON (its table of authorities), with a depth count of how many times each is cited. Pass the citing opinion's id. Requires COURTLISTENER_API_TOKEN (authentication-only endpoint). Returns cited opinion ids; fetch interesting ones with case_detail (type opinion) or their clusters.

Inputs: opinion_id (required), limit.

case_detailA

Fetch a full case by id: a cluster (the case: name, citations, date, judges, and its opinion ids) or a single opinion (its full text). Requires COURTLISTENER_API_TOKEN (these endpoints are authentication-only). Use the cluster_id from opinion_search results.

Inputs: id (required), type.

citation_lookupA

Verify legal citations against CourtListener's database of real cases before relying on them; catches fabricated or mangled citations. Pass free text (a brief, memo, or draft) or a single citation string; every citation recognized in the text is checked. Per citation: FOUND (with case name, date, and link) or an explicit NOT_FOUND / UNKNOWN_REPORTER flag. Requires COURTLISTENER_API_TOKEN (authentication-only endpoint). Caps: 64000 characters of text and 250 citations per call (server rate limit: 60 citations/min).

Inputs: text (required).

cited_byA

Every opinion that CITES a given opinion — the free version of a citator check ('is this case still being relied on, and by whom'). Pass an opinion id (from case_detail's sub_opinion_ids, or an opinion_search hit's cluster via case_detail). Returns citing opinions newest-first or most-cited-first with the same fields as opinion_search. Works without a token. NOTE: this reports who cites the case; it does NOT classify the treatment (followed/distinguished/overruled) — read the citing opinions.

Inputs: opinion_id (required), limit, cursor, order_by.

court_listA

List CourtListener courts and their ids (the values used as the court filter in opinion_search / docket_lookup). Optionally filter by jurisdiction code and/or a name substring. Jurisdiction codes include "F" (federal appellate/other), "FD" (federal district), "FB" (bankruptcy), "S" (state), "SA" (state appellate), "SS" (state supreme). Works without a token.

Inputs: q, limit, jurisdiction.

docket_entriesA

The actual filing history of a federal docket from the RECAP archive: numbered entries, dates, descriptions, and any archived PACER documents (with page counts and availability). Pass a docket_id from docket_lookup. Requires COURTLISTENER_API_TOKEN (authentication-only endpoint). Coverage note: RECAP holds what its users have bought from PACER — an entry or document not present may still exist on PACER.

Inputs: docket_id (required), limit, cursor.

docket_lookupA

Search court dockets (CourtListener type=r) by case name, free text, and/or docket number, optionally scoped to a court. Returns case name, court, docket number, filed/terminated dates, nature of suit, and a link. Works without a token.

Inputs: q, court, limit, cursor, +1 more.

judge_lookupA

Look up judges / people in CourtListener's judiciary database (type via /people/) by last and/or first name. Returns id, assembled name, birth/death dates and place, gender, and how many positions are on file. Works without a token.

Inputs: limit, name_last, name_first.

oral_argumentsA

Search oral-argument audio recordings (CourtListener type=oa): case name, court, argue date, judges on the panel, duration, and an MP3 download link. Useful for hearing how an issue was actually argued. Works without a token.

Inputs: q (required), court, limit, cursor, +3 more.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4.1/5.0

    Scored across 10 tools

    Disambiguation4/5

    Each tool targets a distinct CourtListener data type (opinions, dockets, courts, citations, judges, oral arguments), so there is little overlap in primary purpose. The only potentially confusing pair is cited_by vs case_authorities—both take an opinion id and concern citation networks—but their descriptions explicitly frame them as reverse directions.

    Naming Consistency3/5

    Five tools follow a clean resource_action pattern (opinion_search, docket_lookup, court_list, citation_lookup, judge_lookup), but the other five diverge into noun phrases or other forms (case_detail, cited_by, case_authorities, docket_entries, oral_arguments). The mix is readable but not consistent.

    Tool Count5/5

    Ten tools is right-sized for a legal research server: opinion, docket, court, citation, judge, and oral-argument functionality each have dedicated tools without redundancy. The count matches the breadth of the CourtListener API surface without feeling bloated.

    Completeness4/5

    Core research workflows are covered end-to-end: find opinions, retrieve full text, follow the citation graph in both directions, verify citations, search dockets, and view docket histories. Minor gaps remain—no retrieval of docket entry document content (PACER) and no citator treatment classification—but these are documented limitations.

    Maintenance

    ActivityMaintained
    ResponsivenessOutdated data

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      A remotely callable MCP server for US legal research that provides tools to search, retrieve, and analyze US case law from the CourtListener API, enabling agents to build evidence packs from primary sources without generating legal content.
      MIT
    • A
      license
      A
      quality
      C
      maintenance
      An MCP server that provides AI assistants access to the CourtListener legal database (US court opinions, dockets, RECAP, PACER, oral arguments, judges) and the full eCFR federal regulations via the official CourtListener API v4. It supports bring-your-own-key authentication and is designed for use with MCP-compatible clients like Claude, VS Code, and Cursor.
      34
      4
      MIT