webhook-crash-lab-mcp 0.0.0-stage → 0.2.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/.agents/skills/webhook-crash-lab/SKILL.md +40 -0
- package/.claude-plugin/marketplace.json +17 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.mcp.json +8 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +73 -2
- package/dist/bin.js +11 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-UR6UXUAN.js +256 -0
- package/dist/chunk-UR6UXUAN.js.map +1 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/docs/AGENTS-snippet.md +10 -0
- package/package.json +79 -4
- package/skills/webhook-crash-lab/SKILL.md +40 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webhook-crash-lab
|
|
3
|
+
description: Test and fix webhook handlers (Stripe, GitHub) so they survive duplicate, concurrent and out-of-order deliveries and reject forged, missing or stale signatures. Use when writing, reviewing or debugging a webhook endpoint, after generating webhook code, or when a user reports double credits, double emails, "No signatures found matching the expected signature", or webhooks failing in production.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# webhook-crash-lab
|
|
7
|
+
|
|
8
|
+
Webhook providers deliver events **at least once**: retries, duplicates, parallel copies, wrong order.
|
|
9
|
+
webhook-crash-lab replays those deliveries against the real handler and checks both HTTP status and
|
|
10
|
+
business state (for example "credits granted exactly once").
|
|
11
|
+
|
|
12
|
+
## Loop
|
|
13
|
+
|
|
14
|
+
1. **Find the handler** (`/webhook` route, `app/api/webhooks/*/route.ts`, `constructEvent` call).
|
|
15
|
+
2. **Config.** If no `crash-lab.config.{mjs,js,ts}` exists, run `npx webhook-crash-lab init` and fill it in:
|
|
16
|
+
- `target`: the local dev URL of the webhook route, or an in-process `(req: Request) => Response` handler.
|
|
17
|
+
- `secret`: a **test** signing secret the handler verifies with (set it in the handler's env too).
|
|
18
|
+
- `event`: a realistic event (for example `checkout.session.completed`).
|
|
19
|
+
- `adapter` (`reset` / `snapshot`) + `expect.once`: needed for the duplicate scenarios. Point it at a
|
|
20
|
+
**test database only**, because `reset()` wipes state before every scenario.
|
|
21
|
+
- `sequence`: real event order, for the out-of-order scenario.
|
|
22
|
+
3. **Run.** Call the `run_crash_lab` MCP tool with `{ configPath, cwd }`. Without the MCP server, use
|
|
23
|
+
`npx webhook-crash-lab run crash-lab.config.mjs --json crash-lab-report.json`, then
|
|
24
|
+
`explain_report` or read the JSON. Exit codes: 0 pass, 1 failures, 2 config error.
|
|
25
|
+
4. **Fix the handler**, one failure at a time, in the order returned (signature → body → duplicates → ordering).
|
|
26
|
+
Use each failure's `fix` hint. Typical fixes:
|
|
27
|
+
- raw body for signature checks (`express.raw()`, `await req.text()`), never `express.json()` first;
|
|
28
|
+
- a unique insert on `event.id` (processed-events table) **before** side effects, in the same transaction;
|
|
29
|
+
- BullMQ `queue.add(name, data, { jobId: event.id })`;
|
|
30
|
+
- ignore events older than the latest one already applied for that object.
|
|
31
|
+
5. **Re-run** the same tool with the same arguments. Repeat until `ok` is `true`. Do not weaken the config
|
|
32
|
+
or remove scenarios to make it pass.
|
|
33
|
+
6. Suggest adding `npx webhook-crash-lab run` to CI, and the ESLint plugins
|
|
34
|
+
`eslint-plugin-webhook-safety` / `eslint-plugin-bullmq` for the static versions of these checks.
|
|
35
|
+
|
|
36
|
+
## Rules
|
|
37
|
+
|
|
38
|
+
- Never point crash-lab at production URLs, production databases or live secrets.
|
|
39
|
+
- `not-tested` means the config lacks what that scenario needs (adapter / expect.once / sequence). Say so; do not report it as passing.
|
|
40
|
+
- `run_demo` (no config needed) shows a broken vs fixed app if the user wants to see what is checked.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webhook-crash-lab",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "Krrish Kumar",
|
|
5
|
+
"url": "https://github.com/Krrishkumar125"
|
|
6
|
+
},
|
|
7
|
+
"metadata": {
|
|
8
|
+
"description": "Webhook reliability tools for coding agents: run webhook-crash-lab, fix, re-run."
|
|
9
|
+
},
|
|
10
|
+
"plugins": [
|
|
11
|
+
{
|
|
12
|
+
"name": "webhook-crash-lab",
|
|
13
|
+
"source": "./",
|
|
14
|
+
"description": "Test webhook handlers against duplicate, concurrent, out-of-order and forged deliveries, then fix and re-run until they pass."
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webhook-crash-lab",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Test webhook handlers against duplicate, concurrent, out-of-order and forged deliveries, then fix and re-run until they pass.",
|
|
5
|
+
"author": { "name": "Krrish Kumar", "url": "https://github.com/Krrishkumar125" },
|
|
6
|
+
"homepage": "https://github.com/Krrishkumar125/webhook-crash-lab",
|
|
7
|
+
"repository": "https://github.com/Krrishkumar125/webhook-crash-lab-mcp",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"keywords": ["webhooks", "stripe", "idempotency", "testing", "bullmq"]
|
|
10
|
+
}
|
package/.mcp.json
ADDED
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (2026-10-22)
|
|
4
|
+
|
|
5
|
+
- `run_crash_lab` accepts TypeScript configs (`.ts`/`.mts`/`.cts`) by delegating config loading to
|
|
6
|
+
webhook-crash-lab 0.3.0; jiti is bundled so this works on Node 20 too.
|
|
7
|
+
|
|
8
|
+
## 0.1.0 (2026-10-22)
|
|
9
|
+
|
|
10
|
+
- MCP server with `run_crash_lab`, `run_demo`, `explain_report`, `list_scenarios`.
|
|
11
|
+
- Claude Code plugin (manifest, marketplace, skill, `.mcp.json`) and Codex skill + AGENTS.md snippet.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Krrish Kumar
|
|
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
CHANGED
|
@@ -1,3 +1,74 @@
|
|
|
1
|
-
#
|
|
1
|
+
# webhook-crash-lab-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/Krrishkumar125/webhook-crash-lab-mcp/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Coding agents write webhook handlers that work for one test event and break in production on
|
|
6
|
+
retries, duplicates and forged signatures. This package gives them a loop: **run
|
|
7
|
+
[webhook-crash-lab](https://github.com/Krrishkumar125/webhook-crash-lab) → read structured failures with fix hints → fix → re-run**.
|
|
8
|
+
|
|
9
|
+
It ships three things:
|
|
10
|
+
|
|
11
|
+
| | What | Where |
|
|
12
|
+
| ----------------------- | --------------------------------------------------------------------- | ------------------------------------------- |
|
|
13
|
+
| MCP server | `webhook-crash-lab-mcp` (stdio, official MCP TypeScript SDK) | `src/` |
|
|
14
|
+
| Claude Code plugin | skill + MCP server config | `.claude-plugin/`, `skills/`, `.mcp.json` |
|
|
15
|
+
| Codex / any-agent skill | same skill in the open Agent Skills format, plus an AGENTS.md snippet | `.agents/skills/`, `docs/AGENTS-snippet.md` |
|
|
16
|
+
|
|
17
|
+
## Tools
|
|
18
|
+
|
|
19
|
+
| Tool | Input | Returns |
|
|
20
|
+
| ---------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
21
|
+
| `run_crash_lab` | `configPath`, `cwd?`, `scenarios?`, `reportPath?` | `ok`, counts, `failures[]` (id, failed checks, why, fix) in fix order, `notTested[]`, `nextSteps[]` |
|
|
22
|
+
| `run_demo` | `mode: "broken" \| "fixed"` | same shape, against the built-in demo app (no config needed) |
|
|
23
|
+
| `explain_report` | `reportPath` or `report` (JSON from `webhook-crash-lab run --json`) | same shape, e.g. for a CI report |
|
|
24
|
+
| `list_scenarios` | – | every scenario with what / why / fix |
|
|
25
|
+
|
|
26
|
+
Every result comes back as text for the model and `structuredContent` (with an `outputSchema`) for clients.
|
|
27
|
+
Errors such as a missing or invalid config come back as `isError` results, not crashes.
|
|
28
|
+
|
|
29
|
+
## 60-second start
|
|
30
|
+
|
|
31
|
+
**Claude Code**
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
claude mcp add webhook-crash-lab -- npx -y webhook-crash-lab-mcp
|
|
35
|
+
# or the full plugin (MCP server + skill):
|
|
36
|
+
# /plugin marketplace add Krrishkumar125/webhook-crash-lab-mcp
|
|
37
|
+
# /plugin install webhook-crash-lab@webhook-crash-lab
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Codex**
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
codex mcp add webhook-crash-lab -- npx -y webhook-crash-lab-mcp
|
|
44
|
+
mkdir -p .agents/skills && cp -r node_modules/webhook-crash-lab-mcp/.agents/skills/webhook-crash-lab .agents/skills/
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Cursor / other MCP clients**
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"mcpServers": {
|
|
52
|
+
"webhook-crash-lab": { "command": "npx", "args": ["-y", "webhook-crash-lab-mcp"] }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Then ask: _"Run the webhook crash lab demo"_, or _"Make my Stripe webhook pass webhook-crash-lab"_.
|
|
58
|
+
Paste [`docs/AGENTS-snippet.md`](docs/AGENTS-snippet.md) into `AGENTS.md` / `CLAUDE.md` to make it a standing rule.
|
|
59
|
+
|
|
60
|
+
## Safety
|
|
61
|
+
|
|
62
|
+
`run_crash_lab` imports your crash-lab config (`.mjs`/`.js`/`.cjs`, or `.ts`/`.mts`/`.cts` via the bundled jiti) (it is code, like any config file) and sends requests to
|
|
63
|
+
its `target`. Point configs at a local server and a **test** database and secret only. The skill tells
|
|
64
|
+
agents never to use production URLs or live secrets.
|
|
65
|
+
|
|
66
|
+
## Related
|
|
67
|
+
|
|
68
|
+
- [webhook-crash-lab](https://github.com/Krrishkumar125/webhook-crash-lab): the CLI/library this wraps
|
|
69
|
+
- [eslint-plugin-webhook-safety](https://github.com/Krrishkumar125/eslint-plugin-webhook-safety) and
|
|
70
|
+
[eslint-plugin-bullmq](https://github.com/Krrishkumar125/eslint-plugin-bullmq): the same bugs, caught statically
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT © Krrish Kumar
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
createServer
|
|
4
|
+
} from "./chunk-UR6UXUAN.js";
|
|
5
|
+
|
|
6
|
+
// src/bin.ts
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
var server = createServer();
|
|
9
|
+
await server.connect(new StdioServerTransport());
|
|
10
|
+
console.error("webhook-crash-lab MCP server running on stdio");
|
|
11
|
+
//# sourceMappingURL=bin.js.map
|
package/dist/bin.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/bin.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';\nimport { createServer } from './server.js';\n\nconst server = createServer();\nawait server.connect(new StdioServerTransport());\n// stdout is the protocol channel; log only to stderr.\nconsole.error('webhook-crash-lab MCP server running on stdio');\n"],"mappings":";;;;;;AACA,SAAS,4BAA4B;AAGrC,IAAM,SAAS,aAAa;AAC5B,MAAM,OAAO,QAAQ,IAAI,qBAAqB,CAAC;AAE/C,QAAQ,MAAM,+CAA+C;","names":[]}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// src/core.ts
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { dirname, resolve } from "path";
|
|
4
|
+
import {
|
|
5
|
+
demoConfig,
|
|
6
|
+
loadConfig as loadLabConfig,
|
|
7
|
+
runCrashLab,
|
|
8
|
+
SCENARIOS,
|
|
9
|
+
toJson
|
|
10
|
+
} from "webhook-crash-lab";
|
|
11
|
+
var PRIORITY = [
|
|
12
|
+
"missing-signature",
|
|
13
|
+
"invalid-signature",
|
|
14
|
+
"stale-timestamp",
|
|
15
|
+
"malformed-json",
|
|
16
|
+
"single-valid",
|
|
17
|
+
"duplicate-sequential",
|
|
18
|
+
"duplicate-concurrent",
|
|
19
|
+
"out-of-order"
|
|
20
|
+
];
|
|
21
|
+
var SCENARIO_IDS = SCENARIOS.map((s) => s.id);
|
|
22
|
+
function summarize(result, reportPath) {
|
|
23
|
+
const rank = (id) => PRIORITY.indexOf(id);
|
|
24
|
+
const failures = result.scenarios.filter((s) => s.status === "fail").sort((a, b) => rank(a.id) - rank(b.id)).map((s) => ({
|
|
25
|
+
id: s.id,
|
|
26
|
+
title: s.title,
|
|
27
|
+
why: s.why,
|
|
28
|
+
fix: s.fix,
|
|
29
|
+
failedChecks: s.checks.filter((c) => c.status === "fail").map(({ name, detail }) => ({ name, detail })),
|
|
30
|
+
responses: s.responses
|
|
31
|
+
}));
|
|
32
|
+
const notTested = result.scenarios.filter((s) => s.status === "not-tested").map((s) => s.id);
|
|
33
|
+
const nextSteps = [];
|
|
34
|
+
if (failures.length) {
|
|
35
|
+
nextSteps.push(
|
|
36
|
+
`Fix in this order: ${failures.map((f) => f.id).join(" \u2192 ")}. Each failure has a "fix" hint.`,
|
|
37
|
+
"Change the webhook handler (not the test config), then re-run the same tool with the same arguments. Stop when ok is true.",
|
|
38
|
+
"Static checks for the same bugs: eslint-plugin-webhook-safety and eslint-plugin-bullmq."
|
|
39
|
+
);
|
|
40
|
+
} else {
|
|
41
|
+
nextSteps.push(
|
|
42
|
+
"All scenarios pass. Keep `npx webhook-crash-lab run` in CI so this stays true."
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (notTested.length) {
|
|
46
|
+
nextSteps.push(
|
|
47
|
+
`${notTested.length} scenario(s) not tested (${notTested.join(", ")}): duplicate checks need an \`adapter\` + \`expect.once\`; out-of-order needs \`sequence\` in the config.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
name: result.name,
|
|
52
|
+
ok: result.ok,
|
|
53
|
+
summary: result.summary,
|
|
54
|
+
failures,
|
|
55
|
+
notTested,
|
|
56
|
+
scenarios: result.scenarios.map((s) => ({ id: s.id, status: s.status })),
|
|
57
|
+
nextSteps,
|
|
58
|
+
...reportPath ? { reportPath } : {}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function writeReport(result, reportPath) {
|
|
62
|
+
mkdirSync(dirname(reportPath), { recursive: true });
|
|
63
|
+
writeFileSync(reportPath, toJson(result));
|
|
64
|
+
}
|
|
65
|
+
async function loadConfig(configPath, cwd = process.cwd()) {
|
|
66
|
+
return loadLabConfig(configPath, cwd);
|
|
67
|
+
}
|
|
68
|
+
async function runFromConfig(opts) {
|
|
69
|
+
for (const id of opts.scenarios ?? []) {
|
|
70
|
+
if (!SCENARIO_IDS.includes(id)) {
|
|
71
|
+
throw new Error(`Unknown scenario "${id}". Known: ${SCENARIO_IDS.join(", ")}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const config = await loadConfig(opts.configPath, opts.cwd);
|
|
75
|
+
const result = await runCrashLab(
|
|
76
|
+
opts.scenarios?.length ? { ...config, scenarios: opts.scenarios } : config
|
|
77
|
+
);
|
|
78
|
+
const reportPath = opts.reportPath && resolve(opts.cwd ?? process.cwd(), opts.reportPath);
|
|
79
|
+
if (reportPath) writeReport(result, reportPath);
|
|
80
|
+
return summarize(result, reportPath);
|
|
81
|
+
}
|
|
82
|
+
async function runDemo(mode) {
|
|
83
|
+
return summarize(await runCrashLab(demoConfig(mode)));
|
|
84
|
+
}
|
|
85
|
+
function isReport(x) {
|
|
86
|
+
const r = x;
|
|
87
|
+
return !!r && typeof r === "object" && typeof r.ok === "boolean" && Array.isArray(r.scenarios) && !!r.summary;
|
|
88
|
+
}
|
|
89
|
+
function explainReport(input) {
|
|
90
|
+
let report = input.report;
|
|
91
|
+
if (input.reportPath) {
|
|
92
|
+
const path = resolve(input.cwd ?? process.cwd(), input.reportPath);
|
|
93
|
+
if (!existsSync(path)) throw new Error(`Report not found: ${path}`);
|
|
94
|
+
report = readFileSync(path, "utf8");
|
|
95
|
+
}
|
|
96
|
+
if (report === void 0)
|
|
97
|
+
throw new Error("Pass reportPath or report (the JSON from `webhook-crash-lab run --json`).");
|
|
98
|
+
if (typeof report === "string") {
|
|
99
|
+
try {
|
|
100
|
+
report = JSON.parse(report);
|
|
101
|
+
} catch {
|
|
102
|
+
throw new Error("Report is not valid JSON.");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!isReport(report))
|
|
106
|
+
throw new Error(
|
|
107
|
+
"This is not a webhook-crash-lab JSON report (expected ok, summary, scenarios)."
|
|
108
|
+
);
|
|
109
|
+
return summarize(report, input.reportPath);
|
|
110
|
+
}
|
|
111
|
+
function listScenarios() {
|
|
112
|
+
return SCENARIOS.map(({ id, title, what, why, fix }) => ({ id, title, what, why, fix }));
|
|
113
|
+
}
|
|
114
|
+
function renderSummary(s) {
|
|
115
|
+
const lines = [
|
|
116
|
+
`${s.ok ? "PASS" : "FAIL"} ${s.name}: ${s.summary.pass} pass, ${s.summary.fail} fail, ${s.summary.notTested} not tested`
|
|
117
|
+
];
|
|
118
|
+
for (const f of s.failures) {
|
|
119
|
+
lines.push("", `\u2717 ${f.id}: ${f.title}`);
|
|
120
|
+
for (const c of f.failedChecks) lines.push(` ${c.name}: ${c.detail}`);
|
|
121
|
+
lines.push(` why: ${f.why}`, ` fix: ${f.fix}`);
|
|
122
|
+
}
|
|
123
|
+
lines.push("", "Next steps:", ...s.nextSteps.map((n) => `- ${n}`));
|
|
124
|
+
if (s.reportPath) lines.push(`Report written to ${s.reportPath}`);
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/server.ts
|
|
129
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
130
|
+
import { z } from "zod";
|
|
131
|
+
var VERSION = "0.2.0";
|
|
132
|
+
var summaryShape = {
|
|
133
|
+
name: z.string(),
|
|
134
|
+
ok: z.boolean(),
|
|
135
|
+
summary: z.object({ pass: z.number(), fail: z.number(), notTested: z.number() }),
|
|
136
|
+
failures: z.array(
|
|
137
|
+
z.object({
|
|
138
|
+
id: z.string(),
|
|
139
|
+
title: z.string(),
|
|
140
|
+
why: z.string(),
|
|
141
|
+
fix: z.string(),
|
|
142
|
+
failedChecks: z.array(z.object({ name: z.string(), detail: z.string() })),
|
|
143
|
+
responses: z.array(z.number())
|
|
144
|
+
})
|
|
145
|
+
),
|
|
146
|
+
notTested: z.array(z.string()),
|
|
147
|
+
scenarios: z.array(z.object({ id: z.string(), status: z.string() })),
|
|
148
|
+
nextSteps: z.array(z.string()),
|
|
149
|
+
reportPath: z.string().optional()
|
|
150
|
+
};
|
|
151
|
+
async function respond(work) {
|
|
152
|
+
try {
|
|
153
|
+
const s = await work();
|
|
154
|
+
return {
|
|
155
|
+
content: [{ type: "text", text: renderSummary(s) }],
|
|
156
|
+
structuredContent: s
|
|
157
|
+
};
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return { isError: true, content: [{ type: "text", text: err.message }] };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function createServer() {
|
|
163
|
+
const server = new McpServer({ name: "webhook-crash-lab", version: VERSION });
|
|
164
|
+
server.registerTool(
|
|
165
|
+
"run_crash_lab",
|
|
166
|
+
{
|
|
167
|
+
title: "Run webhook-crash-lab",
|
|
168
|
+
description: "Replay hostile webhook deliveries (duplicates, concurrent duplicates, out-of-order events, forged/missing/stale signatures, malformed JSON) against the handler described by a crash-lab config file, and return structured failures with fix hints. Use a TEST database and test secret only. Create a config with `npx webhook-crash-lab init` if none exists.",
|
|
169
|
+
inputSchema: {
|
|
170
|
+
configPath: z.string().describe(
|
|
171
|
+
"Path to a crash-lab config (.mjs, .js, .cjs, .ts, .mts or .cts), relative to cwd"
|
|
172
|
+
),
|
|
173
|
+
cwd: z.string().optional().describe("Project directory; defaults to the server working directory"),
|
|
174
|
+
scenarios: z.array(z.enum(SCENARIO_IDS)).optional().describe("Run only these scenarios"),
|
|
175
|
+
reportPath: z.string().optional().describe("Also write the full JSON report here")
|
|
176
|
+
},
|
|
177
|
+
outputSchema: summaryShape,
|
|
178
|
+
annotations: { readOnlyHint: false, openWorldHint: true }
|
|
179
|
+
},
|
|
180
|
+
(args) => respond(() => runFromConfig(args))
|
|
181
|
+
);
|
|
182
|
+
server.registerTool(
|
|
183
|
+
"run_demo",
|
|
184
|
+
{
|
|
185
|
+
title: "Run the built-in demo",
|
|
186
|
+
description: 'Run crash-lab against its built-in demo credits app. "broken" shows the typical AI-generated Stripe handler failing; "fixed" shows the same app done right. Needs no config. Use it to show what crash-lab checks before setting up a real config.',
|
|
187
|
+
inputSchema: { mode: z.enum(["broken", "fixed"]).default("broken") },
|
|
188
|
+
outputSchema: summaryShape,
|
|
189
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
190
|
+
},
|
|
191
|
+
({ mode }) => respond(() => runDemo(mode))
|
|
192
|
+
);
|
|
193
|
+
server.registerTool(
|
|
194
|
+
"explain_report",
|
|
195
|
+
{
|
|
196
|
+
title: "Explain a crash-lab report",
|
|
197
|
+
description: "Read a JSON report produced by `webhook-crash-lab run --json <file>` (e.g. from CI) and return the failures in fix order, with why/fix hints and next steps.",
|
|
198
|
+
inputSchema: {
|
|
199
|
+
reportPath: z.string().optional(),
|
|
200
|
+
report: z.union([z.string(), z.record(z.string(), z.unknown())]).optional().describe("Report JSON, as text or object"),
|
|
201
|
+
cwd: z.string().optional()
|
|
202
|
+
},
|
|
203
|
+
outputSchema: summaryShape,
|
|
204
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
205
|
+
},
|
|
206
|
+
(args) => respond(() => explainReport(args))
|
|
207
|
+
);
|
|
208
|
+
server.registerTool(
|
|
209
|
+
"list_scenarios",
|
|
210
|
+
{
|
|
211
|
+
title: "List crash-lab scenarios",
|
|
212
|
+
description: "List every scenario crash-lab can run, with what it sends, why it matters in production, and how to fix a failure.",
|
|
213
|
+
inputSchema: {},
|
|
214
|
+
outputSchema: {
|
|
215
|
+
scenarios: z.array(
|
|
216
|
+
z.object({
|
|
217
|
+
id: z.string(),
|
|
218
|
+
title: z.string(),
|
|
219
|
+
what: z.string(),
|
|
220
|
+
why: z.string(),
|
|
221
|
+
fix: z.string()
|
|
222
|
+
})
|
|
223
|
+
)
|
|
224
|
+
},
|
|
225
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
226
|
+
},
|
|
227
|
+
() => {
|
|
228
|
+
const scenarios = listScenarios();
|
|
229
|
+
return {
|
|
230
|
+
content: [
|
|
231
|
+
{
|
|
232
|
+
type: "text",
|
|
233
|
+
text: scenarios.map((s) => `${s.id}: ${s.what}
|
|
234
|
+
fix: ${s.fix}`).join("\n")
|
|
235
|
+
}
|
|
236
|
+
],
|
|
237
|
+
structuredContent: { scenarios }
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
);
|
|
241
|
+
return server;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export {
|
|
245
|
+
SCENARIO_IDS,
|
|
246
|
+
summarize,
|
|
247
|
+
loadConfig,
|
|
248
|
+
runFromConfig,
|
|
249
|
+
runDemo,
|
|
250
|
+
explainReport,
|
|
251
|
+
listScenarios,
|
|
252
|
+
renderSummary,
|
|
253
|
+
VERSION,
|
|
254
|
+
createServer
|
|
255
|
+
};
|
|
256
|
+
//# sourceMappingURL=chunk-UR6UXUAN.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core.ts","../src/server.ts"],"sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';\nimport { dirname, resolve } from 'node:path';\nimport {\n demoConfig,\n loadConfig as loadLabConfig,\n runCrashLab,\n SCENARIOS,\n toJson,\n type CrashLabConfig,\n type CrashLabResult,\n type ScenarioId,\n} from 'webhook-crash-lab';\n\nexport type { CrashLabResult, ScenarioId };\n\n/** Fix security first, then duplicates, then ordering: later fixes depend on earlier ones. */\nconst PRIORITY: ScenarioId[] = [\n 'missing-signature',\n 'invalid-signature',\n 'stale-timestamp',\n 'malformed-json',\n 'single-valid',\n 'duplicate-sequential',\n 'duplicate-concurrent',\n 'out-of-order',\n];\nexport const SCENARIO_IDS = SCENARIOS.map((s) => s.id) as ScenarioId[];\n\nexport interface Failure {\n id: ScenarioId;\n title: string;\n why: string;\n fix: string;\n failedChecks: { name: string; detail: string }[];\n responses: number[];\n}\n\nexport interface Summary {\n name: string;\n ok: boolean;\n summary: { pass: number; fail: number; notTested: number };\n /** Failing scenarios, most important first. */\n failures: Failure[];\n notTested: ScenarioId[];\n scenarios: { id: ScenarioId; status: string }[];\n nextSteps: string[];\n reportPath?: string;\n}\n\nexport function summarize(result: CrashLabResult, reportPath?: string): Summary {\n const rank = (id: ScenarioId) => PRIORITY.indexOf(id);\n const failures = result.scenarios\n .filter((s) => s.status === 'fail')\n .sort((a, b) => rank(a.id) - rank(b.id))\n .map<Failure>((s) => ({\n id: s.id,\n title: s.title,\n why: s.why,\n fix: s.fix,\n failedChecks: s.checks\n .filter((c) => c.status === 'fail')\n .map(({ name, detail }) => ({ name, detail })),\n responses: s.responses,\n }));\n const notTested = result.scenarios.filter((s) => s.status === 'not-tested').map((s) => s.id);\n\n const nextSteps: string[] = [];\n if (failures.length) {\n nextSteps.push(\n `Fix in this order: ${failures.map((f) => f.id).join(' → ')}. Each failure has a \"fix\" hint.`,\n 'Change the webhook handler (not the test config), then re-run the same tool with the same arguments. Stop when ok is true.',\n 'Static checks for the same bugs: eslint-plugin-webhook-safety and eslint-plugin-bullmq.',\n );\n } else {\n nextSteps.push(\n 'All scenarios pass. Keep `npx webhook-crash-lab run` in CI so this stays true.',\n );\n }\n if (notTested.length) {\n nextSteps.push(\n `${notTested.length} scenario(s) not tested (${notTested.join(', ')}): duplicate checks need an \\`adapter\\` + \\`expect.once\\`; out-of-order needs \\`sequence\\` in the config.`,\n );\n }\n return {\n name: result.name,\n ok: result.ok,\n summary: result.summary,\n failures,\n notTested,\n scenarios: result.scenarios.map((s) => ({ id: s.id, status: s.status })),\n nextSteps,\n ...(reportPath ? { reportPath } : {}),\n };\n}\n\nfunction writeReport(result: CrashLabResult, reportPath: string): void {\n mkdirSync(dirname(reportPath), { recursive: true });\n writeFileSync(reportPath, toJson(result));\n}\n\nexport interface RunOptions {\n configPath: string;\n /** Directory relative paths resolve against. Defaults to process.cwd(). */\n cwd?: string;\n scenarios?: ScenarioId[];\n /** If set, the JSON report is written here too. */\n reportPath?: string;\n}\n\n/**\n * Load a crash-lab config (.mjs/.js/.cjs, or .ts/.mts/.cts via jiti or Node type stripping).\n * Delegates to webhook-crash-lab so the CLI and the MCP server accept exactly the same files,\n * and re-reads the file each call so an agent re-running after edits gets the new config.\n */\nexport async function loadConfig(configPath: string, cwd = process.cwd()): Promise<CrashLabConfig> {\n return loadLabConfig(configPath, cwd);\n}\n\nexport async function runFromConfig(opts: RunOptions): Promise<Summary> {\n for (const id of opts.scenarios ?? []) {\n if (!SCENARIO_IDS.includes(id)) {\n throw new Error(`Unknown scenario \"${id}\". Known: ${SCENARIO_IDS.join(', ')}`);\n }\n }\n const config = await loadConfig(opts.configPath, opts.cwd);\n const result = await runCrashLab(\n opts.scenarios?.length ? { ...config, scenarios: opts.scenarios } : config,\n );\n const reportPath = opts.reportPath && resolve(opts.cwd ?? process.cwd(), opts.reportPath);\n if (reportPath) writeReport(result, reportPath);\n return summarize(result, reportPath);\n}\n\nexport async function runDemo(mode: 'broken' | 'fixed'): Promise<Summary> {\n return summarize(await runCrashLab(demoConfig(mode)));\n}\n\nfunction isReport(x: unknown): x is CrashLabResult {\n const r = x as CrashLabResult;\n return (\n !!r &&\n typeof r === 'object' &&\n typeof r.ok === 'boolean' &&\n Array.isArray(r.scenarios) &&\n !!r.summary\n );\n}\n\nexport function explainReport(input: {\n reportPath?: string;\n report?: unknown;\n cwd?: string;\n}): Summary {\n let report: unknown = input.report;\n if (input.reportPath) {\n const path = resolve(input.cwd ?? process.cwd(), input.reportPath);\n if (!existsSync(path)) throw new Error(`Report not found: ${path}`);\n report = readFileSync(path, 'utf8');\n }\n if (report === undefined)\n throw new Error('Pass reportPath or report (the JSON from `webhook-crash-lab run --json`).');\n if (typeof report === 'string') {\n try {\n report = JSON.parse(report);\n } catch {\n throw new Error('Report is not valid JSON.');\n }\n }\n if (!isReport(report))\n throw new Error(\n 'This is not a webhook-crash-lab JSON report (expected ok, summary, scenarios).',\n );\n return summarize(report, input.reportPath);\n}\n\nexport function listScenarios() {\n return SCENARIOS.map(({ id, title, what, why, fix }) => ({ id, title, what, why, fix }));\n}\n\n/** Short human/agent-readable rendering of a summary. */\nexport function renderSummary(s: Summary): string {\n const lines = [\n `${s.ok ? 'PASS' : 'FAIL'} ${s.name}: ${s.summary.pass} pass, ${s.summary.fail} fail, ${s.summary.notTested} not tested`,\n ];\n for (const f of s.failures) {\n lines.push('', `✗ ${f.id}: ${f.title}`);\n for (const c of f.failedChecks) lines.push(` ${c.name}: ${c.detail}`);\n lines.push(` why: ${f.why}`, ` fix: ${f.fix}`);\n }\n lines.push('', 'Next steps:', ...s.nextSteps.map((n) => `- ${n}`));\n if (s.reportPath) lines.push(`Report written to ${s.reportPath}`);\n return lines.join('\\n');\n}\n","import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';\nimport { z } from 'zod';\nimport {\n explainReport,\n listScenarios,\n renderSummary,\n runDemo,\n runFromConfig,\n SCENARIO_IDS,\n type Summary,\n} from './core.js';\n\nexport const VERSION = '0.2.0';\n\nconst summaryShape = {\n name: z.string(),\n ok: z.boolean(),\n summary: z.object({ pass: z.number(), fail: z.number(), notTested: z.number() }),\n failures: z.array(\n z.object({\n id: z.string(),\n title: z.string(),\n why: z.string(),\n fix: z.string(),\n failedChecks: z.array(z.object({ name: z.string(), detail: z.string() })),\n responses: z.array(z.number()),\n }),\n ),\n notTested: z.array(z.string()),\n scenarios: z.array(z.object({ id: z.string(), status: z.string() })),\n nextSteps: z.array(z.string()),\n reportPath: z.string().optional(),\n};\n\nasync function respond(work: () => Promise<Summary> | Summary) {\n try {\n const s = await work();\n return {\n content: [{ type: 'text' as const, text: renderSummary(s) }],\n structuredContent: s as unknown as Record<string, unknown>,\n };\n } catch (err) {\n return { isError: true, content: [{ type: 'text' as const, text: (err as Error).message }] };\n }\n}\n\nexport function createServer(): McpServer {\n const server = new McpServer({ name: 'webhook-crash-lab', version: VERSION });\n\n server.registerTool(\n 'run_crash_lab',\n {\n title: 'Run webhook-crash-lab',\n description:\n 'Replay hostile webhook deliveries (duplicates, concurrent duplicates, out-of-order events, forged/missing/stale signatures, malformed JSON) against the handler described by a crash-lab config file, and return structured failures with fix hints. Use a TEST database and test secret only. Create a config with `npx webhook-crash-lab init` if none exists.',\n inputSchema: {\n configPath: z\n .string()\n .describe(\n 'Path to a crash-lab config (.mjs, .js, .cjs, .ts, .mts or .cts), relative to cwd',\n ),\n cwd: z\n .string()\n .optional()\n .describe('Project directory; defaults to the server working directory'),\n scenarios: z\n .array(z.enum(SCENARIO_IDS as [string, ...string[]]))\n .optional()\n .describe('Run only these scenarios'),\n reportPath: z.string().optional().describe('Also write the full JSON report here'),\n },\n outputSchema: summaryShape,\n annotations: { readOnlyHint: false, openWorldHint: true },\n },\n (args) => respond(() => runFromConfig(args as Parameters<typeof runFromConfig>[0])),\n );\n\n server.registerTool(\n 'run_demo',\n {\n title: 'Run the built-in demo',\n description:\n 'Run crash-lab against its built-in demo credits app. \"broken\" shows the typical AI-generated Stripe handler failing; \"fixed\" shows the same app done right. Needs no config. Use it to show what crash-lab checks before setting up a real config.',\n inputSchema: { mode: z.enum(['broken', 'fixed']).default('broken') },\n outputSchema: summaryShape,\n annotations: { readOnlyHint: true, openWorldHint: false },\n },\n ({ mode }) => respond(() => runDemo(mode)),\n );\n\n server.registerTool(\n 'explain_report',\n {\n title: 'Explain a crash-lab report',\n description:\n 'Read a JSON report produced by `webhook-crash-lab run --json <file>` (e.g. from CI) and return the failures in fix order, with why/fix hints and next steps.',\n inputSchema: {\n reportPath: z.string().optional(),\n report: z\n .union([z.string(), z.record(z.string(), z.unknown())])\n .optional()\n .describe('Report JSON, as text or object'),\n cwd: z.string().optional(),\n },\n outputSchema: summaryShape,\n annotations: { readOnlyHint: true, openWorldHint: false },\n },\n (args) => respond(() => explainReport(args)),\n );\n\n server.registerTool(\n 'list_scenarios',\n {\n title: 'List crash-lab scenarios',\n description:\n 'List every scenario crash-lab can run, with what it sends, why it matters in production, and how to fix a failure.',\n inputSchema: {},\n outputSchema: {\n scenarios: z.array(\n z.object({\n id: z.string(),\n title: z.string(),\n what: z.string(),\n why: z.string(),\n fix: z.string(),\n }),\n ),\n },\n annotations: { readOnlyHint: true, openWorldHint: false },\n },\n () => {\n const scenarios = listScenarios();\n return {\n content: [\n {\n type: 'text',\n text: scenarios.map((s) => `${s.id}: ${s.what}\\n fix: ${s.fix}`).join('\\n'),\n },\n ],\n structuredContent: { scenarios },\n };\n },\n );\n\n return server;\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ScenarioId, CrashLabConfig, CrashLabResult } from 'webhook-crash-lab';
|
|
2
|
+
export { CrashLabResult, ScenarioId } from 'webhook-crash-lab';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
|
|
5
|
+
declare const SCENARIO_IDS: ScenarioId[];
|
|
6
|
+
interface Failure {
|
|
7
|
+
id: ScenarioId;
|
|
8
|
+
title: string;
|
|
9
|
+
why: string;
|
|
10
|
+
fix: string;
|
|
11
|
+
failedChecks: {
|
|
12
|
+
name: string;
|
|
13
|
+
detail: string;
|
|
14
|
+
}[];
|
|
15
|
+
responses: number[];
|
|
16
|
+
}
|
|
17
|
+
interface Summary {
|
|
18
|
+
name: string;
|
|
19
|
+
ok: boolean;
|
|
20
|
+
summary: {
|
|
21
|
+
pass: number;
|
|
22
|
+
fail: number;
|
|
23
|
+
notTested: number;
|
|
24
|
+
};
|
|
25
|
+
/** Failing scenarios, most important first. */
|
|
26
|
+
failures: Failure[];
|
|
27
|
+
notTested: ScenarioId[];
|
|
28
|
+
scenarios: {
|
|
29
|
+
id: ScenarioId;
|
|
30
|
+
status: string;
|
|
31
|
+
}[];
|
|
32
|
+
nextSteps: string[];
|
|
33
|
+
reportPath?: string;
|
|
34
|
+
}
|
|
35
|
+
declare function summarize(result: CrashLabResult, reportPath?: string): Summary;
|
|
36
|
+
interface RunOptions {
|
|
37
|
+
configPath: string;
|
|
38
|
+
/** Directory relative paths resolve against. Defaults to process.cwd(). */
|
|
39
|
+
cwd?: string;
|
|
40
|
+
scenarios?: ScenarioId[];
|
|
41
|
+
/** If set, the JSON report is written here too. */
|
|
42
|
+
reportPath?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Load a crash-lab config (.mjs/.js/.cjs, or .ts/.mts/.cts via jiti or Node type stripping).
|
|
46
|
+
* Delegates to webhook-crash-lab so the CLI and the MCP server accept exactly the same files,
|
|
47
|
+
* and re-reads the file each call so an agent re-running after edits gets the new config.
|
|
48
|
+
*/
|
|
49
|
+
declare function loadConfig(configPath: string, cwd?: string): Promise<CrashLabConfig>;
|
|
50
|
+
declare function runFromConfig(opts: RunOptions): Promise<Summary>;
|
|
51
|
+
declare function runDemo(mode: 'broken' | 'fixed'): Promise<Summary>;
|
|
52
|
+
declare function explainReport(input: {
|
|
53
|
+
reportPath?: string;
|
|
54
|
+
report?: unknown;
|
|
55
|
+
cwd?: string;
|
|
56
|
+
}): Summary;
|
|
57
|
+
declare function listScenarios(): {
|
|
58
|
+
id: ScenarioId;
|
|
59
|
+
title: string;
|
|
60
|
+
what: string;
|
|
61
|
+
why: string;
|
|
62
|
+
fix: string;
|
|
63
|
+
}[];
|
|
64
|
+
/** Short human/agent-readable rendering of a summary. */
|
|
65
|
+
declare function renderSummary(s: Summary): string;
|
|
66
|
+
|
|
67
|
+
declare const VERSION = "0.2.0";
|
|
68
|
+
declare function createServer(): McpServer;
|
|
69
|
+
|
|
70
|
+
export { type Failure, type RunOptions, SCENARIO_IDS, type Summary, VERSION, createServer, explainReport, listScenarios, loadConfig, renderSummary, runDemo, runFromConfig, summarize };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SCENARIO_IDS,
|
|
3
|
+
VERSION,
|
|
4
|
+
createServer,
|
|
5
|
+
explainReport,
|
|
6
|
+
listScenarios,
|
|
7
|
+
loadConfig,
|
|
8
|
+
renderSummary,
|
|
9
|
+
runDemo,
|
|
10
|
+
runFromConfig,
|
|
11
|
+
summarize
|
|
12
|
+
} from "./chunk-UR6UXUAN.js";
|
|
13
|
+
export {
|
|
14
|
+
SCENARIO_IDS,
|
|
15
|
+
VERSION,
|
|
16
|
+
createServer,
|
|
17
|
+
explainReport,
|
|
18
|
+
listScenarios,
|
|
19
|
+
loadConfig,
|
|
20
|
+
renderSummary,
|
|
21
|
+
runDemo,
|
|
22
|
+
runFromConfig,
|
|
23
|
+
summarize
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<!-- Paste into AGENTS.md (Codex, Cursor, others) or CLAUDE.md. -->
|
|
2
|
+
|
|
3
|
+
## Webhooks
|
|
4
|
+
|
|
5
|
+
- Any change to a webhook handler must pass webhook-crash-lab before it is done:
|
|
6
|
+
`npx webhook-crash-lab run crash-lab.config.mjs --json crash-lab-report.json` (exit 0 = pass).
|
|
7
|
+
If the `webhook-crash-lab` MCP server is available, use its `run_crash_lab` tool instead.
|
|
8
|
+
- On failure: fix the handler using each failure's `fix` hint, then re-run. Never edit the config or drop scenarios to make it pass.
|
|
9
|
+
- Signature checks need the raw body (`express.raw()` / `await req.text()`). Dedupe on `event.id` before side effects. BullMQ jobs from webhooks use `jobId: event.id`.
|
|
10
|
+
- Crash-lab configs must use a test database and test secrets only.
|
package/package.json
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webhook-crash-lab-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "MCP server (plus Claude Code plugin and Codex skill) that lets coding agents run webhook-crash-lab, read structured failures with fix hints, and re-run until the webhook handler survives retries, duplicates and forged signatures.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Krrish Kumar (https://github.com/Krrishkumar125)",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Krrishkumar125/webhook-crash-lab-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Krrishkumar125/webhook-crash-lab-mcp#readme",
|
|
12
|
+
"bugs": "https://github.com/Krrishkumar125/webhook-crash-lab-mcp/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"mcp",
|
|
15
|
+
"model-context-protocol",
|
|
16
|
+
"claude-code",
|
|
17
|
+
"codex",
|
|
18
|
+
"agent-skills",
|
|
19
|
+
"webhook",
|
|
20
|
+
"stripe",
|
|
21
|
+
"idempotency",
|
|
22
|
+
"testing"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"webhook-crash-lab-mcp": "dist/bin.js"
|
|
27
|
+
},
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"skills",
|
|
40
|
+
".claude-plugin",
|
|
41
|
+
".mcp.json",
|
|
42
|
+
".agents",
|
|
43
|
+
"docs",
|
|
44
|
+
"CHANGELOG.md"
|
|
45
|
+
],
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=20"
|
|
48
|
+
},
|
|
49
|
+
"sideEffects": false,
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsup",
|
|
52
|
+
"typecheck": "tsc --noEmit",
|
|
53
|
+
"lint": "eslint . && prettier --check .",
|
|
54
|
+
"format": "prettier --write .",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"test:coverage": "vitest run --coverage",
|
|
57
|
+
"check:exports": "publint && attw --pack . --profile esm-only",
|
|
58
|
+
"smoke": "npm run build && node scripts/smoke.mjs",
|
|
59
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm test && npm run build"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
63
|
+
"jiti": "^2.7.0",
|
|
64
|
+
"webhook-crash-lab": "^0.3.0",
|
|
65
|
+
"zod": "^4.6.7"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
69
|
+
"@eslint/js": "^10.0.1",
|
|
70
|
+
"@types/node": "^20.19.43",
|
|
71
|
+
"@vitest/coverage-v8": "^4.1.12",
|
|
72
|
+
"eslint": "^10.13.0",
|
|
73
|
+
"globals": "^17.13.0",
|
|
74
|
+
"prettier": "^3.9.10",
|
|
75
|
+
"publint": "^0.3.25",
|
|
76
|
+
"tsup": "^8.5.1",
|
|
77
|
+
"typescript": "~5.9",
|
|
78
|
+
"typescript-eslint": "^8.72.0",
|
|
79
|
+
"vitest": "^4.1.12"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: webhook-crash-lab
|
|
3
|
+
description: Test and fix webhook handlers (Stripe, GitHub) so they survive duplicate, concurrent and out-of-order deliveries and reject forged, missing or stale signatures. Use when writing, reviewing or debugging a webhook endpoint, after generating webhook code, or when a user reports double credits, double emails, "No signatures found matching the expected signature", or webhooks failing in production.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# webhook-crash-lab
|
|
7
|
+
|
|
8
|
+
Webhook providers deliver events **at least once**: retries, duplicates, parallel copies, wrong order.
|
|
9
|
+
webhook-crash-lab replays those deliveries against the real handler and checks both HTTP status and
|
|
10
|
+
business state (for example "credits granted exactly once").
|
|
11
|
+
|
|
12
|
+
## Loop
|
|
13
|
+
|
|
14
|
+
1. **Find the handler** (`/webhook` route, `app/api/webhooks/*/route.ts`, `constructEvent` call).
|
|
15
|
+
2. **Config.** If no `crash-lab.config.{mjs,js,ts}` exists, run `npx webhook-crash-lab init` and fill it in:
|
|
16
|
+
- `target`: the local dev URL of the webhook route, or an in-process `(req: Request) => Response` handler.
|
|
17
|
+
- `secret`: a **test** signing secret the handler verifies with (set it in the handler's env too).
|
|
18
|
+
- `event`: a realistic event (for example `checkout.session.completed`).
|
|
19
|
+
- `adapter` (`reset` / `snapshot`) + `expect.once`: needed for the duplicate scenarios. Point it at a
|
|
20
|
+
**test database only**, because `reset()` wipes state before every scenario.
|
|
21
|
+
- `sequence`: real event order, for the out-of-order scenario.
|
|
22
|
+
3. **Run.** Call the `run_crash_lab` MCP tool with `{ configPath, cwd }`. Without the MCP server, use
|
|
23
|
+
`npx webhook-crash-lab run crash-lab.config.mjs --json crash-lab-report.json`, then
|
|
24
|
+
`explain_report` or read the JSON. Exit codes: 0 pass, 1 failures, 2 config error.
|
|
25
|
+
4. **Fix the handler**, one failure at a time, in the order returned (signature → body → duplicates → ordering).
|
|
26
|
+
Use each failure's `fix` hint. Typical fixes:
|
|
27
|
+
- raw body for signature checks (`express.raw()`, `await req.text()`), never `express.json()` first;
|
|
28
|
+
- a unique insert on `event.id` (processed-events table) **before** side effects, in the same transaction;
|
|
29
|
+
- BullMQ `queue.add(name, data, { jobId: event.id })`;
|
|
30
|
+
- ignore events older than the latest one already applied for that object.
|
|
31
|
+
5. **Re-run** the same tool with the same arguments. Repeat until `ok` is `true`. Do not weaken the config
|
|
32
|
+
or remove scenarios to make it pass.
|
|
33
|
+
6. Suggest adding `npx webhook-crash-lab run` to CI, and the ESLint plugins
|
|
34
|
+
`eslint-plugin-webhook-safety` / `eslint-plugin-bullmq` for the static versions of these checks.
|
|
35
|
+
|
|
36
|
+
## Rules
|
|
37
|
+
|
|
38
|
+
- Never point crash-lab at production URLs, production databases or live secrets.
|
|
39
|
+
- `not-tested` means the config lacks what that scenario needs (adapter / expect.once / sequence). Say so; do not report it as passing.
|
|
40
|
+
- `run_demo` (no config needed) shows a broken vs fixed app if the user wants to see what is checked.
|