@xaccefy/pi-casefile 0.1.1 → 0.1.2
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/mcp/server.ts +371 -0
- package/package.json +5 -2
package/mcp/server.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
STATUS_VALUES,
|
|
7
|
+
CONFIDENCE_VALUES,
|
|
8
|
+
SEVERITY_VALUES,
|
|
9
|
+
PRIORITY_VALUES,
|
|
10
|
+
SEARCH_FIELD_VALUES,
|
|
11
|
+
addCaseResult,
|
|
12
|
+
countCases,
|
|
13
|
+
formatCase,
|
|
14
|
+
formatCaseDetail,
|
|
15
|
+
formatCases,
|
|
16
|
+
getCaseById,
|
|
17
|
+
getCasefilePath,
|
|
18
|
+
linkCasesResult,
|
|
19
|
+
promoteFindingResult,
|
|
20
|
+
readCasefile,
|
|
21
|
+
searchCases,
|
|
22
|
+
unlinkCasesResult,
|
|
23
|
+
updateCaseResult,
|
|
24
|
+
writeCaseReport,
|
|
25
|
+
} from "../src/ledger.ts";
|
|
26
|
+
import { runPoc } from "../src/poc-runner.ts";
|
|
27
|
+
|
|
28
|
+
type CasefileMcpToolResult = {
|
|
29
|
+
content: [{ type: "text"; text: string }];
|
|
30
|
+
isError?: boolean;
|
|
31
|
+
structuredContent?: Record<string, unknown>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const statusSchema = z.enum(STATUS_VALUES);
|
|
35
|
+
const confidenceSchema = z.enum(CONFIDENCE_VALUES);
|
|
36
|
+
const severitySchema = z.enum(SEVERITY_VALUES);
|
|
37
|
+
const prioritySchema = z.enum(PRIORITY_VALUES);
|
|
38
|
+
const searchFieldSchema = z.enum(SEARCH_FIELD_VALUES);
|
|
39
|
+
|
|
40
|
+
const commonCaseFields = {
|
|
41
|
+
status: statusSchema.optional().describe("Case status"),
|
|
42
|
+
confidence: confidenceSchema.optional().describe("Confidence level"),
|
|
43
|
+
severity: severitySchema.optional().describe("Security severity"),
|
|
44
|
+
priority: prioritySchema.optional().describe("Triage priority"),
|
|
45
|
+
target: z.string().optional().describe("Target asset, host, repo, or scope"),
|
|
46
|
+
endpoint: z.string().optional().describe("Endpoint, route, file, or object"),
|
|
47
|
+
bugClass: z.string().optional().describe("Bug class or root cause category"),
|
|
48
|
+
summary: z.string().optional().describe("Short report summary"),
|
|
49
|
+
evidence: z.string().optional().describe("Observed evidence or repro notes"),
|
|
50
|
+
impact: z.string().optional().describe("Security impact or chain value"),
|
|
51
|
+
nextStep: z.string().optional().describe("Next validation or exploit step"),
|
|
52
|
+
poc: z.string().optional().describe("Proof of concept steps"),
|
|
53
|
+
remediation: z.string().optional().describe("How to fix it"),
|
|
54
|
+
references: z.array(z.string()).optional().describe("External URLs, CVEs, or advisories"),
|
|
55
|
+
blockers: z.array(z.string()).optional().describe("Current blockers"),
|
|
56
|
+
tags: z.array(z.string()).optional().describe("Tags for filtering"),
|
|
57
|
+
assumptions: z.array(z.string()).optional().describe("Explicit assumptions, unknowns, or uncertainty notes"),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function textResult(text: string, structuredContent?: Record<string, unknown>): CasefileMcpToolResult {
|
|
61
|
+
return {
|
|
62
|
+
content: [{ type: "text" as const, text }],
|
|
63
|
+
...(structuredContent === undefined ? {} : { structuredContent }),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function errorResult(error: unknown): CasefileMcpToolResult {
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
return {
|
|
70
|
+
isError: true,
|
|
71
|
+
content: [{ type: "text" as const, text: `Casefile error: ${message}` }],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runTool(
|
|
76
|
+
fn: () => Promise<{ text: string; structuredContent?: Record<string, unknown> }>,
|
|
77
|
+
): Promise<CasefileMcpToolResult> {
|
|
78
|
+
try {
|
|
79
|
+
const result = await fn();
|
|
80
|
+
return textResult(result.text, result.structuredContent);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return errorResult(error);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createCasefileMcpServer(): McpServer {
|
|
87
|
+
const server = new McpServer({
|
|
88
|
+
name: "casefile",
|
|
89
|
+
version: "1.3.8",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
server.registerTool(
|
|
93
|
+
"casefile_add",
|
|
94
|
+
{
|
|
95
|
+
title: "Add Case",
|
|
96
|
+
description: "Open a new security case as a hypothesis or investigation. Use updates to promote confirmed, blocked, killed, or reported states.",
|
|
97
|
+
inputSchema: {
|
|
98
|
+
title: z.string().describe("Short case title"),
|
|
99
|
+
...commonCaseFields,
|
|
100
|
+
},
|
|
101
|
+
annotations: {
|
|
102
|
+
readOnlyHint: false,
|
|
103
|
+
destructiveHint: false,
|
|
104
|
+
idempotentHint: true,
|
|
105
|
+
openWorldHint: false,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
async (params) => runTool(async () => {
|
|
109
|
+
const result = await addCaseResult(params);
|
|
110
|
+
const text = result.created
|
|
111
|
+
? `Case opened:\n${formatCaseDetail(result.record)}\n\nLedger: ${getCasefilePath()}`
|
|
112
|
+
: `Case already exists: ${result.reason ?? result.record.id}\n${formatCaseDetail(result.record)}\n\nUse casefile_update only for materially new evidence, PoC, impact, blockers, or status changes.`;
|
|
113
|
+
return { text, structuredContent: { ...result, ledger_path: getCasefilePath() } };
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
server.registerTool(
|
|
118
|
+
"casefile_update",
|
|
119
|
+
{
|
|
120
|
+
title: "Update Case",
|
|
121
|
+
description: "Update an existing case with materially new evidence, status, confidence, blockers, impact, remediation, or next steps.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
id: z.string().describe("Case ID to update"),
|
|
124
|
+
title: z.string().optional().describe("Updated case title"),
|
|
125
|
+
...commonCaseFields,
|
|
126
|
+
},
|
|
127
|
+
annotations: {
|
|
128
|
+
readOnlyHint: false,
|
|
129
|
+
destructiveHint: false,
|
|
130
|
+
idempotentHint: true,
|
|
131
|
+
openWorldHint: false,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
async (params) => runTool(async () => {
|
|
135
|
+
const { id, ...update } = params;
|
|
136
|
+
const result = await updateCaseResult(id, update);
|
|
137
|
+
const text = result.changed
|
|
138
|
+
? `Case updated:\n${formatCaseDetail(result.record)}`
|
|
139
|
+
: `Case unchanged: ${result.reason ?? "no material fields changed"}\n${formatCaseDetail(result.record)}`;
|
|
140
|
+
return { text, structuredContent: result };
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
server.registerTool(
|
|
145
|
+
"casefile_promote",
|
|
146
|
+
{
|
|
147
|
+
title: "Promote Finding",
|
|
148
|
+
description: "Run an on-disk PoC script (Docker sandbox or local) and, on exit 0, promote an investigating case to confirmed.",
|
|
149
|
+
inputSchema: {
|
|
150
|
+
id: z.string().describe("Case ID to promote"),
|
|
151
|
+
poc_path: z.string().describe("Absolute path to the PoC script on disk"),
|
|
152
|
+
local: z.boolean().optional().describe("Run locally instead of in Docker sandbox"),
|
|
153
|
+
},
|
|
154
|
+
annotations: {
|
|
155
|
+
readOnlyHint: false,
|
|
156
|
+
destructiveHint: false,
|
|
157
|
+
idempotentHint: false,
|
|
158
|
+
openWorldHint: false,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
async (params) => runTool(async () => {
|
|
162
|
+
const run = runPoc(params.poc_path, params.local !== true);
|
|
163
|
+
const result = await promoteFindingResult(params.id, {
|
|
164
|
+
path: run.path,
|
|
165
|
+
exitCode: run.exitCode,
|
|
166
|
+
ranAt: run.ranAt,
|
|
167
|
+
output: run.output,
|
|
168
|
+
sandbox: run.sandbox,
|
|
169
|
+
});
|
|
170
|
+
const text = run.exitCode === 0
|
|
171
|
+
? `PoC verified (exit ${run.exitCode}). Case promoted to confirmed:\n${formatCaseDetail(result.record)}`
|
|
172
|
+
: `PoC failed (exit ${run.exitCode}). Case remains investigating.\nOutput:\n${run.output}`;
|
|
173
|
+
return { text, structuredContent: { ...result, run } };
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
server.registerTool(
|
|
178
|
+
"casefile_get",
|
|
179
|
+
{
|
|
180
|
+
title: "Get Case",
|
|
181
|
+
description: "Get full details of a single case by ID.",
|
|
182
|
+
inputSchema: {
|
|
183
|
+
id: z.string().describe("Case ID"),
|
|
184
|
+
},
|
|
185
|
+
annotations: {
|
|
186
|
+
readOnlyHint: true,
|
|
187
|
+
destructiveHint: false,
|
|
188
|
+
idempotentHint: true,
|
|
189
|
+
openWorldHint: false,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
async ({ id }) => runTool(async () => {
|
|
193
|
+
const record = getCaseById(id);
|
|
194
|
+
if (!record) throw new Error(`Case not found: ${id}`);
|
|
195
|
+
return { text: formatCaseDetail(record), structuredContent: { record } };
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
server.registerTool(
|
|
200
|
+
"casefile_list",
|
|
201
|
+
{
|
|
202
|
+
title: "List Cases",
|
|
203
|
+
description: "List cases with optional status, confidence, severity, priority, tag, and pagination filters.",
|
|
204
|
+
inputSchema: {
|
|
205
|
+
status: statusSchema.optional(),
|
|
206
|
+
confidence: confidenceSchema.optional(),
|
|
207
|
+
severity: severitySchema.optional(),
|
|
208
|
+
priority: prioritySchema.optional(),
|
|
209
|
+
tag: z.string().optional().describe("Filter by tag"),
|
|
210
|
+
limit: z.number().int().min(1).max(200).optional().describe("Max results, default 50"),
|
|
211
|
+
offset: z.number().int().min(0).optional().describe("Skip N results for pagination"),
|
|
212
|
+
},
|
|
213
|
+
annotations: {
|
|
214
|
+
readOnlyHint: true,
|
|
215
|
+
destructiveHint: false,
|
|
216
|
+
idempotentHint: true,
|
|
217
|
+
openWorldHint: false,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
async (params) => runTool(async () => {
|
|
221
|
+
const result = await searchCases(params);
|
|
222
|
+
const offset = params.offset ?? 0;
|
|
223
|
+
const header = `Showing ${result.cases.length} of ${result.total} cases (offset: ${offset})`;
|
|
224
|
+
const body = result.cases.length > 0 ? formatCases(result.cases) : "No cases match filters.";
|
|
225
|
+
return { text: `${header}\n${body}`, structuredContent: { ...result, offset } };
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
server.registerTool(
|
|
230
|
+
"casefile_search",
|
|
231
|
+
{
|
|
232
|
+
title: "Search Cases",
|
|
233
|
+
description: "Full-text search across cases, optionally restricted to title, summary, evidence, impact, target, endpoint, bugClass, or poc.",
|
|
234
|
+
inputSchema: {
|
|
235
|
+
query: z.string().describe("Text to search across cases"),
|
|
236
|
+
field: searchFieldSchema.optional().describe("Restrict search to a specific field"),
|
|
237
|
+
status: statusSchema.optional(),
|
|
238
|
+
confidence: confidenceSchema.optional(),
|
|
239
|
+
severity: severitySchema.optional(),
|
|
240
|
+
priority: prioritySchema.optional(),
|
|
241
|
+
tag: z.string().optional(),
|
|
242
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
243
|
+
offset: z.number().int().min(0).optional(),
|
|
244
|
+
},
|
|
245
|
+
annotations: {
|
|
246
|
+
readOnlyHint: true,
|
|
247
|
+
destructiveHint: false,
|
|
248
|
+
idempotentHint: true,
|
|
249
|
+
openWorldHint: false,
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
async (params) => runTool(async () => {
|
|
253
|
+
const result = await searchCases(params);
|
|
254
|
+
const offset = params.offset ?? 0;
|
|
255
|
+
const header = `Search "${params.query}"${params.field ? ` in ${params.field}` : ""}: ${result.cases.length} of ${result.total} results (offset: ${offset})`;
|
|
256
|
+
const body = result.cases.length > 0 ? formatCases(result.cases) : "No matching cases.";
|
|
257
|
+
return { text: `${header}\n${body}`, structuredContent: { ...result, offset } };
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
server.registerTool(
|
|
262
|
+
"casefile_link",
|
|
263
|
+
{
|
|
264
|
+
title: "Link Cases",
|
|
265
|
+
description: "Bidirectionally link two cases into an exploit chain or related investigation trail.",
|
|
266
|
+
inputSchema: {
|
|
267
|
+
source_id: z.string().describe("First case ID"),
|
|
268
|
+
target_id: z.string().describe("Second case ID"),
|
|
269
|
+
},
|
|
270
|
+
annotations: {
|
|
271
|
+
readOnlyHint: false,
|
|
272
|
+
destructiveHint: false,
|
|
273
|
+
idempotentHint: true,
|
|
274
|
+
openWorldHint: false,
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
async ({ source_id, target_id }) => runTool(async () => {
|
|
278
|
+
const result = await linkCasesResult(source_id, target_id);
|
|
279
|
+
const text = result.changed
|
|
280
|
+
? `Linked:\n ${formatCase(result.source)}\n <->\n ${formatCase(result.target)}`
|
|
281
|
+
: `Link unchanged: ${result.reason ?? "no material change"}\n ${formatCase(result.source)}\n <->\n ${formatCase(result.target)}`;
|
|
282
|
+
return { text, structuredContent: result };
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
server.registerTool(
|
|
287
|
+
"casefile_unlink",
|
|
288
|
+
{
|
|
289
|
+
title: "Unlink Cases",
|
|
290
|
+
description: "Remove a bidirectional link between two cases.",
|
|
291
|
+
inputSchema: {
|
|
292
|
+
source_id: z.string().describe("First case ID"),
|
|
293
|
+
target_id: z.string().describe("Second case ID"),
|
|
294
|
+
},
|
|
295
|
+
annotations: {
|
|
296
|
+
readOnlyHint: false,
|
|
297
|
+
destructiveHint: false,
|
|
298
|
+
idempotentHint: true,
|
|
299
|
+
openWorldHint: false,
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
async ({ source_id, target_id }) => runTool(async () => {
|
|
303
|
+
const result = await unlinkCasesResult(source_id, target_id);
|
|
304
|
+
const text = result.changed
|
|
305
|
+
? `Unlinked:\n ${formatCase(result.source)}\n -/->\n ${formatCase(result.target)}`
|
|
306
|
+
: `Unlink unchanged: ${result.reason ?? "no material change"}\n ${formatCase(result.source)}\n -/->\n ${formatCase(result.target)}`;
|
|
307
|
+
return { text, structuredContent: result };
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
server.registerTool(
|
|
312
|
+
"casefile_report",
|
|
313
|
+
{
|
|
314
|
+
title: "Write Case Report",
|
|
315
|
+
description: "Generate a markdown report from a confirmed or reported case under the casefile report directory.",
|
|
316
|
+
inputSchema: {
|
|
317
|
+
id: z.string().describe("Case ID to turn into a markdown report"),
|
|
318
|
+
},
|
|
319
|
+
annotations: {
|
|
320
|
+
readOnlyHint: false,
|
|
321
|
+
destructiveHint: false,
|
|
322
|
+
idempotentHint: true,
|
|
323
|
+
openWorldHint: false,
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
async ({ id }) => runTool(async () => {
|
|
327
|
+
const result = await writeCaseReport(id);
|
|
328
|
+
return {
|
|
329
|
+
text: `Report written: ${result.path}\n${formatCase(result.record)}`,
|
|
330
|
+
structuredContent: result,
|
|
331
|
+
};
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
server.registerTool(
|
|
336
|
+
"casefile_count",
|
|
337
|
+
{
|
|
338
|
+
title: "Count Cases",
|
|
339
|
+
description: "Return total case count grouped by status and severity.",
|
|
340
|
+
inputSchema: {},
|
|
341
|
+
annotations: {
|
|
342
|
+
readOnlyHint: true,
|
|
343
|
+
destructiveHint: false,
|
|
344
|
+
idempotentHint: true,
|
|
345
|
+
openWorldHint: false,
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
async () => runTool(async () => {
|
|
349
|
+
const result = await countCases();
|
|
350
|
+
return {
|
|
351
|
+
text: `Casefile: ${result.total} total | Status: ${Object.entries(result.byStatus).map(([key, value]) => `${key}:${value}`).join(", ")} | Severity: ${Object.entries(result.bySeverity).map(([key, value]) => `${key}:${value}`).join(", ")}`,
|
|
352
|
+
structuredContent: result,
|
|
353
|
+
};
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
return server;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function startCasefileMcpServer(): Promise<void> {
|
|
361
|
+
const server = createCasefileMcpServer();
|
|
362
|
+
const transport = new StdioServerTransport();
|
|
363
|
+
await server.connect(transport);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (import.meta.main) {
|
|
367
|
+
startCasefileMcpServer().catch((error) => {
|
|
368
|
+
console.error("Casefile MCP server failed:", error);
|
|
369
|
+
process.exit(1);
|
|
370
|
+
});
|
|
371
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xaccefy/pi-casefile",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"src/ledger.ts",
|
|
36
36
|
"src/poc-runner.ts",
|
|
37
37
|
"src/sqlite-compat.ts",
|
|
38
|
+
"mcp",
|
|
38
39
|
"skills",
|
|
39
40
|
"README.md",
|
|
40
41
|
"LICENSE"
|
|
@@ -49,7 +50,9 @@
|
|
|
49
50
|
]
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
|
-
"@
|
|
53
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
54
|
+
"@sinclair/typebox": "^0.32.34",
|
|
55
|
+
"zod": "^4.4.3"
|
|
53
56
|
},
|
|
54
57
|
"devDependencies": {
|
|
55
58
|
"@types/bun": "^1.3.14",
|