@borgresearch/odin-mcp 0.2.0 → 0.3.1
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/README.md +91 -16
- package/dist/chunk-4LCKW236.js +205 -0
- package/dist/chunk-4LCKW236.js.map +1 -0
- package/dist/chunk-D5FYYTYV.js +1211 -0
- package/dist/chunk-D5FYYTYV.js.map +1 -0
- package/dist/chunk-MHMO7K4D.js +120 -0
- package/dist/chunk-MHMO7K4D.js.map +1 -0
- package/dist/chunk-XHJYVUHG.js +195 -0
- package/dist/chunk-XHJYVUHG.js.map +1 -0
- package/dist/client.d.ts +39 -0
- package/dist/client.js +12 -0
- package/dist/client.js.map +1 -0
- package/dist/guidance.d.ts +53 -0
- package/dist/guidance.js +18 -0
- package/dist/guidance.js.map +1 -0
- package/dist/http.d.ts +8 -0
- package/dist/http.js +34 -0
- package/dist/http.js.map +1 -0
- package/dist/index.js +15 -782
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +11 -0
- package/dist/server.js +13 -0
- package/dist/server.js.map +1 -0
- package/dist/tools.d.ts +29 -0
- package/dist/tools.js +9 -0
- package/dist/tools.js.map +1 -0
- package/package.json +28 -6
package/dist/index.js
CHANGED
|
@@ -1,737 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
PACKAGE_VERSION,
|
|
4
|
+
createOdinMcpServer
|
|
5
|
+
} from "./chunk-MHMO7K4D.js";
|
|
6
|
+
import "./chunk-4LCKW236.js";
|
|
7
|
+
import "./chunk-D5FYYTYV.js";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_ODIN_BASE_URL,
|
|
10
|
+
OdinClient
|
|
11
|
+
} from "./chunk-XHJYVUHG.js";
|
|
2
12
|
|
|
3
13
|
// src/index.ts
|
|
4
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
14
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
-
|
|
7
|
-
// src/client.ts
|
|
8
|
-
var OdinApiError = class extends Error {
|
|
9
|
-
status;
|
|
10
|
-
path;
|
|
11
|
-
method;
|
|
12
|
-
retryAfterSeconds;
|
|
13
|
-
body;
|
|
14
|
-
constructor(params) {
|
|
15
|
-
super(params.message);
|
|
16
|
-
this.name = "OdinApiError";
|
|
17
|
-
this.status = params.status;
|
|
18
|
-
this.path = params.path;
|
|
19
|
-
this.method = params.method;
|
|
20
|
-
this.retryAfterSeconds = params.retryAfterSeconds;
|
|
21
|
-
this.body = params.body;
|
|
22
|
-
}
|
|
23
|
-
};
|
|
24
|
-
var DEFAULT_ODIN_BASE_URL = "https://odin.borghq.io";
|
|
25
|
-
var WRITE_METHODS = /* @__PURE__ */ new Set(["PATCH", "POST", "PUT", "DELETE"]);
|
|
26
|
-
var OdinClient = class {
|
|
27
|
-
baseUrl;
|
|
28
|
-
apiKey;
|
|
29
|
-
fetchImpl;
|
|
30
|
-
vercelProtectionBypassSecret;
|
|
31
|
-
constructor(options) {
|
|
32
|
-
if (!options.apiKey) {
|
|
33
|
-
throw new Error("OdinClient requires an apiKey");
|
|
34
|
-
}
|
|
35
|
-
this.apiKey = options.apiKey;
|
|
36
|
-
this.baseUrl = (options.baseUrl ?? DEFAULT_ODIN_BASE_URL).replace(
|
|
37
|
-
/\/+$/,
|
|
38
|
-
""
|
|
39
|
-
);
|
|
40
|
-
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
41
|
-
this.vercelProtectionBypassSecret = options.vercelProtectionBypassSecret?.trim() || void 0;
|
|
42
|
-
}
|
|
43
|
-
async get(path, searchParams) {
|
|
44
|
-
const url = this.buildUrl(path, searchParams);
|
|
45
|
-
return this.request("GET", url, path);
|
|
46
|
-
}
|
|
47
|
-
async patch(path, body) {
|
|
48
|
-
const url = this.buildUrl(path);
|
|
49
|
-
return this.request("PATCH", url, path, body);
|
|
50
|
-
}
|
|
51
|
-
// Public HTTP method API — kept alongside get/patch even when unused internally
|
|
52
|
-
// fallow-ignore-next-line unused-class-member
|
|
53
|
-
async post(path, body) {
|
|
54
|
-
const url = this.buildUrl(path);
|
|
55
|
-
return this.request("POST", url, path, body);
|
|
56
|
-
}
|
|
57
|
-
buildUrl(path, searchParams) {
|
|
58
|
-
const url = new URL(path.startsWith("/") ? path : `/${path}`, this.baseUrl);
|
|
59
|
-
if (searchParams) {
|
|
60
|
-
for (const [key, value] of Object.entries(searchParams)) {
|
|
61
|
-
if (value === void 0 || value === "") continue;
|
|
62
|
-
url.searchParams.set(key, String(value));
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return url.toString();
|
|
66
|
-
}
|
|
67
|
-
async request(method, url, path, body) {
|
|
68
|
-
const headers = {
|
|
69
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
70
|
-
"Content-Type": "application/json",
|
|
71
|
-
Accept: "application/json"
|
|
72
|
-
};
|
|
73
|
-
if (this.vercelProtectionBypassSecret) {
|
|
74
|
-
headers["x-vercel-protection-bypass"] = this.vercelProtectionBypassSecret;
|
|
75
|
-
}
|
|
76
|
-
const response = await this.fetchImpl(url, {
|
|
77
|
-
method,
|
|
78
|
-
headers,
|
|
79
|
-
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
80
|
-
});
|
|
81
|
-
const rawText = await response.text();
|
|
82
|
-
const parsed = rawText ? safeJsonParse(rawText) : null;
|
|
83
|
-
if (!response.ok) {
|
|
84
|
-
const retryAfterSeconds = parseRetryAfter(
|
|
85
|
-
response.headers.get("Retry-After")
|
|
86
|
-
);
|
|
87
|
-
throw new OdinApiError({
|
|
88
|
-
status: response.status,
|
|
89
|
-
path,
|
|
90
|
-
method,
|
|
91
|
-
retryAfterSeconds,
|
|
92
|
-
body: parsed ?? rawText,
|
|
93
|
-
message: formatErrorMessage({
|
|
94
|
-
status: response.status,
|
|
95
|
-
method,
|
|
96
|
-
path,
|
|
97
|
-
retryAfterSeconds,
|
|
98
|
-
parsed,
|
|
99
|
-
rawText
|
|
100
|
-
})
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
return parsed;
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
function safeJsonParse(text) {
|
|
107
|
-
try {
|
|
108
|
-
return JSON.parse(text);
|
|
109
|
-
} catch {
|
|
110
|
-
return text;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
function parseRetryAfter(header) {
|
|
114
|
-
if (!header) return null;
|
|
115
|
-
const trimmed = header.trim();
|
|
116
|
-
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
|
|
117
|
-
const dateMs = Date.parse(trimmed);
|
|
118
|
-
if (Number.isNaN(dateMs)) return null;
|
|
119
|
-
const seconds = Math.ceil((dateMs - Date.now()) / 1e3);
|
|
120
|
-
return seconds > 0 ? seconds : null;
|
|
121
|
-
}
|
|
122
|
-
function formatErrorMessage(input) {
|
|
123
|
-
const serverMessage = extractServerMessage(input.parsed);
|
|
124
|
-
const actionable = actionableMessage(input);
|
|
125
|
-
if (actionable) {
|
|
126
|
-
return serverMessage ? `${actionable} (server: ${serverMessage})` : actionable;
|
|
127
|
-
}
|
|
128
|
-
const suffix = serverMessage ? ` \u2014 ${serverMessage}` : input.rawText ? ` \u2014 ${input.rawText.slice(0, 200)}` : "";
|
|
129
|
-
return `Odin API ${input.status} on ${input.path}${suffix}.`;
|
|
130
|
-
}
|
|
131
|
-
function actionableMessage(input) {
|
|
132
|
-
switch (input.status) {
|
|
133
|
-
case 401:
|
|
134
|
-
return "ODIN_API_KEY is not set or invalid. Create one at Management > API Keys in the Odin dashboard.";
|
|
135
|
-
case 403:
|
|
136
|
-
if (WRITE_METHODS.has(input.method)) {
|
|
137
|
-
return "API key does not have write permission. Create a new key with 'Read & Write' permissions.";
|
|
138
|
-
}
|
|
139
|
-
return null;
|
|
140
|
-
case 404:
|
|
141
|
-
if (isFindingDetailPath(input.path)) {
|
|
142
|
-
return "Finding not found. Check the finding ID is correct.";
|
|
143
|
-
}
|
|
144
|
-
return null;
|
|
145
|
-
case 429: {
|
|
146
|
-
const n = input.retryAfterSeconds;
|
|
147
|
-
return n ? `Rate limit exceeded. Try again in ${n} seconds.` : "Rate limit exceeded. Try again shortly.";
|
|
148
|
-
}
|
|
149
|
-
default:
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
function isFindingDetailPath(path) {
|
|
154
|
-
return /^\/api\/findings\/[^/]+(\/.*)?$/.test(path);
|
|
155
|
-
}
|
|
156
|
-
function extractServerMessage(parsed) {
|
|
157
|
-
if (parsed && typeof parsed === "object" && "error" in parsed) {
|
|
158
|
-
const err = parsed.error;
|
|
159
|
-
if (typeof err === "string") return err;
|
|
160
|
-
}
|
|
161
|
-
return null;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// src/tools/get-asset.ts
|
|
165
|
-
import { z } from "zod";
|
|
166
|
-
|
|
167
|
-
// src/formatters/markdown.ts
|
|
168
|
-
var STATUS_LABELS = {
|
|
169
|
-
REPORTED: "Reported",
|
|
170
|
-
ACKNOWLEDGED: "Acknowledged",
|
|
171
|
-
MITIGATING: "Mitigating",
|
|
172
|
-
OPEN_FOR_RETEST: "Open for Retest",
|
|
173
|
-
NEEDS_REVISION: "Needs Revision",
|
|
174
|
-
FIXED_AND_RETESTED: "Fixed & Retested"
|
|
175
|
-
};
|
|
176
|
-
var RETEST_STATUS_LABELS = {
|
|
177
|
-
QUEUED: "Queued",
|
|
178
|
-
RUNNING: "Running",
|
|
179
|
-
COMPLETED: "Completed",
|
|
180
|
-
FAILED: "Failed",
|
|
181
|
-
SKIPPED: "Skipped"
|
|
182
|
-
};
|
|
183
|
-
function formatFindingsList(data) {
|
|
184
|
-
const { findings, stats, pagination } = data;
|
|
185
|
-
if (findings.length === 0) {
|
|
186
|
-
return "No findings match the supplied filters.";
|
|
187
|
-
}
|
|
188
|
-
const rows = findings.map((f) => {
|
|
189
|
-
const severity = `${f.severity.label} (CVSS ${f.severity.cvss.toFixed(1)})`;
|
|
190
|
-
return `| ${f.identifier ?? "\u2014"} | ${f.findingId} | ${escapePipes(f.title)} | ${severity} | ${formatStatus(f.status)} | ${formatDate(f.createdAt)} |`;
|
|
191
|
-
});
|
|
192
|
-
return [
|
|
193
|
-
`Showing ${findings.length} of ${stats.total} finding(s). Offset ${pagination.offset}, limit ${pagination.limit}. hasMore=${pagination.hasMore}.`,
|
|
194
|
-
"",
|
|
195
|
-
"| Identifier | Internal ID | Title | Severity | Status | Created |",
|
|
196
|
-
"| --- | --- | --- | --- | --- | --- |",
|
|
197
|
-
...rows
|
|
198
|
-
].join("\n");
|
|
199
|
-
}
|
|
200
|
-
function formatFindingDetail(data) {
|
|
201
|
-
const { finding, revision } = data;
|
|
202
|
-
const context = data.remediationContext;
|
|
203
|
-
const identifier = context?.identifier ?? finding.identifier ?? null;
|
|
204
|
-
const displayIdentifier = identifier ?? finding.findingId;
|
|
205
|
-
const severity = `${finding.severity.label} (CVSS ${finding.severity.cvss.toFixed(1)})`;
|
|
206
|
-
const header = [
|
|
207
|
-
`# ${escapePipes(displayIdentifier)} \u2014 ${escapePipes(finding.title)}`,
|
|
208
|
-
"",
|
|
209
|
-
`- **Finding:** ${displayIdentifier}`,
|
|
210
|
-
`- **Internal ID:** ${finding.findingId}`,
|
|
211
|
-
`- **Severity:** ${severity}`,
|
|
212
|
-
`- **Status:** ${formatStatus(finding.status)}`,
|
|
213
|
-
`- **Repository:** ${context?.repository ?? finding.repository ?? "Not recorded"}`,
|
|
214
|
-
`- **Pentest:** ${finding.context?.pentestId ?? "standalone"}`,
|
|
215
|
-
`- **Created:** ${formatDate(finding.createdAt)}`,
|
|
216
|
-
`- **Updated:** ${formatDate(finding.updatedAt)}`
|
|
217
|
-
];
|
|
218
|
-
const content = context ? {
|
|
219
|
-
revisionNumber: context.revision.revisionNumber,
|
|
220
|
-
summary: context.revision.summary,
|
|
221
|
-
description: context.revision.description,
|
|
222
|
-
impact: context.revision.impact,
|
|
223
|
-
details: context.revision.details,
|
|
224
|
-
remediation: context.revision.remediation,
|
|
225
|
-
affectedAssets: context.revision.affectedAssets,
|
|
226
|
-
codeReference: context.revision.codeReference
|
|
227
|
-
} : revision ? {
|
|
228
|
-
revisionNumber: revision.revisionNumber,
|
|
229
|
-
summary: revision.content.summaryMd,
|
|
230
|
-
description: revision.content.descriptionMd,
|
|
231
|
-
impact: revision.content.businessImpactMd,
|
|
232
|
-
details: revision.content.detailsMd,
|
|
233
|
-
remediation: revision.content.remediationMd,
|
|
234
|
-
affectedAssets: revision.content.affectedAssets ?? [],
|
|
235
|
-
codeReference: null
|
|
236
|
-
} : null;
|
|
237
|
-
const sections = [];
|
|
238
|
-
if (!content) {
|
|
239
|
-
sections.push("_No revision content available._");
|
|
240
|
-
} else {
|
|
241
|
-
sections.push(`_Revision ${content.revisionNumber ?? "unknown"}_`);
|
|
242
|
-
if (content.summary) sections.push("## Summary", content.summary);
|
|
243
|
-
if (content.description)
|
|
244
|
-
sections.push("## Description", content.description);
|
|
245
|
-
if (content.impact) sections.push("## Business Impact", content.impact);
|
|
246
|
-
if (content.details) sections.push("## Details", content.details);
|
|
247
|
-
if (content.remediation)
|
|
248
|
-
sections.push("## Remediation", content.remediation);
|
|
249
|
-
if (content.codeReference) {
|
|
250
|
-
sections.push(
|
|
251
|
-
"## Code reference",
|
|
252
|
-
`- ${content.codeReference.file}:${content.codeReference.line}`
|
|
253
|
-
);
|
|
254
|
-
if (content.codeReference.snippet) {
|
|
255
|
-
sections.push("```", content.codeReference.snippet, "```");
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
if (content.affectedAssets?.length) {
|
|
259
|
-
sections.push(
|
|
260
|
-
"## Affected Assets",
|
|
261
|
-
content.affectedAssets.map((asset) => `- ${asset.display} (${asset.assetId})`).join("\n")
|
|
262
|
-
);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
if (context) {
|
|
266
|
-
sections.push(
|
|
267
|
-
"## Remediation workflow",
|
|
268
|
-
`- **Suggested branch:** ${context.suggestedBranch ?? "Not available"}`,
|
|
269
|
-
`- **Ready for a user-created PR:** ${yesNo(context.workflow.readyForUserCreatedPullRequest)}`,
|
|
270
|
-
`- **GitHub connected:** ${yesNo(context.workflow.githubConnected)}`,
|
|
271
|
-
`- **Automatic retests:** ${context.workflow.automaticRetestsReady ? "Ready after merge" : "Not ready"}`
|
|
272
|
-
);
|
|
273
|
-
if (context.workflow.blockers.length) {
|
|
274
|
-
sections.push(
|
|
275
|
-
"### Blockers",
|
|
276
|
-
context.workflow.blockers.map((blocker) => `- ${blocker}`).join("\n")
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
if (context.workflow.nextSteps.length) {
|
|
280
|
-
sections.push(
|
|
281
|
-
"### Next steps",
|
|
282
|
-
context.workflow.nextSteps.map((step) => `- ${step}`).join("\n")
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
if (context.linkedPullRequests.length) {
|
|
286
|
-
sections.push(
|
|
287
|
-
"## Linked GitHub pull requests",
|
|
288
|
-
context.linkedPullRequests.map(
|
|
289
|
-
(pullRequest) => `- [#${pullRequest.number}](${pullRequest.url}) ${escapePipes(pullRequest.title)} \u2014 ${pullRequest.state}, linked via ${pullRequest.linkedVia}`
|
|
290
|
-
).join("\n")
|
|
291
|
-
);
|
|
292
|
-
} else {
|
|
293
|
-
sections.push("## Linked GitHub pull requests", "_None linked._");
|
|
294
|
-
}
|
|
295
|
-
sections.push("## Latest retest");
|
|
296
|
-
if (context.latestRetest.state === "unavailable") {
|
|
297
|
-
sections.push(`_${context.latestRetest.reason}_`);
|
|
298
|
-
} else if (context.latestRetest.state === "none") {
|
|
299
|
-
sections.push("_No retest history is available yet._");
|
|
300
|
-
} else {
|
|
301
|
-
sections.push(formatRetestSummary(context.latestRetest.retest));
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
return [...header, "", ...sections].join("\n");
|
|
305
|
-
}
|
|
306
|
-
function formatFindingStatusUpdate(finding) {
|
|
307
|
-
const identifier = finding.identifier ?? finding.findingId;
|
|
308
|
-
return [
|
|
309
|
-
`Updated finding **${identifier}** (internal ID ${finding.findingId}) status to **${formatStatus(finding.status)}**.`,
|
|
310
|
-
`Title: ${finding.title}`
|
|
311
|
-
].join("\n");
|
|
312
|
-
}
|
|
313
|
-
function formatFindingPullRequestLink(data) {
|
|
314
|
-
const identifier = data.finding.identifier ?? data.finding.findingId;
|
|
315
|
-
const pullRequest = data.pullRequest;
|
|
316
|
-
const readiness = data.workflow.automaticRetestsReady ? "Automatic retesting is ready after this PR merges." : "Automatic retesting is not currently ready; review the blockers below.";
|
|
317
|
-
const blockers = data.workflow.blockers.length ? `
|
|
318
|
-
${data.workflow.blockers.map((blocker) => `- ${blocker}`).join("\n")}` : "";
|
|
319
|
-
return [
|
|
320
|
-
`Linked existing GitHub pull request [#${pullRequest.number}](${pullRequest.url}) to **${identifier}**.`,
|
|
321
|
-
`Repository: ${pullRequest.repo}; state: ${pullRequest.state}; finding status: ${formatStatus(data.finding.status)}.`,
|
|
322
|
-
"Odin did not create, modify, merge, or push this pull request.",
|
|
323
|
-
readiness,
|
|
324
|
-
blockers ? `Workflow blockers:${blockers}` : ""
|
|
325
|
-
].filter(Boolean).join("\n");
|
|
326
|
-
}
|
|
327
|
-
function formatFindingRetests(data) {
|
|
328
|
-
if (data.state === "none" || data.retests.length === 0) {
|
|
329
|
-
return "No retest history is available for this finding.";
|
|
330
|
-
}
|
|
331
|
-
return [
|
|
332
|
-
`Retest history (${data.retests.length}):`,
|
|
333
|
-
"",
|
|
334
|
-
"| Retest | Status | Trigger | Verdict | Created | Completed |",
|
|
335
|
-
"| --- | --- | --- | --- | --- | --- |",
|
|
336
|
-
...data.retests.map(
|
|
337
|
-
(retest) => `| ${retest.retestId} | ${RETEST_STATUS_LABELS[retest.status]} | ${retest.trigger} | ${retest.combinedVerdict ?? "\u2014"} | ${formatDate(retest.createdAt)} | ${formatDate(retest.completedAt ?? 0)} |`
|
|
338
|
-
),
|
|
339
|
-
"",
|
|
340
|
-
"Queued and running entries are still in progress; completed, skipped, and failed entries are final for that run."
|
|
341
|
-
].join("\n");
|
|
342
|
-
}
|
|
343
|
-
function formatPentestsList(data) {
|
|
344
|
-
if (data.pentests.length === 0) return "No pentests found.";
|
|
345
|
-
const rows = data.pentests.map(formatPentestRow);
|
|
346
|
-
return [
|
|
347
|
-
"| Pentest ID | Name | Status | Start | End | Scope |",
|
|
348
|
-
"| --- | --- | --- | --- | --- | --- |",
|
|
349
|
-
...rows
|
|
350
|
-
].join("\n");
|
|
351
|
-
}
|
|
352
|
-
function formatPentestDetail(data) {
|
|
353
|
-
const p = data.pentest;
|
|
354
|
-
const end = p.endAt ? formatDate(p.endAt) : "open";
|
|
355
|
-
const scope = p.scope.length ? p.scope.map((s) => `- ${s}`).join("\n") : "_No scope defined._";
|
|
356
|
-
return [
|
|
357
|
-
`# ${p.name}`,
|
|
358
|
-
"",
|
|
359
|
-
`- **ID:** ${p.pentestId}`,
|
|
360
|
-
`- **Status:** ${p.status}`,
|
|
361
|
-
`- **Start:** ${formatDate(p.startAt)}`,
|
|
362
|
-
`- **End:** ${end}`,
|
|
363
|
-
`- **Created:** ${formatDate(p.createdAt)}`,
|
|
364
|
-
"",
|
|
365
|
-
"## Scope",
|
|
366
|
-
"",
|
|
367
|
-
scope
|
|
368
|
-
].join("\n");
|
|
369
|
-
}
|
|
370
|
-
function formatFindingsForAsset(findings) {
|
|
371
|
-
if (findings.length === 0) return "_No findings reference this asset._";
|
|
372
|
-
return [
|
|
373
|
-
`## Findings (${findings.length})`,
|
|
374
|
-
"",
|
|
375
|
-
"| Identifier | Internal ID | Title | Severity | Status | Created |",
|
|
376
|
-
"| --- | --- | --- | --- | --- | --- |",
|
|
377
|
-
...findings.map(formatFindingRow)
|
|
378
|
-
].join("\n");
|
|
379
|
-
}
|
|
380
|
-
function formatAssetsList(data) {
|
|
381
|
-
const { assets, stats, pagination } = data;
|
|
382
|
-
if (assets.length === 0) return "No assets match the supplied filters.";
|
|
383
|
-
const rows = assets.map(formatAssetRow);
|
|
384
|
-
return [
|
|
385
|
-
`Showing ${assets.length} of ${stats.total} asset(s). Offset ${pagination.offset}, limit ${pagination.limit}. hasMore=${pagination.hasMore}.`,
|
|
386
|
-
"",
|
|
387
|
-
"| Asset ID | Type | Value | Source | Status | Last seen |",
|
|
388
|
-
"| --- | --- | --- | --- | --- | --- |",
|
|
389
|
-
...rows
|
|
390
|
-
].join("\n");
|
|
391
|
-
}
|
|
392
|
-
function formatAssetDetail(data) {
|
|
393
|
-
const { asset, findings } = data;
|
|
394
|
-
const header = [
|
|
395
|
-
`# ${asset.value}`,
|
|
396
|
-
"",
|
|
397
|
-
`- **ID:** ${asset.assetId}`,
|
|
398
|
-
`- **Type:** ${asset.type}`,
|
|
399
|
-
`- **Source:** ${asset.source}`,
|
|
400
|
-
`- **Status:** ${asset.status}`,
|
|
401
|
-
`- **Last seen:** ${formatDate(asset.lastSeenAt)}`,
|
|
402
|
-
`- **Created:** ${formatDate(asset.createdAt)}`
|
|
403
|
-
];
|
|
404
|
-
return [...header, "", formatFindingsForAsset(findings)].join("\n");
|
|
405
|
-
}
|
|
406
|
-
function formatFindingRow(finding) {
|
|
407
|
-
const severity = `${finding.severity.label} (CVSS ${finding.severity.cvss.toFixed(1)})`;
|
|
408
|
-
return `| ${finding.identifier ?? "\u2014"} | ${finding.findingId} | ${escapePipes(finding.title)} | ${severity} | ${formatStatus(finding.status)} | ${formatDate(finding.createdAt)} |`;
|
|
409
|
-
}
|
|
410
|
-
function formatPentestRow(p) {
|
|
411
|
-
const end = p.endAt ? formatDate(p.endAt) : "open";
|
|
412
|
-
const scope = p.scope.slice(0, 3).join(", ") + (p.scope.length > 3 ? ", \u2026" : "");
|
|
413
|
-
return `| ${p.pentestId} | ${escapePipes(p.name)} | ${p.status} | ${formatDate(p.startAt)} | ${end} | ${escapePipes(scope)} |`;
|
|
414
|
-
}
|
|
415
|
-
function formatAssetRow(a) {
|
|
416
|
-
return `| ${a.assetId} | ${a.type} | ${escapePipes(a.value)} | ${a.source} | ${a.status} | ${formatDate(a.lastSeenAt)} |`;
|
|
417
|
-
}
|
|
418
|
-
function formatRetestSummary(retest) {
|
|
419
|
-
return [
|
|
420
|
-
`- **Status:** ${RETEST_STATUS_LABELS[retest.status]}`,
|
|
421
|
-
`- **Trigger:** ${retest.trigger}`,
|
|
422
|
-
`- **Verdict:** ${retest.combinedVerdict ?? "Not available yet"}`,
|
|
423
|
-
`- **Finding action:** ${retest.statusAction ?? "None"}`,
|
|
424
|
-
`- **Created:** ${formatDate(retest.createdAt)}`,
|
|
425
|
-
`- **Completed:** ${formatDate(retest.completedAt ?? 0)}`
|
|
426
|
-
].join("\n");
|
|
427
|
-
}
|
|
428
|
-
function formatStatus(status) {
|
|
429
|
-
return STATUS_LABELS[status] ?? status;
|
|
430
|
-
}
|
|
431
|
-
function yesNo(value) {
|
|
432
|
-
return value ? "Yes" : "No";
|
|
433
|
-
}
|
|
434
|
-
function escapePipes(value) {
|
|
435
|
-
return value.replace(/\|/g, "\\|");
|
|
436
|
-
}
|
|
437
|
-
function formatDate(epoch) {
|
|
438
|
-
if (!epoch) return "\u2014";
|
|
439
|
-
const ms = epoch < 1e12 ? epoch * 1e3 : epoch;
|
|
440
|
-
return new Date(ms).toISOString().slice(0, 10);
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// src/tools/get-asset.ts
|
|
444
|
-
var getAssetInput = z.object({
|
|
445
|
-
assetId: z.string().min(1)
|
|
446
|
-
});
|
|
447
|
-
var getAssetTool = {
|
|
448
|
-
name: "get_asset",
|
|
449
|
-
title: "Get asset",
|
|
450
|
-
description: "Fetch a single attack-surface asset by ID, including the findings that reference it in their latest revision.",
|
|
451
|
-
inputSchema: getAssetInput,
|
|
452
|
-
handler: (client) => async (params) => {
|
|
453
|
-
const data = await client.get(
|
|
454
|
-
`/api/assets/${encodeURIComponent(params.assetId)}`
|
|
455
|
-
);
|
|
456
|
-
return {
|
|
457
|
-
markdown: formatAssetDetail(data),
|
|
458
|
-
structured: { asset: data.asset, findings: data.findings }
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
};
|
|
462
|
-
|
|
463
|
-
// src/tools/finding-reference.ts
|
|
464
|
-
import { z as z2 } from "zod";
|
|
465
|
-
var findingReferenceFields = {
|
|
466
|
-
identifier: z2.string().trim().min(1).optional().describe(
|
|
467
|
-
'Human-readable finding identifier such as "ODI-12". Preferred input.'
|
|
468
|
-
),
|
|
469
|
-
findingId: z2.string().trim().min(1).optional().describe(
|
|
470
|
-
"Raw internal finding ID, useful when chaining from another tool."
|
|
471
|
-
)
|
|
472
|
-
};
|
|
473
|
-
var findingReferenceInput = z2.object(findingReferenceFields).refine((value) => Boolean(value.identifier || value.findingId), {
|
|
474
|
-
message: "Provide either identifier or findingId."
|
|
475
|
-
});
|
|
476
|
-
function findingReferenceValue(reference) {
|
|
477
|
-
const value = reference.identifier ?? reference.findingId;
|
|
478
|
-
if (!value) {
|
|
479
|
-
throw new Error("A finding identifier or findingId is required.");
|
|
480
|
-
}
|
|
481
|
-
return value;
|
|
482
|
-
}
|
|
483
|
-
function findingReferencePath(reference, suffix = "") {
|
|
484
|
-
return `/api/findings/${encodeURIComponent(findingReferenceValue(reference))}${suffix}`;
|
|
485
|
-
}
|
|
486
|
-
async function resolveFindingId(client, reference) {
|
|
487
|
-
if (reference.findingId) return reference.findingId;
|
|
488
|
-
const data = await client.get(
|
|
489
|
-
findingReferencePath(reference)
|
|
490
|
-
);
|
|
491
|
-
return data.remediationContext?.findingId ?? data.finding.findingId;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// src/tools/get-finding.ts
|
|
495
|
-
var getFindingInput = findingReferenceInput;
|
|
496
|
-
var getFindingTool = {
|
|
497
|
-
name: "get_finding",
|
|
498
|
-
title: "Get finding remediation context",
|
|
499
|
-
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.",
|
|
500
|
-
inputSchema: getFindingInput,
|
|
501
|
-
handler: (client) => async (params) => {
|
|
502
|
-
const data = await client.get(
|
|
503
|
-
findingReferencePath(params)
|
|
504
|
-
);
|
|
505
|
-
return {
|
|
506
|
-
markdown: formatFindingDetail(data),
|
|
507
|
-
structured: {
|
|
508
|
-
finding: data.remediationContext ?? data.finding,
|
|
509
|
-
revision: data.remediationContext?.revision ?? data.revision,
|
|
510
|
-
remediationContext: data.remediationContext ?? null
|
|
511
|
-
}
|
|
512
|
-
};
|
|
513
|
-
}
|
|
514
|
-
};
|
|
515
|
-
|
|
516
|
-
// src/tools/get-finding-retests.ts
|
|
517
|
-
import { z as z3 } from "zod";
|
|
518
|
-
var getFindingRetestsInput = z3.object(findingReferenceFields).refine((value) => Boolean(value.identifier || value.findingId), {
|
|
519
|
-
message: "Provide either identifier or findingId."
|
|
520
|
-
});
|
|
521
|
-
var getFindingRetestsTool = {
|
|
522
|
-
name: "get_finding_retests",
|
|
523
|
-
title: "Get finding retest history",
|
|
524
|
-
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.",
|
|
525
|
-
inputSchema: getFindingRetestsInput,
|
|
526
|
-
handler: (client) => async (params) => {
|
|
527
|
-
try {
|
|
528
|
-
const data = await client.get(
|
|
529
|
-
findingReferencePath(params, "/retests")
|
|
530
|
-
);
|
|
531
|
-
return {
|
|
532
|
-
markdown: formatFindingRetests(data),
|
|
533
|
-
structured: {
|
|
534
|
-
state: data.state,
|
|
535
|
-
retests: data.retests
|
|
536
|
-
}
|
|
537
|
-
};
|
|
538
|
-
} catch (error) {
|
|
539
|
-
if (error instanceof OdinApiError && error.status === 404 && isRetestHistoryUnavailable(error.body)) {
|
|
540
|
-
const reason = "Retest history is gated or unavailable for this organisation.";
|
|
541
|
-
return {
|
|
542
|
-
markdown: reason,
|
|
543
|
-
structured: { state: "unavailable", reason, retests: [] }
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
throw error;
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
};
|
|
550
|
-
function isRetestHistoryUnavailable(body) {
|
|
551
|
-
return typeof body === "object" && body !== null && "code" in body && body.code === "RETEST_HISTORY_UNAVAILABLE";
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// src/tools/get-pentest.ts
|
|
555
|
-
import { z as z4 } from "zod";
|
|
556
|
-
var getPentestInput = z4.object({
|
|
557
|
-
pentestId: z4.string().min(1)
|
|
558
|
-
});
|
|
559
|
-
var getPentestTool = {
|
|
560
|
-
name: "get_pentest",
|
|
561
|
-
title: "Get pentest",
|
|
562
|
-
description: "Fetch a single pentest by ID, including status, scheduled window, and in-scope targets.",
|
|
563
|
-
inputSchema: getPentestInput,
|
|
564
|
-
handler: (client) => async (params) => {
|
|
565
|
-
const data = await client.get(
|
|
566
|
-
`/api/pentests/${encodeURIComponent(params.pentestId)}`
|
|
567
|
-
);
|
|
568
|
-
return {
|
|
569
|
-
markdown: formatPentestDetail(data),
|
|
570
|
-
structured: { pentest: data.pentest }
|
|
571
|
-
};
|
|
572
|
-
}
|
|
573
|
-
};
|
|
574
|
-
|
|
575
|
-
// src/tools/link-finding-pull-request.ts
|
|
576
|
-
import { z as z5 } from "zod";
|
|
577
|
-
var linkFindingPullRequestInput = z5.object({
|
|
578
|
-
...findingReferenceFields,
|
|
579
|
-
url: z5.string().url().describe("Existing GitHub pull request URL to link to the finding.")
|
|
580
|
-
}).refine((value) => Boolean(value.identifier || value.findingId), {
|
|
581
|
-
message: "Provide either identifier or findingId."
|
|
582
|
-
});
|
|
583
|
-
var linkFindingPullRequestTool = {
|
|
584
|
-
name: "link_finding_pull_request",
|
|
585
|
-
title: "Link an existing GitHub pull request",
|
|
586
|
-
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.",
|
|
587
|
-
inputSchema: linkFindingPullRequestInput,
|
|
588
|
-
handler: (client) => async (params) => {
|
|
589
|
-
const data = await client.post(
|
|
590
|
-
findingReferencePath(params, "/link-pr"),
|
|
591
|
-
{ url: params.url }
|
|
592
|
-
);
|
|
593
|
-
return {
|
|
594
|
-
markdown: formatFindingPullRequestLink(data),
|
|
595
|
-
structured: {
|
|
596
|
-
finding: data.finding,
|
|
597
|
-
pullRequest: data.pullRequest,
|
|
598
|
-
linkSource: data.linkSource,
|
|
599
|
-
workflow: data.workflow
|
|
600
|
-
}
|
|
601
|
-
};
|
|
602
|
-
}
|
|
603
|
-
};
|
|
604
|
-
|
|
605
|
-
// src/tools/list-assets.ts
|
|
606
|
-
import { z as z6 } from "zod";
|
|
607
|
-
var listAssetsInput = z6.object({
|
|
608
|
-
type: z6.enum(["DOMAIN", "SUBDOMAIN", "IP"]).optional(),
|
|
609
|
-
source: z6.enum(["MANUAL", "RECON", "IMPORT", "AGENT"]).optional(),
|
|
610
|
-
status: z6.string().optional().describe(
|
|
611
|
-
"Dynamic asset status set by recon (e.g. ACTIVE, INACTIVE, TLS_ERROR)."
|
|
612
|
-
),
|
|
613
|
-
search: z6.string().optional(),
|
|
614
|
-
sortBy: z6.enum(["lastSeenAt", "createdAt", "value"]).optional(),
|
|
615
|
-
sortOrder: z6.enum(["asc", "desc"]).optional(),
|
|
616
|
-
limit: z6.number().int().min(1).max(100).optional().default(50),
|
|
617
|
-
offset: z6.number().int().min(0).optional().default(0)
|
|
618
|
-
});
|
|
619
|
-
var listAssetsTool = {
|
|
620
|
-
name: "list_assets",
|
|
621
|
-
title: "List assets",
|
|
622
|
-
description: "List attack-surface assets for the organisation. Supports filtering by type, source, status, and free-text search.",
|
|
623
|
-
inputSchema: listAssetsInput,
|
|
624
|
-
handler: (client) => async (params) => {
|
|
625
|
-
const data = await client.get("/api/assets", params);
|
|
626
|
-
return {
|
|
627
|
-
markdown: formatAssetsList(data),
|
|
628
|
-
structured: {
|
|
629
|
-
assets: data.assets,
|
|
630
|
-
stats: data.stats,
|
|
631
|
-
pagination: data.pagination
|
|
632
|
-
}
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
};
|
|
636
|
-
|
|
637
|
-
// src/tools/list-findings.ts
|
|
638
|
-
import { z as z7 } from "zod";
|
|
639
|
-
var listFindingsInput = z7.object({
|
|
640
|
-
pentestId: z7.string().optional().describe("Filter to a single pentest."),
|
|
641
|
-
assetId: z7.string().optional().describe(
|
|
642
|
-
"Filter to findings whose latest revision references this asset."
|
|
643
|
-
),
|
|
644
|
-
repository: z7.string().optional().describe("Filter to a source repository such as owner/name."),
|
|
645
|
-
severity: z7.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFORMATIONAL"]).optional(),
|
|
646
|
-
status: z7.enum([
|
|
647
|
-
"REPORTED",
|
|
648
|
-
"ACKNOWLEDGED",
|
|
649
|
-
"MITIGATING",
|
|
650
|
-
"OPEN_FOR_RETEST",
|
|
651
|
-
"NEEDS_REVISION",
|
|
652
|
-
"FIXED_AND_RETESTED"
|
|
653
|
-
]).optional(),
|
|
654
|
-
search: z7.string().optional().describe("Free-text search on finding titles and human identifiers."),
|
|
655
|
-
sortBy: z7.enum(["severity", "createdAt"]).optional(),
|
|
656
|
-
sortOrder: z7.enum(["asc", "desc"]).optional(),
|
|
657
|
-
limit: z7.number().int().min(1).max(200).optional().default(50),
|
|
658
|
-
offset: z7.number().int().min(0).optional().default(0)
|
|
659
|
-
});
|
|
660
|
-
var listFindingsTool = {
|
|
661
|
-
name: "list_findings",
|
|
662
|
-
title: "List findings",
|
|
663
|
-
description: "List security findings for the organisation. Supports filtering by pentest, asset, repository, severity, current client-visible status, and searching finding titles or human identifiers such as ODI-12.",
|
|
664
|
-
inputSchema: listFindingsInput,
|
|
665
|
-
handler: (client) => async (params) => {
|
|
666
|
-
const data = await client.get(
|
|
667
|
-
"/api/findings",
|
|
668
|
-
params
|
|
669
|
-
);
|
|
670
|
-
return {
|
|
671
|
-
markdown: formatFindingsList(data),
|
|
672
|
-
structured: {
|
|
673
|
-
findings: data.findings,
|
|
674
|
-
stats: data.stats,
|
|
675
|
-
pagination: data.pagination
|
|
676
|
-
}
|
|
677
|
-
};
|
|
678
|
-
}
|
|
679
|
-
};
|
|
680
|
-
|
|
681
|
-
// src/tools/list-pentests.ts
|
|
682
|
-
import { z as z8 } from "zod";
|
|
683
|
-
var listPentestsInput = z8.object({});
|
|
684
|
-
var listPentestsTool = {
|
|
685
|
-
name: "list_pentests",
|
|
686
|
-
title: "List pentests",
|
|
687
|
-
description: "List all pentests for the organisation, including status, scheduled window, and in-scope targets.",
|
|
688
|
-
inputSchema: listPentestsInput,
|
|
689
|
-
handler: (client) => async () => {
|
|
690
|
-
const data = await client.get("/api/pentests");
|
|
691
|
-
return {
|
|
692
|
-
markdown: formatPentestsList(data),
|
|
693
|
-
structured: { pentests: data.pentests }
|
|
694
|
-
};
|
|
695
|
-
}
|
|
696
|
-
};
|
|
697
|
-
|
|
698
|
-
// src/tools/update-finding-status.ts
|
|
699
|
-
import { z as z9 } from "zod";
|
|
700
|
-
var updateFindingStatusInput = z9.object({
|
|
701
|
-
...findingReferenceFields,
|
|
702
|
-
status: z9.enum(["MITIGATING", "OPEN_FOR_RETEST", "ACKNOWLEDGED"]).describe(
|
|
703
|
-
"Target status. MITIGATING means work has started, OPEN_FOR_RETEST means the fix is ready for verification, and ACKNOWLEDGED means the organisation accepts the risk for now."
|
|
704
|
-
)
|
|
705
|
-
}).refine((value) => Boolean(value.identifier || value.findingId), {
|
|
706
|
-
message: "Provide either identifier or findingId."
|
|
707
|
-
});
|
|
708
|
-
var updateFindingStatusTool = {
|
|
709
|
-
name: "update_finding_status",
|
|
710
|
-
title: "Update finding status",
|
|
711
|
-
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.",
|
|
712
|
-
inputSchema: updateFindingStatusInput,
|
|
713
|
-
handler: (client) => async (params) => {
|
|
714
|
-
const findingId = await resolveFindingId(client, params);
|
|
715
|
-
const data = await client.patch(
|
|
716
|
-
`/api/findings/${encodeURIComponent(findingId)}/status`,
|
|
717
|
-
{ status: params.status }
|
|
718
|
-
);
|
|
719
|
-
return {
|
|
720
|
-
markdown: formatFindingStatusUpdate(data.finding),
|
|
721
|
-
structured: { finding: data.finding }
|
|
722
|
-
};
|
|
723
|
-
}
|
|
724
|
-
};
|
|
725
|
-
|
|
726
|
-
// src/index.ts
|
|
727
|
-
var PACKAGE_VERSION = "0.2.0";
|
|
728
15
|
async function main() {
|
|
729
16
|
const apiKey = process.env.ODIN_API_KEY;
|
|
730
17
|
if (!apiKey) {
|
|
731
|
-
|
|
732
|
-
"ODIN_API_KEY is
|
|
18
|
+
throw new Error(
|
|
19
|
+
"ODIN_API_KEY is required. Create a key under Management > API Keys."
|
|
733
20
|
);
|
|
734
|
-
process.exit(1);
|
|
735
21
|
}
|
|
736
22
|
const baseUrl = process.env.ODIN_BASE_URL ?? DEFAULT_ODIN_BASE_URL;
|
|
737
23
|
const client = new OdinClient({
|
|
@@ -739,62 +25,9 @@ async function main() {
|
|
|
739
25
|
baseUrl,
|
|
740
26
|
vercelProtectionBypassSecret: process.env.ODIN_VERCEL_PROTECTION_BYPASS_SECRET
|
|
741
27
|
});
|
|
742
|
-
const server =
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
});
|
|
746
|
-
const tools = [
|
|
747
|
-
listFindingsTool,
|
|
748
|
-
getFindingTool,
|
|
749
|
-
updateFindingStatusTool,
|
|
750
|
-
linkFindingPullRequestTool,
|
|
751
|
-
getFindingRetestsTool,
|
|
752
|
-
listPentestsTool,
|
|
753
|
-
getPentestTool,
|
|
754
|
-
listAssetsTool,
|
|
755
|
-
getAssetTool
|
|
756
|
-
];
|
|
757
|
-
for (const tool of tools) {
|
|
758
|
-
const run = tool.handler(client);
|
|
759
|
-
server.registerTool(
|
|
760
|
-
tool.name,
|
|
761
|
-
{
|
|
762
|
-
title: tool.title,
|
|
763
|
-
description: tool.description,
|
|
764
|
-
inputSchema: tool.inputSchema.shape
|
|
765
|
-
},
|
|
766
|
-
async (params) => {
|
|
767
|
-
try {
|
|
768
|
-
const { markdown, structured } = await run(params);
|
|
769
|
-
return {
|
|
770
|
-
content: [{ type: "text", text: markdown }],
|
|
771
|
-
structuredContent: { markdown, ...toObject(structured) }
|
|
772
|
-
};
|
|
773
|
-
} catch (error) {
|
|
774
|
-
return {
|
|
775
|
-
isError: true,
|
|
776
|
-
content: [{ type: "text", text: toErrorMessage(error) }]
|
|
777
|
-
};
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
);
|
|
781
|
-
}
|
|
782
|
-
const transport = new StdioServerTransport();
|
|
783
|
-
await server.connect(transport);
|
|
784
|
-
console.error(
|
|
785
|
-
`odin-mcp ${PACKAGE_VERSION} connected (baseUrl=${baseUrl}, tools=${tools.length}).`
|
|
786
|
-
);
|
|
787
|
-
}
|
|
788
|
-
function toObject(value) {
|
|
789
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
790
|
-
return value;
|
|
791
|
-
}
|
|
792
|
-
return { data: value };
|
|
793
|
-
}
|
|
794
|
-
function toErrorMessage(error) {
|
|
795
|
-
if (error instanceof OdinApiError) return error.message;
|
|
796
|
-
if (error instanceof Error) return `Unexpected error: ${error.message}`;
|
|
797
|
-
return `Unexpected error: ${String(error)}`;
|
|
28
|
+
const server = createOdinMcpServer(client, { appUrl: baseUrl });
|
|
29
|
+
await server.connect(new StdioServerTransport());
|
|
30
|
+
console.error(`odin-mcp ${PACKAGE_VERSION} connected over stdio.`);
|
|
798
31
|
}
|
|
799
32
|
main().catch((error) => {
|
|
800
33
|
console.error("odin-mcp failed to start:", error);
|