@borgresearch/odin-mcp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1211 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ OdinApiError
4
+ } from "./chunk-XHJYVUHG.js";
5
+
6
+ // src/tools/finding-actions.ts
7
+ import { z as z3 } from "zod";
8
+
9
+ // src/tools/finding-reference.ts
10
+ import { z } from "zod";
11
+ var findingReferenceFields = {
12
+ identifier: z.string().trim().min(1).optional().describe(
13
+ 'Human-readable finding identifier such as "ODI-12". Preferred input.'
14
+ ),
15
+ findingId: z.string().trim().min(1).optional().describe(
16
+ "Raw internal finding ID, useful when chaining from another tool."
17
+ )
18
+ };
19
+ var findingReferenceInput = z.object(findingReferenceFields).refine((value) => Boolean(value.identifier || value.findingId), {
20
+ message: "Provide either identifier or findingId."
21
+ });
22
+ function findingReferenceValue(reference) {
23
+ const value = reference.identifier ?? reference.findingId;
24
+ if (!value) {
25
+ throw new Error("A finding identifier or findingId is required.");
26
+ }
27
+ return value;
28
+ }
29
+ function findingReferencePath(reference, suffix = "") {
30
+ return `/api/findings/${encodeURIComponent(findingReferenceValue(reference))}${suffix}`;
31
+ }
32
+ async function resolveFindingId(client, reference) {
33
+ if (reference.findingId) return reference.findingId;
34
+ const data = await client.get(
35
+ findingReferencePath(reference)
36
+ );
37
+ return data.remediationContext?.findingId ?? data.finding.findingId;
38
+ }
39
+
40
+ // src/tools/finding-tool.ts
41
+ import { z as z2 } from "zod";
42
+ function findingToolInput(fields) {
43
+ return z2.object({ ...findingReferenceFields, ...fields }).refine(
44
+ (value) => {
45
+ const reference = value;
46
+ return Boolean(reference.identifier || reference.findingId);
47
+ },
48
+ { message: "Provide either identifier or findingId." }
49
+ );
50
+ }
51
+ var readAnnotations = {
52
+ readOnlyHint: true,
53
+ destructiveHint: false,
54
+ idempotentHint: true,
55
+ openWorldHint: false
56
+ };
57
+ var writeAnnotations = {
58
+ readOnlyHint: false,
59
+ destructiveHint: true,
60
+ idempotentHint: false,
61
+ openWorldHint: true
62
+ };
63
+
64
+ // src/tools/finding-actions.ts
65
+ var followFindingInput = findingToolInput({
66
+ follow: z3.boolean().describe("True to follow the finding, false to stop following it.")
67
+ });
68
+ var followFindingTool = {
69
+ name: "follow_finding",
70
+ title: "Follow or unfollow a finding",
71
+ description: "Follow a finding to get its notifications, or stop following it. Requires write permission and acts as the API key creator. The assignee always follows their finding and can't unfollow it.",
72
+ annotations: { ...writeAnnotations, idempotentHint: true },
73
+ inputSchema: followFindingInput,
74
+ handler: (client) => async (params) => {
75
+ const data = await client.put(
76
+ findingReferencePath(params, "/subscription"),
77
+ { subscribed: params.follow }
78
+ );
79
+ const label = findingReferenceValue(params);
80
+ return {
81
+ markdown: data.subscribed ? `Following ${label}.` : `Stopped following ${label}.`,
82
+ structured: data
83
+ };
84
+ }
85
+ };
86
+ var requestFindingRetestInput = findingToolInput({});
87
+ var requestFindingRetestTool = {
88
+ name: "request_finding_retest",
89
+ title: "Request a finding retest",
90
+ description: "Queue a manual retest that checks whether a finding is still exploitable. Spends credits, so confirm with the user first. Works for findings in REPORTED, ACKNOWLEDGED, MITIGATING or OPEN_FOR_RETEST status with no retest already queued or running. Requires write permission and acts as the API key creator. Read the result later with get_finding_retests.",
91
+ annotations: writeAnnotations,
92
+ inputSchema: requestFindingRetestInput,
93
+ handler: (client) => async (params) => {
94
+ try {
95
+ const data = await client.post(
96
+ findingReferencePath(params, "/retest"),
97
+ {}
98
+ );
99
+ return {
100
+ markdown: `Queued retest ${data.retestId} for ${findingReferenceValue(params)}.`,
101
+ structured: data
102
+ };
103
+ } catch (error) {
104
+ if (error instanceof OdinApiError && isRetestUnavailable(error.body)) {
105
+ const reason = "Retests aren't available for this organisation.";
106
+ return {
107
+ markdown: reason,
108
+ structured: { state: "unavailable", reason }
109
+ };
110
+ }
111
+ throw error;
112
+ }
113
+ }
114
+ };
115
+ function isRetestUnavailable(body) {
116
+ return typeof body === "object" && body !== null && body.code === "RETEST_UNAVAILABLE";
117
+ }
118
+
119
+ // src/tools/finding-attachments.ts
120
+ import { z as z4 } from "zod";
121
+ var prepareFindingAttachmentUploadInput = findingToolInput({
122
+ filename: z4.string().min(1).max(500),
123
+ contentType: z4.enum([
124
+ "image/png",
125
+ "image/jpeg",
126
+ "image/gif",
127
+ "image/webp",
128
+ "application/pdf",
129
+ "application/zip",
130
+ "application/json",
131
+ "application/octet-stream",
132
+ "text/plain",
133
+ "text/markdown",
134
+ "text/csv",
135
+ "text/log"
136
+ ]),
137
+ sizeBytes: z4.number().int().min(1).max(25 * 1024 * 1024)
138
+ });
139
+ var prepareFindingAttachmentUploadTool = {
140
+ name: "prepare_finding_attachment_upload",
141
+ title: "Prepare a private discussion attachment upload",
142
+ description: "Create a five-minute signed upload URL for a file up to 25 MiB. Requires write permission. Does not upload bytes or post a message. The caller must PUT the exact file to uploadUrl using requiredHeaders, without the Odin API key, then pass publicUrl as the attachment URL to create_finding_message. publicUrl is a private gs:// object reference. Treat signed URLs as temporary secrets.",
143
+ annotations: writeAnnotations,
144
+ inputSchema: prepareFindingAttachmentUploadInput,
145
+ handler: (client) => async (params) => {
146
+ const data = await client.post(
147
+ findingReferencePath(params, "/thread/upload-url"),
148
+ {
149
+ filename: params.filename,
150
+ contentType: params.contentType,
151
+ sizeBytes: params.sizeBytes
152
+ }
153
+ );
154
+ return {
155
+ markdown: "Upload URL prepared; no bytes have been uploaded. PUT the file to uploadUrl with requiredHeaders, then attach publicUrl in a discussion message. Do not send the Odin API key to the upload URL.",
156
+ structured: data
157
+ };
158
+ }
159
+ };
160
+ var getFindingAttachmentUrlInput = findingToolInput({
161
+ messageId: z4.string().min(1),
162
+ url: z4.string().url().describe(
163
+ "Exact private attachment URL from this message's attachments array."
164
+ )
165
+ });
166
+ var getFindingAttachmentUrlTool = {
167
+ name: "get_finding_attachment_url",
168
+ title: "Get a temporary attachment download URL",
169
+ description: "Resolve a stored discussion attachment to a ten-minute signed download URL. Requires read permission and the attachment must belong to the selected finding and message. Does not download bytes or forward the Odin key to storage. Treat the returned signed URL as a temporary secret.",
170
+ annotations: { ...readAnnotations, openWorldHint: true },
171
+ inputSchema: getFindingAttachmentUrlInput,
172
+ handler: (client) => async (params) => {
173
+ const url = await client.getRedirectUrl(
174
+ findingReferencePath(
175
+ params,
176
+ `/thread/attachments/${encodeURIComponent(params.messageId)}`
177
+ ),
178
+ { url: params.url }
179
+ );
180
+ return {
181
+ markdown: "Temporary attachment download URL resolved. No bytes were downloaded; the URL is in the structured result.",
182
+ structured: { url }
183
+ };
184
+ }
185
+ };
186
+ var getFindingSlackUrlInput = findingToolInput({
187
+ messageId: z4.string().min(1).optional(),
188
+ threadId: z4.string().min(1).optional()
189
+ }).refine((value) => Boolean(value.messageId || value.threadId), {
190
+ message: "Provide a messageId or threadId."
191
+ });
192
+ var getFindingSlackUrlTool = {
193
+ name: "get_finding_slack_url",
194
+ title: "Get a synced discussion's Slack permalink",
195
+ description: "Resolve a synced message or thread to its Slack permalink. Requires read permission. Does not navigate to Slack or forward the Odin API key.",
196
+ annotations: { ...readAnnotations, openWorldHint: true },
197
+ inputSchema: getFindingSlackUrlInput,
198
+ handler: (client) => async (params) => {
199
+ const url = await client.getRedirectUrl(
200
+ findingReferencePath(params, "/thread/slack-link"),
201
+ { messageId: params.messageId, threadId: params.threadId }
202
+ );
203
+ return { markdown: `Slack permalink: ${url}`, structured: { url } };
204
+ }
205
+ };
206
+
207
+ // src/tools/finding-collaboration.ts
208
+ import { z as z5 } from "zod";
209
+ var attachmentInput = z5.object({
210
+ type: z5.enum(["IMAGE", "FILE"]),
211
+ url: z5.string().url().describe(
212
+ "Private gs:// URL returned by prepare_finding_attachment_upload after uploading the file."
213
+ ),
214
+ name: z5.string().min(1).max(500),
215
+ sizeBytes: z5.number().int().nonnegative().optional(),
216
+ mimeType: z5.string().min(1).max(255).optional()
217
+ });
218
+ var assignFindingInput = findingToolInput({
219
+ assigneeUserId: z5.string().min(1).nullable().describe("Organisation member user ID, or null to clear assignment.")
220
+ });
221
+ var assignFindingTool = {
222
+ name: "assign_finding",
223
+ title: "Assign a finding",
224
+ description: "Assign a finding to an organisation member, or clear its assignment. Requires write permission. Assignment can notify the assignee by email and Slack and synchronise connected issue trackers.",
225
+ annotations: { ...writeAnnotations, idempotentHint: true },
226
+ inputSchema: assignFindingInput,
227
+ handler: (client) => async (params) => {
228
+ const data = await client.patch(
229
+ findingReferencePath(params, "/assignee"),
230
+ { assigneeUserId: params.assigneeUserId }
231
+ );
232
+ return {
233
+ markdown: `Updated assignment for ${data.finding.identifier ?? data.finding.findingId}: ${params.assigneeUserId ?? "unassigned"}.`,
234
+ structured: { finding: data.finding }
235
+ };
236
+ }
237
+ };
238
+ var getFindingDiscussionInput = findingToolInput({
239
+ limit: z5.number().int().min(1).max(100).optional(),
240
+ offset: z5.number().int().nonnegative().optional(),
241
+ before: z5.number().int().nonnegative().optional().describe(
242
+ "Only messages before this timestamp, in the API's stored timestamp units."
243
+ )
244
+ });
245
+ var getFindingDiscussionTool = {
246
+ name: "get_finding_discussion",
247
+ title: "Read finding discussions",
248
+ description: "Read native and Slack-synchronised conversation blocks for a finding. Each block paginates independently. API keys have no personal read or unread state.",
249
+ annotations: readAnnotations,
250
+ inputSchema: getFindingDiscussionInput,
251
+ handler: (client) => async (params) => {
252
+ const data = await client.get(
253
+ findingReferencePath(params, "/thread"),
254
+ { limit: params.limit, offset: params.offset, before: params.before }
255
+ );
256
+ const markdown = data.blocks.length ? data.blocks.map(
257
+ (block) => [
258
+ `## ${block.thread.type} discussion ${block.thread.threadId} (${block.thread.status})`,
259
+ `More messages: ${block.hasMore}.`,
260
+ ...block.messages.map(
261
+ (message) => `### ${message.author.displayName} (${message.messageId})
262
+ ${message.bodyMd}`
263
+ )
264
+ ].join("\n\n")
265
+ ).join("\n\n") : "No discussions exist for this finding.";
266
+ return { markdown, structured: data };
267
+ }
268
+ };
269
+ var createFindingMessageInput = findingToolInput({
270
+ bodyMd: z5.string().min(1).max(5e4).describe(
271
+ "Markdown body. Mention syntax: @[Label](USER:id), @[Label](OPERATOR:id), @[ODI-12](FINDING:id), or @[Pentest](PENTEST:id); use list_finding_mentionables to discover IDs."
272
+ ),
273
+ threadId: z5.string().min(1).optional().describe("Existing block to reply to; omit for the native discussion."),
274
+ attachments: z5.array(attachmentInput).max(20).optional()
275
+ });
276
+ var createFindingMessageTool = {
277
+ name: "create_finding_message",
278
+ title: "Post a finding discussion message",
279
+ description: "Post a Markdown discussion message. Requires write permission and acts as the API key creator. May send email, mention notifications and Slack messages. Repeating a call creates another message.",
280
+ annotations: writeAnnotations,
281
+ inputSchema: createFindingMessageInput,
282
+ handler: (client) => async (params) => {
283
+ const data = await client.post(
284
+ findingReferencePath(params, "/thread"),
285
+ {
286
+ bodyMd: params.bodyMd,
287
+ threadId: params.threadId,
288
+ attachments: params.attachments
289
+ }
290
+ );
291
+ return {
292
+ markdown: `Posted message ${data.message.messageId}.`,
293
+ structured: data
294
+ };
295
+ }
296
+ };
297
+ var editFindingMessageInput = findingToolInput({
298
+ messageId: z5.string().min(1),
299
+ bodyMd: z5.string().min(1).max(5e4)
300
+ });
301
+ var editFindingMessageTool = {
302
+ name: "edit_finding_message",
303
+ title: "Edit a finding discussion message",
304
+ description: "Edit a message authored by the API key creator. Requires write permission. New mentions can send notifications and the edit can synchronise to Slack.",
305
+ annotations: writeAnnotations,
306
+ inputSchema: editFindingMessageInput,
307
+ handler: (client) => async (params) => {
308
+ const data = await client.patch(
309
+ findingReferencePath(
310
+ params,
311
+ `/thread/${encodeURIComponent(params.messageId)}`
312
+ ),
313
+ { bodyMd: params.bodyMd }
314
+ );
315
+ return {
316
+ markdown: `Edited message ${data.message.messageId}.`,
317
+ structured: data
318
+ };
319
+ }
320
+ };
321
+ var deleteFindingMessageInput = findingToolInput({
322
+ messageId: z5.string().min(1)
323
+ });
324
+ var deleteFindingMessageTool = {
325
+ name: "delete_finding_message",
326
+ title: "Delete a finding discussion message",
327
+ description: "Delete a message authored by the API key creator. Requires write permission and may delete its Slack mirror. Confirm the user's intent before deleting.",
328
+ annotations: writeAnnotations,
329
+ inputSchema: deleteFindingMessageInput,
330
+ handler: (client) => async (params) => {
331
+ const data = await client.delete(
332
+ findingReferencePath(
333
+ params,
334
+ `/thread/${encodeURIComponent(params.messageId)}`
335
+ )
336
+ );
337
+ return {
338
+ markdown: `Deleted message ${params.messageId}.`,
339
+ structured: data
340
+ };
341
+ }
342
+ };
343
+ var updateFindingDiscussionInput = findingToolInput({
344
+ action: z5.enum(["resolve", "reopen"])
345
+ });
346
+ var updateFindingDiscussionTool = {
347
+ name: "update_finding_discussion",
348
+ title: "Resolve or reopen the native discussion",
349
+ description: "Resolve or reopen a finding's native discussion. Requires write permission and may synchronise the status to Slack. This does not change the finding's security status.",
350
+ annotations: writeAnnotations,
351
+ inputSchema: updateFindingDiscussionInput,
352
+ handler: (client) => async (params) => {
353
+ const data = await client.post(
354
+ findingReferencePath(params, "/thread/resolve"),
355
+ { action: params.action }
356
+ );
357
+ return {
358
+ markdown: `Discussion status: ${data.thread?.status ?? params.action}.`,
359
+ structured: data
360
+ };
361
+ }
362
+ };
363
+ var listFindingMentionablesInput = findingToolInput({
364
+ q: z5.string().trim().max(100).optional()
365
+ });
366
+ var listFindingMentionablesTool = {
367
+ name: "list_finding_mentionables",
368
+ title: "Find people and entities to mention",
369
+ description: "Find authorised people, findings and pentests that can be mentioned in a finding discussion. Returns up to eight people, eight findings and eight pentests matching q.",
370
+ annotations: readAnnotations,
371
+ inputSchema: listFindingMentionablesInput,
372
+ handler: (client) => async (params) => {
373
+ const data = await client.get(
374
+ findingReferencePath(params, "/thread/mentionables"),
375
+ { q: params.q }
376
+ );
377
+ return {
378
+ markdown: data.mentionables.map((item) => `- ${item.label}: ${item.id}`).join("\n") || "No matching mentionables.",
379
+ structured: data
380
+ };
381
+ }
382
+ };
383
+ var disconnectFindingDiscussionInput = findingToolInput({
384
+ threadId: z5.string().min(1)
385
+ });
386
+ var disconnectFindingDiscussionTool = {
387
+ name: "disconnect_finding_slack_thread",
388
+ title: "Disconnect a synced Slack thread",
389
+ description: "Disconnect a finding's SLACK_SYNC discussion block while preserving imported messages. Stops future synchronisation and may post a confirmation to Slack. Requires write permission. Confirm the user's intent before disconnecting.",
390
+ annotations: writeAnnotations,
391
+ inputSchema: disconnectFindingDiscussionInput,
392
+ handler: (client) => async (params) => {
393
+ const data = await client.post(
394
+ findingReferencePath(params, "/thread/disconnect"),
395
+ { threadId: params.threadId }
396
+ );
397
+ return {
398
+ markdown: `Disconnected Slack discussion ${params.threadId}. Existing messages are preserved.`,
399
+ structured: data
400
+ };
401
+ }
402
+ };
403
+
404
+ // src/tools/finding-pull-requests.ts
405
+ import { z as z6 } from "zod";
406
+ var listFindingPullRequestsInput = findingToolInput({
407
+ query: z6.string().optional().describe("PR number, title search, owner/repo#123, or GitHub PR URL."),
408
+ page: z6.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional()
409
+ });
410
+ var listFindingPullRequestsTool = {
411
+ name: "list_finding_pull_requests",
412
+ title: "Find accessible pull requests to link",
413
+ description: "Search accessible GitHub pull requests for this finding. Defaults to the finding's repository, otherwise its connected GitHub organisation. Results are advisory; link_finding_pull_request verifies the chosen PR. Requires read permission and a configured GitHub integration.",
414
+ annotations: { ...readAnnotations, openWorldHint: true },
415
+ inputSchema: listFindingPullRequestsInput,
416
+ handler: (client) => async (params) => {
417
+ const data = await client.get(
418
+ findingReferencePath(params, "/link-pr"),
419
+ { query: params.query, page: params.page }
420
+ );
421
+ return {
422
+ markdown: [
423
+ `Accessible pull requests (${data.repository ?? "connected organisation"}); more pages: ${data.pagination.hasMore}.`,
424
+ ...data.pullRequests.map(
425
+ (pr) => `- ${pr.repo}#${pr.number}: ${pr.title} (${pr.state}) ${pr.url}`
426
+ )
427
+ ].join("\n"),
428
+ structured: data
429
+ };
430
+ }
431
+ };
432
+ var unlinkFindingPullRequestInput = findingToolInput({
433
+ prNodeId: z6.string().min(1).describe(
434
+ "Stored prNodeId of a manually linked pull request from get_finding."
435
+ )
436
+ });
437
+ var unlinkFindingPullRequestTool = {
438
+ name: "unlink_finding_pull_request",
439
+ title: "Unlink a manually linked pull request",
440
+ description: "Remove a manual finding-to-PR link. Requires write permission. Automatic links cannot be removed with this operation. Does not modify, close, merge or delete the GitHub PR. Confirm the user's intent before removing the link.",
441
+ annotations: { ...writeAnnotations, openWorldHint: false },
442
+ inputSchema: unlinkFindingPullRequestInput,
443
+ handler: (client) => async (params) => {
444
+ const data = await client.delete(
445
+ findingReferencePath(params, "/link-pr"),
446
+ { prNodeId: params.prNodeId }
447
+ );
448
+ return {
449
+ markdown: `Removed manual pull request link ${params.prNodeId} from ${data.finding.identifier ?? data.finding.findingId}.`,
450
+ structured: data
451
+ };
452
+ }
453
+ };
454
+
455
+ // src/tools/findings-overview.ts
456
+ import { z as z8 } from "zod";
457
+
458
+ // src/tools/list-findings.ts
459
+ import { z as z7 } from "zod";
460
+
461
+ // src/formatters/markdown.ts
462
+ var STATUS_LABELS = {
463
+ REPORTED: "Reported",
464
+ ACKNOWLEDGED: "Risk accepted",
465
+ MITIGATING: "Fixing",
466
+ OPEN_FOR_RETEST: "Open for Retest",
467
+ NEEDS_REVISION: "Needs Revision",
468
+ FIXED_AND_RETESTED: "Fixed & Retested"
469
+ };
470
+ var RETEST_STATUS_LABELS = {
471
+ QUEUED: "Queued",
472
+ RUNNING: "Running",
473
+ COMPLETED: "Completed",
474
+ FAILED: "Failed",
475
+ SKIPPED: "Skipped"
476
+ };
477
+ function formatFindingsList(data) {
478
+ const { findings, pagination } = data;
479
+ if (findings.length === 0) {
480
+ return "No findings match the supplied filters.";
481
+ }
482
+ const rows = findings.map((f) => {
483
+ const severity = `${f.severity.label} (CVSS ${f.severity.cvss.toFixed(1)})`;
484
+ return `| ${f.identifier ?? "\u2014"} | ${f.findingId} | ${escapePipes(f.title)} | ${severity} | ${formatStatus(f.status)} | ${formatDate(f.createdAt)} |`;
485
+ });
486
+ return [
487
+ `Showing ${findings.length} of ${pagination.total} finding(s). Offset ${pagination.offset}, limit ${pagination.limit}. hasMore=${pagination.hasMore}.`,
488
+ "",
489
+ "| Identifier | Internal ID | Title | Severity | Status | Created |",
490
+ "| --- | --- | --- | --- | --- | --- |",
491
+ ...rows
492
+ ].join("\n");
493
+ }
494
+ function formatFindingDetail(data) {
495
+ const { finding, revision } = data;
496
+ const context = data.remediationContext;
497
+ const identifier = context?.identifier ?? finding.identifier ?? null;
498
+ const displayIdentifier = identifier ?? finding.findingId;
499
+ const severity = `${finding.severity.label} (CVSS ${finding.severity.cvss.toFixed(1)})`;
500
+ const header = [
501
+ `# ${escapePipes(displayIdentifier)} \u2014 ${escapePipes(finding.title)}`,
502
+ "",
503
+ `- **Finding:** ${displayIdentifier}`,
504
+ `- **Internal ID:** ${finding.findingId}`,
505
+ `- **Severity:** ${severity}`,
506
+ `- **Status:** ${formatStatus(finding.status)}`,
507
+ `- **Repository:** ${context?.repository ?? finding.repository ?? "Not recorded"}`,
508
+ `- **Pentest:** ${finding.context?.pentestId ?? "standalone"}`,
509
+ `- **Created:** ${formatDate(finding.createdAt)}`,
510
+ `- **Updated:** ${formatDate(finding.updatedAt)}`
511
+ ];
512
+ const content = context ? {
513
+ revisionNumber: context.revision.revisionNumber,
514
+ summary: context.revision.summary,
515
+ description: context.revision.description,
516
+ impact: context.revision.impact,
517
+ details: context.revision.details,
518
+ remediation: context.revision.remediation,
519
+ affectedAssets: context.revision.affectedAssets,
520
+ codeReference: context.revision.codeReference
521
+ } : revision ? {
522
+ revisionNumber: revision.revisionNumber,
523
+ summary: revision.content.summaryMd,
524
+ description: revision.content.descriptionMd,
525
+ impact: revision.content.businessImpactMd,
526
+ details: revision.content.detailsMd,
527
+ remediation: revision.content.remediationMd,
528
+ affectedAssets: revision.content.affectedAssets ?? [],
529
+ codeReference: null
530
+ } : null;
531
+ const sections = [];
532
+ if (!content) {
533
+ sections.push("_No revision content available._");
534
+ } else {
535
+ sections.push(`_Revision ${content.revisionNumber ?? "unknown"}_`);
536
+ if (content.summary) sections.push("## Summary", content.summary);
537
+ if (content.description)
538
+ sections.push("## Description", content.description);
539
+ if (content.impact) sections.push("## Business Impact", content.impact);
540
+ if (content.details) sections.push("## Details", content.details);
541
+ if (content.remediation)
542
+ sections.push("## Remediation", content.remediation);
543
+ if (content.codeReference) {
544
+ sections.push(
545
+ "## Code reference",
546
+ `- ${content.codeReference.file}:${content.codeReference.line}`
547
+ );
548
+ if (content.codeReference.snippet) {
549
+ sections.push("```", content.codeReference.snippet, "```");
550
+ }
551
+ }
552
+ if (content.affectedAssets?.length) {
553
+ sections.push(
554
+ "## Affected Assets",
555
+ content.affectedAssets.map((asset) => `- ${asset.display} (${asset.assetId})`).join("\n")
556
+ );
557
+ }
558
+ }
559
+ if (context) {
560
+ sections.push(
561
+ "## Remediation workflow",
562
+ `- **Suggested branch:** ${context.suggestedBranch ?? "Not available"}`,
563
+ `- **Ready for a user-created PR:** ${yesNo(context.workflow.readyForUserCreatedPullRequest)}`,
564
+ `- **GitHub connected:** ${yesNo(context.workflow.githubConnected)}`,
565
+ `- **Automatic retests:** ${context.workflow.automaticRetestsReady ? "Ready after merge" : "Not ready"}`
566
+ );
567
+ if (context.workflow.blockers.length) {
568
+ sections.push(
569
+ "### Blockers",
570
+ context.workflow.blockers.map((blocker) => `- ${blocker}`).join("\n")
571
+ );
572
+ }
573
+ if (context.workflow.nextSteps.length) {
574
+ sections.push(
575
+ "### Next steps",
576
+ context.workflow.nextSteps.map((step) => `- ${step}`).join("\n")
577
+ );
578
+ }
579
+ if (context.linkedPullRequests.length) {
580
+ sections.push(
581
+ "## Linked GitHub pull requests",
582
+ context.linkedPullRequests.map(
583
+ (pullRequest) => `- [#${pullRequest.number}](${pullRequest.url}) ${escapePipes(pullRequest.title)} \u2014 ${pullRequest.state}, linked via ${pullRequest.linkedVia}`
584
+ ).join("\n")
585
+ );
586
+ } else {
587
+ sections.push("## Linked GitHub pull requests", "_None linked._");
588
+ }
589
+ sections.push("## Latest retest");
590
+ if (context.latestRetest.state === "unavailable") {
591
+ sections.push(`_${context.latestRetest.reason}_`);
592
+ } else if (context.latestRetest.state === "none") {
593
+ sections.push("_No retest history is available yet._");
594
+ } else {
595
+ sections.push(formatRetestSummary(context.latestRetest.retest));
596
+ }
597
+ }
598
+ return [...header, "", ...sections].join("\n");
599
+ }
600
+ function formatFindingStatusUpdate(finding) {
601
+ const identifier = finding.identifier ?? finding.findingId;
602
+ return [
603
+ `Updated finding **${identifier}** (internal ID ${finding.findingId}) status to **${formatStatus(finding.status)}**.`,
604
+ `Title: ${finding.title}`
605
+ ].join("\n");
606
+ }
607
+ function formatFindingPullRequestLink(data) {
608
+ const identifier = data.finding.identifier ?? data.finding.findingId;
609
+ const pullRequest = data.pullRequest;
610
+ const readiness = data.workflow.automaticRetestsReady ? "Automatic retesting is ready after this PR merges." : "Automatic retesting is not currently ready; review the blockers below.";
611
+ const blockers = data.workflow.blockers.length ? `
612
+ ${data.workflow.blockers.map((blocker) => `- ${blocker}`).join("\n")}` : "";
613
+ return [
614
+ `Linked existing GitHub pull request [#${pullRequest.number}](${pullRequest.url}) to **${identifier}**.`,
615
+ `Repository: ${pullRequest.repo}; state: ${pullRequest.state}; finding status: ${formatStatus(data.finding.status)}.`,
616
+ "Odin did not create, modify, merge, or push this pull request.",
617
+ readiness,
618
+ blockers ? `Workflow blockers:${blockers}` : ""
619
+ ].filter(Boolean).join("\n");
620
+ }
621
+ function formatFindingRetests(data) {
622
+ if (data.state === "none" || data.retests.length === 0) {
623
+ return "No retest history is available for this finding.";
624
+ }
625
+ return [
626
+ `Retest history (${data.retests.length}):`,
627
+ "",
628
+ "| Retest | Status | Trigger | Verdict | Created | Completed |",
629
+ "| --- | --- | --- | --- | --- | --- |",
630
+ ...data.retests.map(
631
+ (retest) => `| ${retest.retestId} | ${RETEST_STATUS_LABELS[retest.status]} | ${retest.trigger} | ${retest.combinedVerdict ?? "\u2014"} | ${formatDate(retest.createdAt)} | ${formatDate(retest.completedAt ?? 0)} |`
632
+ ),
633
+ "",
634
+ "Queued and running entries are still in progress; completed, skipped, and failed entries are final for that run."
635
+ ].join("\n");
636
+ }
637
+ function formatPentestsList(data) {
638
+ if (data.pentests.length === 0) return "No pentests found.";
639
+ const rows = data.pentests.map(formatPentestRow);
640
+ return [
641
+ "| Pentest ID | Name | Status | Start | End | Scope |",
642
+ "| --- | --- | --- | --- | --- | --- |",
643
+ ...rows
644
+ ].join("\n");
645
+ }
646
+ function formatPentestDetail(data) {
647
+ const p = data.pentest;
648
+ const end = p.endAt ? formatDate(p.endAt) : "open";
649
+ const scope = p.scope.length ? p.scope.map((s) => `- ${s}`).join("\n") : "_No scope defined._";
650
+ return [
651
+ `# ${p.name}`,
652
+ "",
653
+ `- **ID:** ${p.pentestId}`,
654
+ `- **Status:** ${p.status}`,
655
+ `- **Start:** ${formatDate(p.startAt)}`,
656
+ `- **End:** ${end}`,
657
+ `- **Created:** ${formatDate(p.createdAt)}`,
658
+ "",
659
+ "## Scope",
660
+ "",
661
+ scope
662
+ ].join("\n");
663
+ }
664
+ function formatFindingsForAsset(findings) {
665
+ if (findings.length === 0) return "_No findings reference this asset._";
666
+ return [
667
+ `## Findings (${findings.length})`,
668
+ "",
669
+ "| Identifier | Internal ID | Title | Severity | Status | Created |",
670
+ "| --- | --- | --- | --- | --- | --- |",
671
+ ...findings.map(formatFindingRow)
672
+ ].join("\n");
673
+ }
674
+ function formatAssetsList(data) {
675
+ const { assets, pagination } = data;
676
+ if (assets.length === 0) return "No assets match the supplied filters.";
677
+ const rows = assets.map(formatAssetRow);
678
+ return [
679
+ `Showing ${assets.length} of ${pagination.total} asset(s). Offset ${pagination.offset}, limit ${pagination.limit}. hasMore=${pagination.hasMore}.`,
680
+ "",
681
+ "| Asset ID | Type | Value | Source | Status | Last seen |",
682
+ "| --- | --- | --- | --- | --- | --- |",
683
+ ...rows
684
+ ].join("\n");
685
+ }
686
+ function formatAssetDetail(data) {
687
+ const { asset, findings } = data;
688
+ const header = [
689
+ `# ${asset.value}`,
690
+ "",
691
+ `- **ID:** ${asset.assetId}`,
692
+ `- **Type:** ${asset.type}`,
693
+ `- **Source:** ${asset.source}`,
694
+ `- **Status:** ${asset.status}`,
695
+ `- **Last seen:** ${formatDate(asset.lastSeenAt)}`,
696
+ `- **Created:** ${formatDate(asset.createdAt)}`
697
+ ];
698
+ return [...header, "", formatFindingsForAsset(findings)].join("\n");
699
+ }
700
+ function formatFindingRow(finding) {
701
+ const severity = `${finding.severity.label} (CVSS ${finding.severity.cvss.toFixed(1)})`;
702
+ return `| ${finding.identifier ?? "\u2014"} | ${finding.findingId} | ${escapePipes(finding.title)} | ${severity} | ${formatStatus(finding.status)} | ${formatDate(finding.createdAt)} |`;
703
+ }
704
+ function formatPentestRow(p) {
705
+ const end = p.endAt ? formatDate(p.endAt) : "open";
706
+ const scope = p.scope.slice(0, 3).join(", ") + (p.scope.length > 3 ? ", \u2026" : "");
707
+ return `| ${p.pentestId} | ${escapePipes(p.name)} | ${p.status} | ${formatDate(p.startAt)} | ${end} | ${escapePipes(scope)} |`;
708
+ }
709
+ function formatAssetRow(a) {
710
+ return `| ${a.assetId} | ${a.type} | ${escapePipes(a.value)} | ${a.source} | ${a.status} | ${formatDate(a.lastSeenAt)} |`;
711
+ }
712
+ function formatRetestSummary(retest) {
713
+ return [
714
+ `- **Status:** ${RETEST_STATUS_LABELS[retest.status]}`,
715
+ `- **Trigger:** ${retest.trigger}`,
716
+ `- **Verdict:** ${retest.combinedVerdict ?? "Not available yet"}`,
717
+ `- **Finding action:** ${retest.statusAction ?? "None"}`,
718
+ `- **Created:** ${formatDate(retest.createdAt)}`,
719
+ `- **Completed:** ${formatDate(retest.completedAt ?? 0)}`
720
+ ].join("\n");
721
+ }
722
+ function formatStatus(status) {
723
+ return STATUS_LABELS[status] ?? status;
724
+ }
725
+ function yesNo(value) {
726
+ return value ? "Yes" : "No";
727
+ }
728
+ function escapePipes(value) {
729
+ return value.replace(/\|/g, "\\|");
730
+ }
731
+ function formatDate(epoch) {
732
+ if (!epoch) return "\u2014";
733
+ const ms = epoch < 1e12 ? epoch * 1e3 : epoch;
734
+ return new Date(ms).toISOString().slice(0, 10);
735
+ }
736
+
737
+ // src/tools/list-findings.ts
738
+ var severityInput = z7.enum([
739
+ "CRITICAL",
740
+ "HIGH",
741
+ "MEDIUM",
742
+ "LOW",
743
+ "INFORMATIONAL"
744
+ ]);
745
+ var statusInput = z7.enum([
746
+ "REPORTED",
747
+ "ACKNOWLEDGED",
748
+ "MITIGATING",
749
+ "OPEN_FOR_RETEST",
750
+ "NEEDS_REVISION",
751
+ "FIXED_AND_RETESTED"
752
+ ]);
753
+ var sourceInput = z7.enum([
754
+ "MANUAL",
755
+ "WATCHDOG",
756
+ "IMPORT",
757
+ "AGENT",
758
+ "THREAT_MODEL"
759
+ ]);
760
+ var confirmationTypeInput = z7.enum(["static", "partial", "exploited"]);
761
+ var assigneeInput = z7.string().trim().min(1).max(500);
762
+ var findingFilterFields = {
763
+ pentestId: z7.string().optional().describe("Filter to a single pentest."),
764
+ assetId: z7.string().optional().describe(
765
+ "Filter to findings whose latest revision references this asset."
766
+ ),
767
+ repository: z7.string().optional().describe("Filter to a source repository such as owner/name."),
768
+ severity: z7.union([severityInput, z7.array(severityInput).min(1)]).optional(),
769
+ status: z7.union([statusInput, z7.array(statusInput).min(1)]).optional(),
770
+ excludeStatuses: z7.union([statusInput, z7.array(statusInput).min(1)]).optional().describe("Exclude these client-visible statuses."),
771
+ source: z7.union([sourceInput, z7.array(sourceInput).min(1)]).optional(),
772
+ confirmationType: z7.union([confirmationTypeInput, z7.array(confirmationTypeInput).min(1)]).optional(),
773
+ assignee: z7.union([assigneeInput, z7.array(assigneeInput).min(1)]).refine((value) => String(value).length <= 500, {
774
+ message: "Combined assignee filter must be at most 500 characters."
775
+ }).optional().describe(
776
+ "Client user ID(s), or __unassigned__ to include findings without an assignee."
777
+ ),
778
+ cvssMin: z7.number().min(0).max(10).optional(),
779
+ cvssMax: z7.number().min(0).max(10).optional(),
780
+ hideDormant: z7.boolean().optional().describe("Hide findings on dormant assets."),
781
+ search: z7.string().optional().describe("Free-text search on finding titles and human identifiers.")
782
+ };
783
+ var listFindingsInput = z7.object({
784
+ ...findingFilterFields,
785
+ lean: z7.boolean().optional().describe(
786
+ "Exclude heavy evidence from list results; details stay available."
787
+ ),
788
+ stats: z7.boolean().optional().describe("Include aggregate statistics (defaults to true)."),
789
+ sortBy: z7.enum(["severity", "createdAt"]).optional(),
790
+ sortOrder: z7.enum(["asc", "desc"]).optional(),
791
+ limit: z7.number().int().min(1).max(200).optional().default(50),
792
+ offset: z7.number().int().min(0).optional().default(0)
793
+ });
794
+ function serializeFindingQuery(params) {
795
+ return Object.fromEntries(
796
+ Object.entries(params).map(([key, value]) => [
797
+ key,
798
+ Array.isArray(value) ? value.join(",") : typeof value === "boolean" ? String(value) : value
799
+ ])
800
+ );
801
+ }
802
+ var listFindingsTool = {
803
+ name: "list_findings",
804
+ title: "List findings",
805
+ description: "List security findings for the organisation. Filter by pentest, asset, repository, severity, client-visible status, source, confirmation type, assignee, CVSS range, or dormant assets. Search titles or human identifiers such as ODI-12.",
806
+ annotations: readAnnotations,
807
+ inputSchema: listFindingsInput,
808
+ handler: (client) => async (params) => {
809
+ const data = await client.get(
810
+ "/api/findings",
811
+ serializeFindingQuery(params)
812
+ );
813
+ return {
814
+ markdown: formatFindingsList(data),
815
+ structured: {
816
+ findings: data.findings,
817
+ stats: data.stats,
818
+ pagination: data.pagination
819
+ }
820
+ };
821
+ }
822
+ };
823
+
824
+ // src/tools/findings-overview.ts
825
+ var getFindingStatsInput = z8.object({
826
+ pentestId: z8.string().optional()
827
+ });
828
+ var getFindingStatsTool = {
829
+ name: "get_finding_stats",
830
+ title: "Get finding statistics",
831
+ description: "Read aggregate finding totals by severity and status, optionally scoped to a pentest. These totals do not apply other finding list filters.",
832
+ annotations: readAnnotations,
833
+ inputSchema: getFindingStatsInput,
834
+ handler: (client) => async (params) => {
835
+ const data = await client.get(
836
+ "/api/findings/stats",
837
+ params
838
+ );
839
+ return {
840
+ markdown: `Total findings: ${data.stats.total}.
841
+ ${JSON.stringify(data.stats, null, 2)}`,
842
+ structured: data
843
+ };
844
+ }
845
+ };
846
+ var listFindingRepositoriesTool = {
847
+ name: "list_finding_repositories",
848
+ title: "List repositories with findings",
849
+ description: "List organisation repositories available for the repository filter in list_findings.",
850
+ annotations: readAnnotations,
851
+ inputSchema: z8.object({}),
852
+ handler: (client) => async () => {
853
+ const data = await client.get(
854
+ "/api/findings/repositories"
855
+ );
856
+ return {
857
+ markdown: data.repositories.map((repository) => `- ${repository}`).join("\n") || "No repositories with findings.",
858
+ structured: data
859
+ };
860
+ }
861
+ };
862
+ var listFindingAssetsTool = {
863
+ name: "list_finding_assets",
864
+ title: "List assets referenced by findings",
865
+ description: "List organisation assets referenced by findings for use with the assetId filter. This differs from listing all attack-surface assets.",
866
+ annotations: readAnnotations,
867
+ inputSchema: z8.object({}),
868
+ handler: (client) => async () => {
869
+ const data = await client.get(
870
+ "/api/findings/assets"
871
+ );
872
+ return {
873
+ markdown: data.assets.map((asset) => `- ${asset.value} (${asset.type}, ${asset.assetId})`).join("\n") || "No assets referenced by findings.",
874
+ structured: data
875
+ };
876
+ }
877
+ };
878
+ var exportFindingsInput = z8.object(findingFilterFields);
879
+ var exportFindingsTool = {
880
+ name: "export_findings",
881
+ title: "Export findings and revision content",
882
+ description: "Export findings matching the supplied filters, including revision Markdown and affected assets. The API returns all matching rows without pagination; narrow the filters for large organisations. This reads data and creates no file.",
883
+ annotations: readAnnotations,
884
+ inputSchema: exportFindingsInput,
885
+ handler: (client) => async (params) => {
886
+ const data = await client.get(
887
+ "/api/findings/export",
888
+ serializeFindingQuery(params)
889
+ );
890
+ return {
891
+ markdown: `Exported ${data.findings.length} matching finding(s). Revision content is in the structured findings result.`,
892
+ structured: data
893
+ };
894
+ }
895
+ };
896
+ var findingSummariesInput = z8.object({
897
+ findingIds: z8.array(z8.string().trim().min(1)).max(200).describe(
898
+ "Up to 200 internal finding IDs from list_findings. Human identifiers are not accepted by these batch endpoints."
899
+ )
900
+ });
901
+ var getFindingRetestSummariesTool = {
902
+ name: "get_finding_retest_summaries",
903
+ title: "Get latest retests for several findings",
904
+ description: "Read the latest retest summary for up to 200 finding IDs. Requires read permission and the organisation's retest capability; never starts a retest or spends credits. Missing entries have no latest retest.",
905
+ annotations: readAnnotations,
906
+ inputSchema: findingSummariesInput,
907
+ handler: (client) => async (params) => {
908
+ const data = await client.post(
909
+ "/api/findings/retest-summaries",
910
+ params
911
+ );
912
+ return {
913
+ markdown: `Latest retests available for ${Object.keys(data.summaries).length} finding(s).
914
+ ${JSON.stringify(data.summaries, null, 2)}`,
915
+ structured: data
916
+ };
917
+ }
918
+ };
919
+ var getFindingDiscussionSummariesTool = {
920
+ name: "get_finding_discussion_summaries",
921
+ title: "Get discussion summaries for several findings",
922
+ description: "Read message counts and unresolved discussion state for up to 200 finding IDs. API keys have no personal unread state; unreadCount is zero for key-authenticated calls.",
923
+ annotations: readAnnotations,
924
+ inputSchema: findingSummariesInput,
925
+ handler: (client) => async (params) => {
926
+ const data = await client.post(
927
+ "/api/findings/thread-summaries",
928
+ params
929
+ );
930
+ return {
931
+ markdown: `Discussion summaries available for ${Object.keys(data.summaries).length} finding(s).
932
+ ${JSON.stringify(data.summaries, null, 2)}`,
933
+ structured: data
934
+ };
935
+ }
936
+ };
937
+
938
+ // src/tools/additional-tools.ts
939
+ var additionalFindingTools = [
940
+ getFindingStatsTool,
941
+ listFindingRepositoriesTool,
942
+ listFindingAssetsTool,
943
+ exportFindingsTool,
944
+ getFindingRetestSummariesTool,
945
+ getFindingDiscussionSummariesTool,
946
+ assignFindingTool,
947
+ getFindingDiscussionTool,
948
+ createFindingMessageTool,
949
+ editFindingMessageTool,
950
+ deleteFindingMessageTool,
951
+ updateFindingDiscussionTool,
952
+ listFindingMentionablesTool,
953
+ disconnectFindingDiscussionTool,
954
+ prepareFindingAttachmentUploadTool,
955
+ getFindingAttachmentUrlTool,
956
+ getFindingSlackUrlTool,
957
+ listFindingPullRequestsTool,
958
+ unlinkFindingPullRequestTool,
959
+ followFindingTool,
960
+ requestFindingRetestTool
961
+ ];
962
+
963
+ // src/tools/get-asset.ts
964
+ import { z as z9 } from "zod";
965
+ var getAssetInput = z9.object({
966
+ assetId: z9.string().min(1)
967
+ });
968
+ var getAssetTool = {
969
+ name: "get_asset",
970
+ title: "Get asset",
971
+ description: "Fetch a single attack-surface asset by ID, including the findings that reference it in their latest revision.",
972
+ annotations: readAnnotations,
973
+ inputSchema: getAssetInput,
974
+ handler: (client) => async (params) => {
975
+ const data = await client.get(
976
+ `/api/assets/${encodeURIComponent(params.assetId)}`
977
+ );
978
+ return {
979
+ markdown: formatAssetDetail(data),
980
+ structured: { asset: data.asset, findings: data.findings }
981
+ };
982
+ }
983
+ };
984
+
985
+ // src/tools/get-finding.ts
986
+ var getFindingInput = findingReferenceInput;
987
+ var getFindingTool = {
988
+ name: "get_finding",
989
+ title: "Get finding remediation context",
990
+ description: "Fetch a finding by human identifier such as ODI-12 (preferred) or by internal findingId. Returns bounded remediation context, the suggested branch name, linked GitHub pull requests, workflow readiness, and retest state. Odin only provides context and can link an existing PR; it never creates commits, pushes branches, or creates pull requests.",
991
+ annotations: readAnnotations,
992
+ inputSchema: getFindingInput,
993
+ handler: (client) => async (params) => {
994
+ const data = await client.get(
995
+ findingReferencePath(params)
996
+ );
997
+ return {
998
+ markdown: formatFindingDetail(data),
999
+ structured: {
1000
+ finding: data.remediationContext ?? data.finding,
1001
+ revision: data.remediationContext?.revision ?? data.revision,
1002
+ remediationContext: data.remediationContext ?? null
1003
+ }
1004
+ };
1005
+ }
1006
+ };
1007
+
1008
+ // src/tools/get-finding-retests.ts
1009
+ import { z as z10 } from "zod";
1010
+ var getFindingRetestsInput = z10.object(findingReferenceFields).refine((value) => Boolean(value.identifier || value.findingId), {
1011
+ message: "Provide either identifier or findingId."
1012
+ });
1013
+ var getFindingRetestsTool = {
1014
+ name: "get_finding_retests",
1015
+ title: "Get finding retest history",
1016
+ description: "Read the retest history for a finding by human identifier such as ODI-12 or internal findingId. Distinguishes no history, queued or running, completed, skipped, failed, and gated/unavailable results. This tool never triggers a retest or spends credits.",
1017
+ annotations: readAnnotations,
1018
+ inputSchema: getFindingRetestsInput,
1019
+ handler: (client) => async (params) => {
1020
+ try {
1021
+ const data = await client.get(
1022
+ findingReferencePath(params, "/retests")
1023
+ );
1024
+ return {
1025
+ markdown: formatFindingRetests(data),
1026
+ structured: {
1027
+ state: data.state,
1028
+ retests: data.retests
1029
+ }
1030
+ };
1031
+ } catch (error) {
1032
+ if (error instanceof OdinApiError && error.status === 404 && isRetestHistoryUnavailable(error.body)) {
1033
+ const reason = "Retest history is gated or unavailable for this organisation.";
1034
+ return {
1035
+ markdown: reason,
1036
+ structured: { state: "unavailable", reason, retests: [] }
1037
+ };
1038
+ }
1039
+ throw error;
1040
+ }
1041
+ }
1042
+ };
1043
+ function isRetestHistoryUnavailable(body) {
1044
+ return typeof body === "object" && body !== null && "code" in body && body.code === "RETEST_HISTORY_UNAVAILABLE";
1045
+ }
1046
+
1047
+ // src/tools/get-pentest.ts
1048
+ import { z as z11 } from "zod";
1049
+ var getPentestInput = z11.object({
1050
+ pentestId: z11.string().min(1)
1051
+ });
1052
+ var getPentestTool = {
1053
+ name: "get_pentest",
1054
+ title: "Get pentest",
1055
+ description: "Fetch a single pentest by ID, including status, scheduled window, and in-scope targets.",
1056
+ annotations: readAnnotations,
1057
+ inputSchema: getPentestInput,
1058
+ handler: (client) => async (params) => {
1059
+ const data = await client.get(
1060
+ `/api/pentests/${encodeURIComponent(params.pentestId)}`
1061
+ );
1062
+ return {
1063
+ markdown: formatPentestDetail(data),
1064
+ structured: { pentest: data.pentest }
1065
+ };
1066
+ }
1067
+ };
1068
+
1069
+ // src/tools/link-finding-pull-request.ts
1070
+ import { z as z12 } from "zod";
1071
+ var linkFindingPullRequestInput = z12.object({
1072
+ ...findingReferenceFields,
1073
+ url: z12.string().url().describe("Existing GitHub pull request URL to link to the finding.")
1074
+ }).refine((value) => Boolean(value.identifier || value.findingId), {
1075
+ message: "Provide either identifier or findingId."
1076
+ });
1077
+ var linkFindingPullRequestTool = {
1078
+ name: "link_finding_pull_request",
1079
+ title: "Link an existing GitHub pull request",
1080
+ description: "Link an existing accessible GitHub pull request to a finding identified by ODI-12 or findingId. This operation is idempotent. It does not create, modify, merge, or push a pull request, commit, or branch, and it requires an API key with Read & Write permission; cookie-authenticated callers also need member role. Ask the user before linking.",
1081
+ annotations: { ...writeAnnotations, idempotentHint: true },
1082
+ inputSchema: linkFindingPullRequestInput,
1083
+ handler: (client) => async (params) => {
1084
+ const data = await client.post(
1085
+ findingReferencePath(params, "/link-pr"),
1086
+ { url: params.url }
1087
+ );
1088
+ return {
1089
+ markdown: formatFindingPullRequestLink(data),
1090
+ structured: {
1091
+ finding: data.finding,
1092
+ pullRequest: data.pullRequest,
1093
+ linkSource: data.linkSource,
1094
+ workflow: data.workflow
1095
+ }
1096
+ };
1097
+ }
1098
+ };
1099
+
1100
+ // src/tools/list-assets.ts
1101
+ import { z as z13 } from "zod";
1102
+ var listAssetsInput = z13.object({
1103
+ type: z13.enum(["DOMAIN", "SUBDOMAIN", "IP"]).optional(),
1104
+ source: z13.enum(["MANUAL", "RECON", "IMPORT", "AGENT"]).optional(),
1105
+ status: z13.string().optional().describe(
1106
+ "Dynamic asset status set by recon (e.g. ACTIVE, INACTIVE, TLS_ERROR)."
1107
+ ),
1108
+ search: z13.string().optional(),
1109
+ stats: z13.boolean().optional().describe("Include aggregate statistics (defaults to true)."),
1110
+ sortBy: z13.enum(["lastSeenAt", "createdAt", "value"]).optional(),
1111
+ sortOrder: z13.enum(["asc", "desc"]).optional(),
1112
+ limit: z13.number().int().min(1).max(100).optional().default(50),
1113
+ offset: z13.number().int().min(0).optional().default(0)
1114
+ });
1115
+ var listAssetsTool = {
1116
+ name: "list_assets",
1117
+ title: "List assets",
1118
+ description: "List attack-surface assets for the organisation. Supports filtering by type, source, status, and free-text search.",
1119
+ annotations: readAnnotations,
1120
+ inputSchema: listAssetsInput,
1121
+ handler: (client) => async (params) => {
1122
+ const data = await client.get("/api/assets", {
1123
+ ...params,
1124
+ stats: params.stats === void 0 ? void 0 : String(params.stats)
1125
+ });
1126
+ return {
1127
+ markdown: formatAssetsList(data),
1128
+ structured: {
1129
+ assets: data.assets,
1130
+ stats: data.stats,
1131
+ pagination: data.pagination
1132
+ }
1133
+ };
1134
+ }
1135
+ };
1136
+
1137
+ // src/tools/list-pentests.ts
1138
+ import { z as z14 } from "zod";
1139
+ var listPentestsInput = z14.object({});
1140
+ var listPentestsTool = {
1141
+ name: "list_pentests",
1142
+ title: "List pentests",
1143
+ description: "List all pentests for the organisation, including status, scheduled window, and in-scope targets.",
1144
+ annotations: readAnnotations,
1145
+ inputSchema: listPentestsInput,
1146
+ handler: (client) => async () => {
1147
+ const data = await client.get("/api/pentests");
1148
+ return {
1149
+ markdown: formatPentestsList(data),
1150
+ structured: { pentests: data.pentests }
1151
+ };
1152
+ }
1153
+ };
1154
+
1155
+ // src/tools/update-finding-status.ts
1156
+ import { z as z15 } from "zod";
1157
+ var updateFindingStatusInput = z15.object({
1158
+ ...findingReferenceFields,
1159
+ status: z15.enum(["MITIGATING", "OPEN_FOR_RETEST", "ACKNOWLEDGED"]).describe(
1160
+ "Target status. MITIGATING (shown in Odin as Fixing) means work has started, OPEN_FOR_RETEST means the fix is ready for verification, and ACKNOWLEDGED (shown as Risk accepted) means the organisation accepts the risk for now."
1161
+ )
1162
+ }).refine((value) => Boolean(value.identifier || value.findingId), {
1163
+ message: "Provide either identifier or findingId."
1164
+ });
1165
+ var updateFindingStatusTool = {
1166
+ name: "update_finding_status",
1167
+ title: "Update finding status",
1168
+ description: "Change a finding's status by human identifier such as ODI-12 or internal findingId. Requires an API key with Read & Write permission; cookie-authenticated callers also need member role. Only use this after the user explicitly asks for the status change; a matching pull request is not permission to mutate a finding.",
1169
+ annotations: { ...writeAnnotations, idempotentHint: true },
1170
+ inputSchema: updateFindingStatusInput,
1171
+ handler: (client) => async (params) => {
1172
+ const findingId = await resolveFindingId(client, params);
1173
+ const data = await client.patch(
1174
+ `/api/findings/${encodeURIComponent(findingId)}/status`,
1175
+ { status: params.status }
1176
+ );
1177
+ return {
1178
+ markdown: formatFindingStatusUpdate(data.finding),
1179
+ structured: { finding: data.finding }
1180
+ };
1181
+ }
1182
+ };
1183
+
1184
+ // src/tools.ts
1185
+ var definitions = [
1186
+ listFindingsTool,
1187
+ getFindingTool,
1188
+ updateFindingStatusTool,
1189
+ linkFindingPullRequestTool,
1190
+ getFindingRetestsTool,
1191
+ listPentestsTool,
1192
+ getPentestTool,
1193
+ listAssetsTool,
1194
+ getAssetTool,
1195
+ ...additionalFindingTools
1196
+ ];
1197
+ var ODIN_TOOLS = definitions.map((tool) => ({
1198
+ name: tool.name,
1199
+ title: tool.title,
1200
+ description: tool.description,
1201
+ inputSchema: tool.inputSchema,
1202
+ annotations: tool.annotations,
1203
+ run: (client, params) => tool.handler(client)(
1204
+ params
1205
+ )
1206
+ }));
1207
+
1208
+ export {
1209
+ ODIN_TOOLS
1210
+ };
1211
+ //# sourceMappingURL=chunk-D5FYYTYV.js.map