@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.
- package/.pipeworx-staged-ok +6 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/bin/cli.js +17 -0
- package/package.json +28 -0
- package/server.json +18 -0
- package/src/index.ts +373 -0
- package/src/server.ts +45 -0
- package/tsconfig.json +18 -0
|
@@ -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
|
+
}
|