@pipeworx/mcp-bankruptcy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ {
2
+ "staged_at": "2026-09-23T18:57:33.534Z",
3
+ "publisher_blob": "da6994091dc9dcc6a7433084251ca4bbd58b4bed",
4
+ "publisher_commit": "1c417b2cfcdac06e2d9210e869265722ef013f20",
5
+ "note": "Written by scripts/publish-pack.sh as its LAST step. Its presence is the ONLY thing that lets stage-unpublished-packs.sh skip this pack. Deleting this dir is the correct way to force a rebuild."
6
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mojibake Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,166 @@
1
+ # Bankruptcy — US business bankruptcy filings
2
+
3
+ Business bankruptcy filings from US federal bankruptcy courts: **1.55 million
4
+ filings**, searchable by company name, court and date. No API key.
5
+
6
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1663+ live data sources.
7
+
8
+ ## Auth
9
+
10
+ None.
11
+
12
+ ## What this is, and what it is not
13
+
14
+ This is **docket metadata**: that a case exists, who filed, in which court, when
15
+ it was filed, when it terminated, and which judge was assigned. It contains
16
+ **no filings** — no petitions, schedules, motions or orders. Those live in
17
+ RECAP, a separate corpus that is not keyless.
18
+
19
+ An agent that has this data knows a company filed. It does not know what the
20
+ company said.
21
+
22
+ ## Consumer bankruptcy is deliberately excluded
23
+
24
+ Of CourtListener's 71,677,647 dockets, **35,312,051 are individual bankruptcy
25
+ filings** — half the corpus. They carry no business signal and are PII-adjacent,
26
+ so they are excluded.
27
+
28
+ The docket export has **no chapter column**, so business and consumer are
29
+ separated by a legal-entity-suffix test on the debtor name (`Inc`, `LLC`,
30
+ `Corp`, `Holdings`, `Construction`, and so on), applied only inside bankruptcy
31
+ courts. Two consequences worth knowing before relying on a negative result:
32
+
33
+ - **A sole proprietorship filing under a personal name will not appear here.**
34
+ - "No filings found" means "no *business* filing matched", never "this entity
35
+ has never filed".
36
+
37
+ ## Tools
38
+
39
+ | Tool | Returns |
40
+ |---|---|
41
+ | `bankruptcy_search` | Filings matching a company name, with court, dates and judge |
42
+ | `bankruptcy_recent` | Recent filings, newest first, optionally one court |
43
+
44
+ Court ids follow CourtListener's convention: `nysb` (S.D.N.Y. Bankruptcy),
45
+ `deb` (Delaware), `txsb` (S.D. Tex.), `cacb` (C.D. Cal.), `ilnb` (N.D. Ill.).
46
+
47
+ ## Caveats
48
+
49
+ - **Quarterly snapshot.** Every response carries `snapshot_date`. Anything filed
50
+ after it is not here.
51
+ - **`date_terminated` is 78.3% filled**, so a missing termination date means
52
+ *not recorded*, never *still open*. Responses say this on every row.
53
+ - **`nature_of_suit` is sparse** (14.8% corpus-wide) and cannot be relied on to
54
+ filter by case type.
55
+ - Case names are as the court recorded them, including abbreviations and
56
+ inconsistent entity suffixes. Search matches substrings for that reason.
57
+
58
+ ## Attribution
59
+
60
+ Data is CourtListener / Free Law Project, published under the Public Domain
61
+ Mark. Free Law Project funds and maintains the corpus. If this pack is useful
62
+ to you, <https://free.law/donate/> is where that goes.
63
+
64
+ ## Quick Start
65
+
66
+ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "bankruptcy": {
72
+ "url": "https://gateway.pipeworx.io/bankruptcy/mcp"
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ ### What this endpoint actually serves
79
+
80
+ `tools/list` at `https://gateway.pipeworx.io/bankruptcy/mcp` returns the tools in the table
81
+ above **plus the shared Pipeworx meta-tools** — `ask_pipeworx`,
82
+ `discover_tools`, `search_within`, `remember`/`recall` and the rest of the
83
+ gateway-wide set. So the tool count you see is larger than this table: a
84
+ single-pack endpoint currently lists roughly 30 shared tools alongside the
85
+ pack's own. The connection's `initialize` response states its exact scope, and
86
+ is the authoritative answer for a given day.
87
+
88
+ This is deliberate, not multiplexing by accident. The meta-tools are what let a
89
+ scoped connection answer a question this pack does not cover — via
90
+ `ask_pipeworx`, which routes across the whole catalog — without you adding a
91
+ second MCP server. There is currently no way to mount a pack endpoint without
92
+ them; if the extra schemas cost you more context than the routing is worth,
93
+ connect to the full gateway once rather than to several pack endpoints.
94
+
95
+ Or connect to the full Pipeworx gateway to get every pack's tools listed
96
+ directly, instead of just this one's:
97
+
98
+ ```json
99
+ {
100
+ "mcpServers": {
101
+ "pipeworx": {
102
+ "url": "https://gateway.pipeworx.io/mcp"
103
+ }
104
+ }
105
+ }
106
+ ```
107
+
108
+ Both URLs reach the same gateway and the same 1663+ data sources. The
109
+ only difference is which pack's tools are listed **directly**; `ask_pipeworx`
110
+ reaches all of them from either one.
111
+
112
+ ## No MCP client? Call it over HTTP
113
+
114
+ ```bash
115
+ curl -X POST https://gateway.pipeworx.io/v1/tools/bankruptcy_search \
116
+ -H 'Content-Type: application/json' \
117
+ -d '{"company":"Kmart","limit":5}'
118
+ ```
119
+
120
+ No account needed for the first calls. Inspect any tool: `GET https://gateway.pipeworx.io/v1/tools/bankruptcy_search`. Find one: `POST https://gateway.pipeworx.io/v1/tools/search_packs` with `{"query":"..."}`.
121
+
122
+ ## Standalone (no gateway account)
123
+
124
+ This package also runs as a local stdio MCP server — no Pipeworx account, no
125
+ gateway round-trip:
126
+
127
+ ```json
128
+ {
129
+ "mcpServers": {
130
+ "bankruptcy": {
131
+ "command": "npx",
132
+ "args": ["-y", "@pipeworx/mcp-bankruptcy"]
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ Or run it directly to confirm it starts:
139
+
140
+ ```bash
141
+ npx -y @pipeworx/mcp-bankruptcy
142
+ ```
143
+
144
+ It speaks MCP over stdin/stdout and answers `initialize`/`tools/list`/`tools/call`
145
+ for **only** this pack's tools — none of the shared meta-tools the gateway
146
+ connection above adds. Same source, same tools, no ask_pipeworx routing.
147
+
148
+ ## Using with ask_pipeworx
149
+
150
+ Instead of calling tools directly, you can ask questions in plain English —
151
+ this works on the pack endpoint above as well as on the full gateway:
152
+
153
+ ```
154
+ ask_pipeworx({ question: "your question about Bankruptcy data" })
155
+ ```
156
+
157
+ The gateway picks the right tool and fills the arguments automatically.
158
+
159
+ ## More
160
+
161
+ - [Docs and guides](https://pipeworx.io/docs)
162
+ - [pipeworx.io](https://pipeworx.io)
163
+
164
+ ## License
165
+
166
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Entry point for `npx @pipeworx/mcp-<slug>`.
4
+ //
5
+ // Packs ship as raw TypeScript (no build step — see publish-pack.sh for why:
6
+ // tsx sidesteps every extensionless-import / bare-JSON-import edge case a
7
+ // per-pack tsc build would have to solve one pack at a time). This file
8
+ // registers tsx's ESM loader programmatically, then hands off to src/server.ts,
9
+ // which wraps the pack's {tools, callTool} export in a stdio MCP server.
10
+ //
11
+ // Copied verbatim into every published pack repo by scripts/publish-pack.sh —
12
+ // edit this file, not a per-pack copy.
13
+ import { register } from 'tsx/esm/api';
14
+
15
+ register();
16
+
17
+ await import('../src/server.ts');
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@pipeworx/mcp-bankruptcy",
3
+ "version": "0.1.0",
4
+ "description": "Business bankruptcy filings — US federal bankruptcy courts.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "bin": {
9
+ "mcp-bankruptcy": "bin/cli.js"
10
+ },
11
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "bankruptcy"],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/pipeworx-io/mcp-bankruptcy.git"
16
+ },
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.30.0",
22
+ "tsx": "^4.19.0"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "^5.9.3",
26
+ "@cloudflare/workers-types": "^4.20260405.1"
27
+ }
28
+ }
package/server.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.pipeworx-io/bankruptcy",
4
+ "title": "Bankruptcy",
5
+ "description": "Business bankruptcy filings — US federal bankruptcy courts.",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/bankruptcy",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-bankruptcy",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/bankruptcy/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,373 @@
1
+ interface McpToolDefinition {
2
+ name: string;
3
+ description: string;
4
+ /** Human-facing one-liner (fleet #1967). Optional; consumers fall back to
5
+ * description. Kept in step with shared/src/types.ts — scripts/lib/
6
+ * check-inlined-types.mjs reports drift at publish time. */
7
+ summary?: string;
8
+ inputSchema: {
9
+ type: 'object';
10
+ properties: Record<string, unknown>;
11
+ required?: string[];
12
+ anyOf?: Array<{ required: string[] }>;
13
+ oneOf?: Array<{ required: string[] }>;
14
+ allOf?: Array<{ required: string[] }>;
15
+ };
16
+ outputSchema?: Record<string, unknown>;
17
+ }
18
+
19
+ interface McpToolExport {
20
+ tools: McpToolDefinition[];
21
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
22
+ meter?: { credits: number };
23
+ cost?: Record<string, unknown>;
24
+ provider?: string;
25
+ }
26
+
27
+ /**
28
+ * Shared query layer for CourtListener docket data.
29
+ *
30
+ * Four packs — bankruptcy, federal-criminal, federal-civil, federal-appellate —
31
+ * read the SAME table with different filters. They are separate packs because
32
+ * routing is embedding cosine over tool descriptions, and four descriptions
33
+ * tuned to four questions match far better than one generic "search dockets".
34
+ * They share one table because splitting the storage would quadruple the
35
+ * operational surface and the bill to buy nothing.
36
+ *
37
+ * This lives in shared rather than being copied four times, so a fix to the
38
+ * query shape lands once. publish-pack.sh inlines it when a pack ships
39
+ * standalone.
40
+ *
41
+ * Data: 36.4M dockets, consumer bankruptcy excluded. A docket records that a
42
+ * case EXISTS — parties, court, dates, judge. It contains none of the filings;
43
+ * those are RECAP, which is a separate and non-keyless corpus. Any tool built
44
+ * on this must not imply otherwise.
45
+ */
46
+
47
+ interface DocketMirror {
48
+ url: string;
49
+ key: string;
50
+ }
51
+
52
+ interface DocketRow {
53
+ id: number;
54
+ case_name: string | null;
55
+ case_name_full: string | null;
56
+ docket_number: string | null;
57
+ court_id: string;
58
+ court_class: string;
59
+ is_criminal: boolean;
60
+ is_business_bankruptcy: boolean;
61
+ date_filed: string | null;
62
+ date_terminated: string | null;
63
+ date_last_filing: string | null;
64
+ cause: string | null;
65
+ nature_of_suit: string | null;
66
+ jurisdiction_type: string | null;
67
+ assigned_to_str: string | null;
68
+ pacer_case_id: string | null;
69
+ snapshot_date: string;
70
+ }
71
+
72
+ function docketMirror(args: Record<string, unknown>): DocketMirror | null {
73
+ const url = (args._supabaseUrl as string | undefined)?.trim();
74
+ const key = (args._supabaseKey as string | undefined)?.trim();
75
+ return url && key ? { url, key } : null;
76
+ }
77
+
78
+ function docketUnavailable(tool: string) {
79
+ return {
80
+ found: false,
81
+ reason: 'mirror_unavailable',
82
+ message: `${tool} is not reachable from this deployment.`,
83
+ hint: 'Case law (opinions, full text) is served by the court-listener pack and is unaffected.',
84
+ };
85
+ }
86
+
87
+ /** Present a docket without implying we hold the filings. */
88
+ function shapeDocket(d: DocketRow) {
89
+ return {
90
+ docket_id: d.id,
91
+ case_name: d.case_name,
92
+ case_name_full: d.case_name_full,
93
+ docket_number: d.docket_number,
94
+ court: d.court_id,
95
+ court_class: d.court_class,
96
+ date_filed: d.date_filed,
97
+ date_terminated: d.date_terminated,
98
+ // Absence is not closure: date_terminated is 78.3% filled, so a null means
99
+ // "not recorded", never "still open". Stating that on the row keeps a
100
+ // caller from reading an open case out of a missing field.
101
+ status: d.date_terminated ? 'terminated' : 'no termination date recorded',
102
+ date_last_filing: d.date_last_filing,
103
+ cause: d.cause,
104
+ nature_of_suit: d.nature_of_suit,
105
+ assigned_judge: d.assigned_to_str,
106
+ pacer_case_id: d.pacer_case_id,
107
+ courtlistener_url: `https://www.courtlistener.com/docket/${d.id}/`,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Query the docket mirror.
113
+ *
114
+ * Throws on failure rather than returning an empty array. The first version
115
+ * swallowed every error into [], which meant a pack pointed at the WRONG
116
+ * DATABASE reported "no dockets matched" — indistinguishable from a genuine
117
+ * miss, and the caller would have believed it. An empty result must mean the
118
+ * data is not there, never that we could not ask.
119
+ */
120
+ async function query(m: DocketMirror, qs: string): Promise<DocketRow[]> {
121
+ const res = await fetch(`${m.url}/rest/v1/courtlistener_dockets?${qs}`, {
122
+ headers: { apikey: m.key, Authorization: `Bearer ${m.key}` },
123
+ });
124
+ if (!res.ok) {
125
+ const body = (await res.text()).slice(0, 200);
126
+ throw new Error(
127
+ `Docket query failed (HTTP ${res.status}) against ${m.url.replace(/https?:\/\//, '').split('.')[0]}: ${body}`,
128
+ );
129
+ }
130
+ return (await res.json()) as DocketRow[];
131
+ }
132
+
133
+ interface DocketSearch {
134
+ party?: string;
135
+ court?: string;
136
+ /**
137
+ * Filter for real on both paths: parameters to search_dockets_by_name on the
138
+ * party path (since migration 080), PostgREST filters on the browse path.
139
+ * They used to be a client-side trim of the page the RPC had already cut to
140
+ * `limit`, which quietly returned fewer rows than asked for and gave the
141
+ * planner nothing to work with. The tool schemas describe them as filters;
142
+ * keep them honest if this ever changes.
143
+ */
144
+ filedAfter?: string;
145
+ filedBefore?: string;
146
+ limit?: number;
147
+ }
148
+
149
+ /**
150
+ * Search dockets within one court class.
151
+ *
152
+ * Party match is a name substring rather than full-text: docket case names are
153
+ * short and adversarial ("Acme Corp v. Smith"), so a caller searching one party
154
+ * needs to match a fragment, which tsquery does badly and ilike does well.
155
+ */
156
+ async function searchDockets(
157
+ m: DocketMirror,
158
+ filters: { court_class?: string; is_criminal?: boolean; is_business_bankruptcy?: boolean },
159
+ s: DocketSearch,
160
+ ) {
161
+ const limit = Math.min(50, Math.max(1, s.limit ?? 10));
162
+
163
+ const fetchRows = async (filedAfter?: string, filedBefore?: string): Promise<DocketRow[]> => {
164
+ if (s.party) {
165
+ // Name search goes through the RPC, never a PostgREST ilike filter. An
166
+ // ilike scan over 36M rows exceeds the 8s statement timeout — which the
167
+ // packs reported as "no filings found" until this layer was made to throw.
168
+ // The RPC matches the expression the FTS index is built on; PostgREST's own
169
+ // fts operator generates a different one that the planner ignores.
170
+ return rpc(m, {
171
+ q: s.party.replace(/[*,()]/g, ' ').trim(),
172
+ p_court_class: filters.court_class ?? null,
173
+ p_is_criminal: filters.is_criminal ?? null,
174
+ p_business_bk: filters.is_business_bankruptcy ?? null,
175
+ p_court: s.court ?? null,
176
+ // The dates go to the RPC now, and did not before. Trimming them here, on
177
+ // rows the database had ALREADY cut to `limit`, meant a date-bounded
178
+ // search silently returned a subset of what was asked for: ask for 10
179
+ // dockets filed after 2020 and you got however many of the newest 10
180
+ // happened to qualify, with nothing in the response saying the rest had
181
+ // been dropped. It also denied the planner its one selective predicate.
182
+ // See migration 080.
183
+ p_filed_after: filedAfter ?? null,
184
+ p_filed_before: filedBefore ?? null,
185
+ lim: limit,
186
+ });
187
+ } else {
188
+ const parts: string[] = ['select=*'];
189
+ if (filters.court_class) parts.push(`court_class=eq.${filters.court_class}`);
190
+ if (filters.is_criminal !== undefined) parts.push(`is_criminal=is.${filters.is_criminal}`);
191
+ if (filters.is_business_bankruptcy !== undefined) {
192
+ parts.push(`is_business_bankruptcy=is.${filters.is_business_bankruptcy}`);
193
+ }
194
+ if (s.court) parts.push(`court_id=eq.${encodeURIComponent(s.court)}`);
195
+ if (filedAfter) parts.push(`date_filed=gte.${filedAfter}`);
196
+ if (filedBefore) parts.push(`date_filed=lte.${filedBefore}`);
197
+ parts.push('order=date_filed.desc.nullslast');
198
+ parts.push(`limit=${limit}`);
199
+ return query(m, parts.join('&'));
200
+ }
201
+ };
202
+
203
+ let rows: DocketRow[] = await fetchRows(s.filedAfter, s.filedBefore);
204
+ // A date window beyond what this dataset covers returns 0 rows as a clean
205
+ // success — "securities class actions filed after 2026-07-24" answered
206
+ // "none exist" when the truth was "the coverage ends earlier" (fleet #508's
207
+ // verbatim question died here after the routing fix). On a dated empty,
208
+ // retry once WITHOUT the dates: if data exists, return the newest available
209
+ // and say the window outran what is covered — the caller learns the
210
+ // coverage horizon instead of a false negative.
211
+ let dateWindowNote: string | undefined;
212
+ if (rows.length === 0 && (s.filedAfter || s.filedBefore)) {
213
+ const undated = await fetchRows(undefined, undefined);
214
+ if (undated.length > 0) {
215
+ rows = undated;
216
+ // WORDING IS LOAD-BEARING, same rule as labelAge's note in authority.ts:
217
+ // no "the mirror's" / "the mirror holds" — that is a claim about who
218
+ // stores what, not about what the data says. check:hosting-claims did
219
+ // not catch this before shared/ was wired into its string pass (task
220
+ // #2009); "pipeworx mirror of CourtListener bulk data" a few lines
221
+ // below was the same defect on the `source` field of every response on
222
+ // this rail, across all five packs that call searchDockets().
223
+ dateWindowNote =
224
+ `No dockets matched within the requested date window (${s.filedAfter ?? ''}..${s.filedBefore ?? ''}) — the newest matching docket on file is ${undated[0]?.date_filed ?? 'unknown'}, so the window likely outran what is covered. Returning the newest matching dockets available; treat absence WITHIN the window as UNKNOWN, not as zero filings.`;
225
+ }
226
+ }
227
+
228
+ return {
229
+ returned: rows.length,
230
+ dockets: rows.map(shapeDocket),
231
+ ...(dateWindowNote ? { date_window_note: dateWindowNote } : {}),
232
+ snapshot_date: rows[0]?.snapshot_date ?? null,
233
+ source: 'CourtListener bulk docket data',
234
+ attribution: 'CourtListener / Free Law Project — Public Domain Mark.',
235
+ // Said on every response, not just empty ones: a docket is the existence of
236
+ // a case, and an agent that assumes otherwise will describe filings it has
237
+ // never seen.
238
+ scope_note:
239
+ 'Dockets record that a case exists — parties, court, dates, judge. They do NOT contain complaints, motions, orders or any filed document.',
240
+ ...(rows.length === 0
241
+ ? {
242
+ found: false,
243
+ hint: 'No docket matched. Names are matched by word, so try one distinctive party alone ("Theranos" rather than a full caption). Consumer bankruptcy filings are deliberately excluded from this dataset.',
244
+ }
245
+ : {}),
246
+ };
247
+ }
248
+
249
+ /** Word-matching name search against the FTS index. See searchDockets. */
250
+ async function rpc(m: DocketMirror, body: Record<string, unknown>): Promise<DocketRow[]> {
251
+ for (let attempt = 0; ; attempt++) {
252
+ const res = await fetch(`${m.url}/rest/v1/rpc/search_dockets_by_name`, {
253
+ method: 'POST',
254
+ headers: {
255
+ apikey: m.key,
256
+ Authorization: `Bearer ${m.key}`,
257
+ 'Content-Type': 'application/json',
258
+ },
259
+ body: JSON.stringify(body),
260
+ });
261
+ if (res.ok) return (await res.json()) as DocketRow[];
262
+
263
+ const detail = (await res.text()).slice(0, 200);
264
+ const timedOut = detail.includes('57014');
265
+ // 57014 (statement_timeout) was NOT transient contention, whatever this
266
+ // comment used to say — it was the query plan, fixed in migration 080. What
267
+ // survives it is a genuinely cold first touch: a name matching tens of
268
+ // thousands of dockets has to read that many heap pages, and the first read
269
+ // of them can still run long while the same call moments later is warm. So
270
+ // the retry stays, and the message no longer tells the caller their date
271
+ // range is powerless — since 080 it is a real filter and does narrow the
272
+ // scan.
273
+ if (timedOut && attempt === 0) continue;
274
+ if (timedOut) {
275
+ throw new Error(
276
+ 'Docket name search timed out twice (Postgres 57014). The name matches enough dockets that reading them cold exceeds the statement timeout; the identical call usually succeeds moments later, so retry it as-is. Narrowing filed_after/filed_before, or giving one distinctive party word rather than a common one, reduces the work and makes it less likely.',
277
+ );
278
+ }
279
+ throw new Error(`Docket name search failed (HTTP ${res.status}): ${detail}`);
280
+ }
281
+ }
282
+ /**
283
+ * Business bankruptcy filings — US federal bankruptcy courts.
284
+ *
285
+ * Covers US federal court dockets (36.4M).
286
+ * Consumer bankruptcy is deliberately EXCLUDED: of 71.7M dockets,
287
+ * 35.3M are individual filings — half the corpus, no business signal, and
288
+ * PII-adjacent. What is left is 1.55M business filings.
289
+ *
290
+ * Because the docket export has no chapter column, business vs consumer is
291
+ * decided by a legal-entity-suffix test on the debtor name. A sole
292
+ * proprietorship filing under a personal name is therefore absent, and the
293
+ * tool descriptions say so rather than implying complete coverage of
294
+ * commercial insolvency.
295
+ */
296
+
297
+
298
+ const tools: McpToolExport['tools'] = [
299
+ {
300
+ name: 'bankruptcy_search',
301
+ description:
302
+ 'Find US BUSINESS BANKRUPTCY filings by company name. Searches 1.55 million business filings across federal bankruptcy courts — no API key. Returns the case name, docket number, court, filing date, termination date and assigned judge. Use for "has this company filed for bankruptcy", corporate distress checks, counterparty risk and due diligence. Individual consumer bankruptcies are deliberately excluded.',
303
+ inputSchema: {
304
+ type: 'object',
305
+ properties: {
306
+ company: { type: 'string', description: 'Company name or a distinctive part of it, e.g. "Kmart".' },
307
+ court: { type: 'string', description: 'Optional court id, e.g. "cacb" (C.D. Cal. Bankruptcy).' },
308
+ filed_after: { type: 'string', description: 'Only filings on or after this date, YYYY-MM-DD.' },
309
+ filed_before: { type: 'string', description: 'Only filings on or before this date, YYYY-MM-DD.' },
310
+ limit: { type: 'number', description: 'Maximum filings to return (1-50, default 10).' },
311
+ },
312
+ required: ['company'],
313
+ },
314
+ },
315
+ {
316
+ name: 'bankruptcy_recent',
317
+ description:
318
+ 'List recent US business bankruptcy filings, newest first, optionally limited to one bankruptcy court. No API key, no rate limit. Use for "what companies filed for bankruptcy recently" or to watch one district. Returns case name, docket number, court, filing and termination dates.',
319
+ inputSchema: {
320
+ type: 'object',
321
+ properties: {
322
+ court: { type: 'string', description: 'Optional bankruptcy court id, e.g. "nysb", "deb", "txsb".' },
323
+ filed_after: { type: 'string', description: 'Only filings on or after this date, YYYY-MM-DD.' },
324
+ limit: { type: 'number', description: 'Maximum filings to return (1-50, default 20).' },
325
+ },
326
+ },
327
+ },
328
+ ];
329
+
330
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
331
+ const m = docketMirror(args);
332
+ if (!m) return docketUnavailable(name);
333
+
334
+ switch (name) {
335
+ case 'bankruptcy_search':
336
+ return {
337
+ company: args.company,
338
+ ...(await searchDockets(
339
+ m,
340
+ { court_class: 'federal_bankruptcy', is_business_bankruptcy: true },
341
+ {
342
+ party: String(args.company ?? ''),
343
+ court: args.court as string | undefined,
344
+ filedAfter: args.filed_after as string | undefined,
345
+ filedBefore: args.filed_before as string | undefined,
346
+ limit: Number(args.limit) || 10,
347
+ },
348
+ )),
349
+ coverage_note:
350
+ 'Business filings only. Consumer bankruptcy is excluded, and a sole proprietorship filing under a personal name will not appear here.',
351
+ };
352
+
353
+ case 'bankruptcy_recent':
354
+ return {
355
+ ...(await searchDockets(
356
+ m,
357
+ { court_class: 'federal_bankruptcy', is_business_bankruptcy: true },
358
+ {
359
+ court: args.court as string | undefined,
360
+ filedAfter: args.filed_after as string | undefined,
361
+ limit: Number(args.limit) || 20,
362
+ },
363
+ )),
364
+ coverage_note:
365
+ 'Business filings only, and coverage carries an as-of date — anything filed after snapshot_date is not here yet.',
366
+ };
367
+
368
+ default:
369
+ throw new Error(`Unknown tool: ${name}`);
370
+ }
371
+ }
372
+
373
+ export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
package/src/server.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Stdio MCP server entry point for @pipeworx/mcp-bankruptcy.
3
+ * Generated by scripts/publish-pack.sh — do not hand-edit in the pack repo;
4
+ * edit scripts/publish-pack.sh (the server.ts heredoc) and republish instead.
5
+ */
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
+ import pack from './index.js';
10
+
11
+ const server = new Server(
12
+ { name: '@pipeworx/mcp-bankruptcy', version: '0.1.0' },
13
+ { capabilities: { tools: {} } },
14
+ );
15
+
16
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
17
+ tools: pack.tools.map((t) => ({
18
+ name: t.name,
19
+ description: t.description,
20
+ inputSchema: t.inputSchema,
21
+ })),
22
+ }));
23
+
24
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
25
+ const { name, arguments: args } = request.params;
26
+ try {
27
+ const result = await pack.callTool(name, (args ?? {}) as Record<string, unknown>);
28
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
29
+ } catch (err) {
30
+ return {
31
+ content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
32
+ isError: true,
33
+ };
34
+ }
35
+ });
36
+
37
+ async function main() {
38
+ const transport = new StdioServerTransport();
39
+ await server.connect(transport);
40
+ }
41
+
42
+ main().catch((err) => {
43
+ console.error('Fatal error running server:', err);
44
+ process.exit(1);
45
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "types": ["@cloudflare/workers-types"],
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "outDir": "dist",
13
+ "rootDir": "src",
14
+ "declaration": true
15
+ },
16
+ "include": ["src"],
17
+ "exclude": ["src/server.ts"]
18
+ }