osv-scanner-mcp 0.1.12 → 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.
- package/README.md +54 -0
- package/dist/index.js +21 -1
- package/dist/osv/artifactReport.js +60 -0
- package/dist/osv/binaryDownloader.js +7 -7
- package/dist/osv/osvApi.js +9 -7
- package/dist/osv/runner.js +40 -10
- package/dist/osv/sbomReport.js +55 -0
- package/dist/tools/scanJavaArtifact.js +14 -0
- package/dist/tools/scanSbom.js +26 -0
- package/dist/utils/artifactDetector.js +61 -0
- package/dist/utils/readResponseBytes.js +33 -0
- package/dist/utils/sbomInput.js +99 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ Google製 [OSV-Scanner](https://github.com/google/osv-scanner) をラップす
|
|
|
12
12
|
## 特徴
|
|
13
13
|
|
|
14
14
|
- **ワンショットスキャン**: `scan_java_project` ツールにプロジェクトパスを渡すだけで、検出→スキャン→整形済みレポートまで一気に返します
|
|
15
|
+
- **JAR/WAR実体スキャン**: `scan_java_artifact` ツールで、lockfileが無い・shaded/fat JARしか手元にないプロジェクトでもアーカイブ内メタデータから既知の脆弱性を検出します(ベストエフォート同定であることを明示するcoverage情報付き)
|
|
16
|
+
- **SBOM入力スキャン**: `scan_sbom` ツールでCycloneDX/SPDXのJSON SBOMに記録された依存を検査します。SBOMの網羅性や実成果物との一致は未検証であることを明示します
|
|
15
17
|
- **深刻度順のレポート**: パッケージごとに脆弱性をCVSSスコア順に整理し、5段階の深刻度ラベル(critical / high / medium / low / unknown)とサマリ集計付きで返します
|
|
16
18
|
- **修正版の提示**: 各脆弱性の `fixed_versions` をMavenバージョン優先順位規則で正しくソートして含めます(`2.17.1-RELEASE` のようなsemver非対応の表記にも対応)
|
|
17
19
|
- **セキュリティ第一の設計**: シェル非経由の実行・引数ホワイトリスト・パス正規化と境界チェック・タイムアウト/出力サイズ上限を実装段階から組み込んでいます
|
|
@@ -178,6 +180,57 @@ Java(Maven)プロジェクトをスキャンし、既知の脆弱性レポート
|
|
|
178
180
|
- `fixed_versions` はMaven優先順位で昇順。複数のリリース系統(例: 2.12系バックポートと2.15系)が混在することがあります。空配列は「修正版が存在しない」ことを意味します
|
|
179
181
|
- `severity_score` が取得できない脆弱性は `null` / `"unknown"` として扱います
|
|
180
182
|
|
|
183
|
+
### `scan_java_artifact`
|
|
184
|
+
|
|
185
|
+
JAR/WARファイルの実体をスキャンします。既存のマニフェスト方式とは別ツールです。
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{ "artifact_path": "/absolute/path/to/application.war" }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`artifact_path` はJAR/WARファイル、または探索するディレクトリの絶対パスです。
|
|
192
|
+
ディレクトリ指定では `target` や `build` も探索します。`.git`、`node_modules`、`.idea`、`.vscode` と探索中のシンボリックリンクは除外します。
|
|
193
|
+
探索上限は深さ8・100ファイル・10,000エントリです。上限に達して探索を完了できない場合は、結果を黙って省略せず `artifact_search_limit_exceeded` を返します。対象を絞って再実行してください。
|
|
194
|
+
`OSV_MCP_ALLOWED_ROOT` による制限も適用されます。
|
|
195
|
+
|
|
196
|
+
OSV-Scanner 2.4.0の `java/archive` プラグインを使用し、ネストJARもスキャナー側で解析します。Javaコードやビルドは実行しません。
|
|
197
|
+
識別にはアーカイブ内メタデータを用いるため、除去済みメタデータやshaded/minimized JAR内の依存を見落とす場合があります。
|
|
198
|
+
|
|
199
|
+
**出力の読み方:**
|
|
200
|
+
|
|
201
|
+
- 先頭の `coverage` に `jars_found`、`jars_identified`、`unidentified_jars` を返します。件数はWARも含む、ファイルシステム上で列挙した外側のアーカイブ単位です。ネストJARの総数ではありません。
|
|
202
|
+
- `artifacts[].status` は `identified_with_vulnerabilities` / `identified_without_known_vulnerabilities` / `unidentified` の3値です。「同定済み」は少なくとも1件のMaven座標を取得できた意味であり、全依存の同定ではありません。
|
|
203
|
+
- `coverage.completeness` は常に `incomplete`。`identified_vulnerability_count: 0` は安全性の保証ではありません。
|
|
204
|
+
- `packages` は同定できた脆弱なパッケージの詳細です。複数アーカイブに含まれる同一パッケージ・脆弱性は全体集計では重複排除します。
|
|
205
|
+
- JAR/WARが無い場合は `no_scannable_artifacts`、全件同定不能の場合は警告を含む成功レポートです。
|
|
206
|
+
|
|
207
|
+
`suggest_fix` は引き続きマニフェスト方式専用です。experimentalプラグインを使うため、OSV-Scannerのピン留めバージョン更新時には、フラグとJAR/WARの出力形式も再検証してください。
|
|
208
|
+
信頼できないアーカイブの展開はOSV-Scannerのネイティブ処理に依存します。タイムアウト・出力上限はありますが、OSレベルのメモリ制限やサンドボックスを提供するものではありません。
|
|
209
|
+
|
|
210
|
+
### `scan_sbom`
|
|
211
|
+
|
|
212
|
+
既存のSBOMに記録された依存をOSV-Scannerで照会します。SBOMの生成、ビルド、JARの実行は行いません。
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{ "sbom_path": "/absolute/path/to/release-sbom.json" }
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
- **対応形式**: UTF-8 JSONのCycloneDX 1.4 / 1.5 / 1.6、SPDX 2.2 / 2.3。XML、SPDX tag-value、SPDX 3は未対応です。
|
|
219
|
+
- **入力**: 16MiB以下のローカル通常ファイルの絶対パス。ファイル名は任意で、内容から形式を判別します。CycloneDXは`components`、SPDXは`packages`配列が必要です。形式・主要構造の確認であり、仕様全体のJSON Schema検証ではありません。
|
|
220
|
+
- **識別情報**: CycloneDXの`components[].purl`、SPDXの`packages[].externalRefs`にバージョン付きPackage URLを含めてください。例: `pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1`。詳細は[OSV-Scanner公式ドキュメント](https://github.com/google/osv-scanner/blob/main/docs/scan-source.md)を参照してください。
|
|
221
|
+
- **安全な読み込み**: 許可ルートと読み込み中のサイズ上限を確認し、権限制限付きの一時コピーだけをスキャンします。元ファイルは変更せず、一時コピーは成功・失敗ともに削除します。
|
|
222
|
+
|
|
223
|
+
出力の先頭に`coverage`を返します。
|
|
224
|
+
|
|
225
|
+
- `identified_package_count`: スキャナーが識別した、名前・バージョン・エコシステムの重複を除いたパッケージ数。既知脆弱性がないものも含みます。
|
|
226
|
+
- `unidentified_packages`: スキャナー出力に存在したものの、バージョン等が不足しているパッケージ。スキャナー自体が読み飛ばした項目は列挙できないため、この配列が空でも全件検査を意味しません。
|
|
227
|
+
- `status`: 識別できたものがあれば`packages_identified`、なければ`no_packages_identified`。
|
|
228
|
+
- `completeness` / `artifact_match`: ともに`not_verified`。SBOMの依存網羅性や、実際のJARと同一ビルドのものかは自動検証しません。
|
|
229
|
+
|
|
230
|
+
`sbom`には元ファイルのパス・形式・仕様バージョン・スキャンに用いた入力バイト列のSHA256を返します。`identified_vulnerability_count`と`packages`には識別できた依存の検出結果を返します。**検出0件は「安全」の保証ではありません。** メタデータのないJARを補完するには、そのビルドに対応する正確なSBOMを別途用意してください。
|
|
231
|
+
|
|
232
|
+
不正JSONは`invalid_sbom`、未対応形式は`unsupported_sbom_format`、入力上限超過は`sbom_too_large`、存在しない・読み取れないファイルは`sbom_not_found`です。空のSBOMや識別できるパッケージがないSBOMは、警告付きの成功レポートになります。既存ツールと同じタイムアウト・出力上限・同時実行枠を使用します。
|
|
233
|
+
|
|
181
234
|
### `suggest_fix`
|
|
182
235
|
|
|
183
236
|
スキャンを実行し、脆弱なパッケージごとに**推奨アップグレードバージョン**を提案します。単純な最大バージョンではなく、現在のバージョンに最も近いリリース系統の修正版を3段階フォールバックで選定します:
|
|
@@ -297,6 +350,7 @@ npm run build # dist/ へビルド
|
|
|
297
350
|
- [x] npmパッケージ化(`npx osv-scanner-mcp`)
|
|
298
351
|
- [x] OSV-Scannerバイナリの自動ダウンロード(チェックサム検証付き)
|
|
299
352
|
- [x] Gradle対応(lockfile方式)
|
|
353
|
+
- [x] `scan_java_artifact` ツール: JAR/WAR実体スキャン(lockfileが無い・shaded/fat JARのみのプロジェクト向け)
|
|
300
354
|
|
|
301
355
|
## ライセンス
|
|
302
356
|
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { handleExplainVulnerability } from "./tools/explainVulnerability.js";
|
|
17
17
|
import { handleScanJavaProject } from "./tools/scanJavaProject.js";
|
|
18
|
+
import { handleScanJavaArtifact } from "./tools/scanJavaArtifact.js";
|
|
19
|
+
import { handleScanSbom } from "./tools/scanSbom.js";
|
|
18
20
|
import { handleSuggestFix } from "./tools/suggestFix.js";
|
|
19
21
|
import { ALLOWED_ROOT_ENV, allowedRootStartupError, allowedRootStartupWarning, } from "./utils/startupConfig.js";
|
|
20
22
|
export { ALLOWED_ROOT_ENV };
|
|
@@ -32,7 +34,7 @@ if (startupWarning !== null) {
|
|
|
32
34
|
// NOTE: リリース時はpackage.jsonのversionと同じ値に更新すること
|
|
33
35
|
const server = new McpServer({
|
|
34
36
|
name: "osv-scanner-mcp",
|
|
35
|
-
version: "0.
|
|
37
|
+
version: "0.3.0",
|
|
36
38
|
});
|
|
37
39
|
server.registerTool("scan_java_project", {
|
|
38
40
|
title: "Javaプロジェクトの脆弱性スキャン",
|
|
@@ -74,6 +76,24 @@ server.registerTool("explain_vulnerability", {
|
|
|
74
76
|
.describe("脆弱性のID(例: GHSA-jfh8-c2jp-5v3q、CVE-2021-44228)"),
|
|
75
77
|
},
|
|
76
78
|
}, async ({ vulnerability_id }) => handleExplainVulnerability({ vulnerability_id }));
|
|
79
|
+
server.registerTool("scan_java_artifact", {
|
|
80
|
+
title: "JAR/WAR実体の脆弱性スキャン",
|
|
81
|
+
description: "JAR/WARファイルまたはディレクトリ内の実体をスキャンする。ビルドやJavaコードの実行は行わない。" +
|
|
82
|
+
"メタデータによるベストエフォート同定のため、coverageと同定不能ファイルを必ず確認すること。" +
|
|
83
|
+
"completenessは常にincomplete。検出0件でも安全性や全依存の同定を保証しない。",
|
|
84
|
+
inputSchema: {
|
|
85
|
+
artifact_path: z.string().min(1).describe("JAR/WARファイル、または探索するディレクトリの絶対パス"),
|
|
86
|
+
},
|
|
87
|
+
}, async ({ artifact_path }) => handleScanJavaArtifact({ artifact_path }, { allowedRoot: process.env[ALLOWED_ROOT_ENV] }));
|
|
88
|
+
server.registerTool("scan_sbom", {
|
|
89
|
+
title: "SBOMの脆弱性スキャン",
|
|
90
|
+
description: "CycloneDX 1.4/1.5/1.6またはSPDX 2.2/2.3のJSON SBOMから識別できる依存をスキャンする。" +
|
|
91
|
+
"ビルドやJARの実行は行わない。入力は16MiB以下のローカルファイル。" +
|
|
92
|
+
"SBOMの網羅性・鮮度・実成果物との一致は未検証であり、検出0件でも安全性を保証しない。",
|
|
93
|
+
inputSchema: {
|
|
94
|
+
sbom_path: z.string().min(1).describe("CycloneDX/SPDX JSON SBOMファイルの絶対パス"),
|
|
95
|
+
},
|
|
96
|
+
}, async ({ sbom_path }) => handleScanSbom({ sbom_path }, { allowedRoot: process.env[ALLOWED_ROOT_ENV] }));
|
|
77
97
|
const transport = new StdioServerTransport();
|
|
78
98
|
await server.connect(transport);
|
|
79
99
|
// stdoutはMCPプロトコル専用のため、起動ログはstderrへ
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ScanToolError } from "../errors.js";
|
|
2
|
+
import { sanitizeExternalText } from "../utils/externalText.js";
|
|
3
|
+
import { asArray, asRecord } from "../utils/unknownJson.js";
|
|
4
|
+
import { parseOsvScanOutput } from "./scanReport.js";
|
|
5
|
+
const WARNING = "Best-effort metadata identification only. Missing or removed metadata can hide dependencies, even in identified archives. Zero identified vulnerabilities does not establish safety.";
|
|
6
|
+
const HINT = "Metadata may be missing (shaded/minimized archive). Scan the build project's lockfile instead.";
|
|
7
|
+
function isIdentifiedPackage(raw) {
|
|
8
|
+
const info = asRecord(asRecord(raw)?.package);
|
|
9
|
+
if (info?.ecosystem !== "Maven" || typeof info.name !== "string" || typeof info.version !== "string")
|
|
10
|
+
return false;
|
|
11
|
+
const parts = info.name.split(":");
|
|
12
|
+
return parts.length === 2 && parts.every((part) => part.trim() !== "" && part.toLowerCase() !== "unknown") &&
|
|
13
|
+
info.version.trim() !== "" && info.version.toLowerCase() !== "unknown";
|
|
14
|
+
}
|
|
15
|
+
export function buildArtifactReport(raw, artifactPaths) {
|
|
16
|
+
const results = asRecord(raw)?.results;
|
|
17
|
+
if (!Array.isArray(results)) {
|
|
18
|
+
throw new ScanToolError("invalid_output", "OSV-Scanner artifact output is missing its results array");
|
|
19
|
+
}
|
|
20
|
+
const byPath = new Map();
|
|
21
|
+
for (const result of results) {
|
|
22
|
+
const record = asRecord(result);
|
|
23
|
+
const source = asRecord(record?.source);
|
|
24
|
+
// Identity comparison must precede display sanitization, which can collapse distinct names.
|
|
25
|
+
const sourcePath = source?.path;
|
|
26
|
+
if (source?.type !== "artifact" || typeof sourcePath !== "string" || !artifactPaths.includes(sourcePath))
|
|
27
|
+
continue;
|
|
28
|
+
const packages = asArray(record?.packages).filter(isIdentifiedPackage);
|
|
29
|
+
if (packages.length > 0)
|
|
30
|
+
byPath.set(sourcePath, [...(byPath.get(sourcePath) ?? []), ...packages]);
|
|
31
|
+
}
|
|
32
|
+
const normalized = [...byPath].map(([file, packages]) => ({ source: { path: file }, packages }));
|
|
33
|
+
const report = parseOsvScanOutput({ results: normalized });
|
|
34
|
+
const artifacts = artifactPaths.map((file) => {
|
|
35
|
+
const packages = byPath.get(file);
|
|
36
|
+
const identified = packages !== undefined;
|
|
37
|
+
const perFile = parseOsvScanOutput({ results: [{ packages: packages ?? [] }] });
|
|
38
|
+
return {
|
|
39
|
+
path: sanitizeExternalText(file),
|
|
40
|
+
status: !identified ? "unidentified" : perFile.vulnerability_count > 0
|
|
41
|
+
? "identified_with_vulnerabilities" : "identified_without_known_vulnerabilities",
|
|
42
|
+
identified_vulnerability_count: perFile.vulnerability_count,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
coverage: {
|
|
47
|
+
jars_found: artifactPaths.length,
|
|
48
|
+
jars_identified: byPath.size,
|
|
49
|
+
unidentified_jars: artifacts.filter((item) => item.status === "unidentified")
|
|
50
|
+
.map(({ path }) => ({ path, hint: HINT })),
|
|
51
|
+
completeness: "incomplete",
|
|
52
|
+
warning: WARNING,
|
|
53
|
+
},
|
|
54
|
+
artifacts,
|
|
55
|
+
identified_vulnerability_count: report.vulnerability_count,
|
|
56
|
+
identified_vulnerable_package_count: report.vulnerable_package_count,
|
|
57
|
+
severity_breakdown: report.severity_breakdown,
|
|
58
|
+
packages: report.packages,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -15,6 +15,7 @@ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
|
15
15
|
import os from "node:os";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { ScanToolError } from "../errors.js";
|
|
18
|
+
import { readResponseBytes } from "../utils/readResponseBytes.js";
|
|
18
19
|
/** ピン留めするOSV-Scannerのバージョン。更新時は下のチェックサムも必ず更新すること */
|
|
19
20
|
export const PINNED_OSV_SCANNER_VERSION = "2.4.0";
|
|
20
21
|
/**
|
|
@@ -70,15 +71,14 @@ async function downloadAsset(url, fetchFn, timeoutMs) {
|
|
|
70
71
|
if (!response.ok) {
|
|
71
72
|
throw new ScanToolError("binary_download_failed", `OSV-Scannerのダウンロードに失敗しました(HTTP ${response.status}): ${url}`);
|
|
72
73
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
throw new ScanToolError("binary_download_failed", `ダウンロードサイズが上限(${MAX_BINARY_BYTES}バイト)を超えています`);
|
|
74
|
+
try {
|
|
75
|
+
return await readResponseBytes(response, MAX_BINARY_BYTES, new ScanToolError("binary_download_failed", `ダウンロードサイズが上限(${MAX_BINARY_BYTES}バイト)を超えています`));
|
|
76
76
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof ScanToolError)
|
|
79
|
+
throw error;
|
|
80
|
+
throw new ScanToolError("binary_download_failed", "OSV-Scannerのダウンロード本文を受信できませんでした");
|
|
80
81
|
}
|
|
81
|
-
return buffer;
|
|
82
82
|
}
|
|
83
83
|
/**
|
|
84
84
|
* ピン留めバージョンのOSV-Scannerをキャッシュから返す。
|
package/dist/osv/osvApi.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - レスポンスは外部由来データとして防御的にパースする(呼び出し側)
|
|
10
10
|
*/
|
|
11
11
|
import { ScanToolError } from "../errors.js";
|
|
12
|
+
import { readResponseBytes } from "../utils/readResponseBytes.js";
|
|
12
13
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
13
14
|
const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
14
15
|
const DEFAULT_BASE_URL = "https://api.osv.dev";
|
|
@@ -56,14 +57,15 @@ export async function fetchOsvRecord(id, options = {}) {
|
|
|
56
57
|
if (!response.ok) {
|
|
57
58
|
throw new ScanToolError("api_request_failed", `OSV APIがエラーを返しました(HTTP ${response.status})`);
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
let body;
|
|
61
|
+
try {
|
|
62
|
+
const bytes = await readResponseBytes(response, maxBytes, new ScanToolError("output_too_large", `OSV APIのレスポンスがサイズ上限(${maxBytes}バイト)を超えました`));
|
|
63
|
+
body = new TextDecoder().decode(bytes);
|
|
62
64
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
throw new ScanToolError("
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error instanceof ScanToolError)
|
|
67
|
+
throw error;
|
|
68
|
+
throw new ScanToolError("api_request_failed", "OSV APIのレスポンス本文を受信できませんでした");
|
|
67
69
|
}
|
|
68
70
|
try {
|
|
69
71
|
return JSON.parse(body);
|
package/dist/osv/runner.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* セキュリティ設計(docs/DESIGN_TODO.md):
|
|
5
5
|
* - シェルを経由しない`spawn`+引数配列で実行(コマンドインジェクション対策)
|
|
6
6
|
* - OSV-Scannerへ渡す引数は固定リストのみ。呼び出し側から任意フラグは注入できない
|
|
7
|
-
* (
|
|
7
|
+
* (プロジェクトは検証済みディレクトリ1つ、実体スキャンは列挙済みのJAR/WAR絶対パスだけを渡す)
|
|
8
|
+
* - SBOMは検証・サイズ制限済みの専用一時コピー1つだけを渡す
|
|
8
9
|
* - タイムアウトと出力サイズ上限を設ける(ハング・巨大出力によるDoS対策)
|
|
9
10
|
*
|
|
10
11
|
* 終了コード(2.4.0で実機確認):
|
|
@@ -38,12 +39,23 @@ const DEFAULT_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
|
|
|
38
39
|
const MAX_STDERR_DETAIL_BYTES = 8 * 1024;
|
|
39
40
|
/** OSV-Scannerに渡す固定引数。ここに無いオプションは一切使わない(ホワイトリスト) */
|
|
40
41
|
const FIXED_SCAN_ARGS = ["scan", "source", "-r", "--format", "json"];
|
|
42
|
+
const FIXED_ARTIFACT_ARGS = [
|
|
43
|
+
"scan", "source", "--format", "json", "--all-packages", "--no-ignore",
|
|
44
|
+
"--experimental-no-default-plugins", "--experimental-plugins", "java/archive",
|
|
45
|
+
];
|
|
46
|
+
const FIXED_SBOM_ARGS = [
|
|
47
|
+
"scan", "source", "--format", "json", "--all-packages", "--no-ignore",
|
|
48
|
+
"--experimental-no-default-plugins", "--experimental-plugins", "sbom",
|
|
49
|
+
];
|
|
50
|
+
const SCAN_ARGS = { project: FIXED_SCAN_ARGS, artifact: FIXED_ARTIFACT_ARGS, sbom: FIXED_SBOM_ARGS };
|
|
41
51
|
const EXIT_NO_VULNS = 0;
|
|
42
52
|
const EXIT_VULNS_FOUND = 1;
|
|
43
53
|
const EXIT_NO_PACKAGES = 128;
|
|
44
|
-
function execOsvScanner(binaryPath,
|
|
54
|
+
function execOsvScanner(binaryPath, targetPaths, timeoutMs, maxOutputBytes, mode) {
|
|
45
55
|
return new Promise((resolve, reject) => {
|
|
46
|
-
const child = spawn(binaryPath, [
|
|
56
|
+
const child = spawn(binaryPath, [
|
|
57
|
+
...SCAN_ARGS[mode], ...targetPaths,
|
|
58
|
+
], {
|
|
47
59
|
shell: false,
|
|
48
60
|
stdio: ["ignore", "pipe", "pipe"],
|
|
49
61
|
});
|
|
@@ -101,25 +113,43 @@ function execOsvScanner(binaryPath, projectDir, timeoutMs, maxOutputBytes) {
|
|
|
101
113
|
* 絶対パスを渡すこと**(このレイヤーではパス検証を行わない)
|
|
102
114
|
*/
|
|
103
115
|
export async function runOsvScan(projectDir, options = {}) {
|
|
116
|
+
return parseOsvScanOutput(await runScan([projectDir], options, "project"));
|
|
117
|
+
}
|
|
118
|
+
/** Accept only the exact absolute files enumerated by detectJavaArtifacts. */
|
|
119
|
+
export async function runOsvArtifactScan(artifactPaths, options = {}) {
|
|
120
|
+
if (artifactPaths.length === 0) {
|
|
121
|
+
throw new ScanToolError("no_scannable_artifacts", "No JAR/WAR archives selected");
|
|
122
|
+
}
|
|
123
|
+
return runScan(artifactPaths, options, "artifact");
|
|
124
|
+
}
|
|
125
|
+
/** Scan only the private, validated snapshot prepared by handleScanSbom. */
|
|
126
|
+
export async function runOsvSbomScan(snapshotPath, options = {}) {
|
|
127
|
+
return runScan([snapshotPath], options, "sbom");
|
|
128
|
+
}
|
|
129
|
+
async function runScan(targetPaths, options, mode) {
|
|
104
130
|
const limit = options.maxConcurrentScans ?? maxConcurrentScansFromEnv();
|
|
105
131
|
if (activeScans >= limit) {
|
|
106
132
|
throw new ScanToolError("too_many_concurrent_scans", `同時実行できるスキャンは${limit}件までです(現在${activeScans}件実行中)。実行中のスキャン完了後に再試行してください`);
|
|
107
133
|
}
|
|
108
134
|
activeScans++;
|
|
109
135
|
try {
|
|
110
|
-
return await runOsvScanUnguarded(
|
|
136
|
+
return await runOsvScanUnguarded(targetPaths, options, mode);
|
|
111
137
|
}
|
|
112
138
|
finally {
|
|
113
139
|
activeScans--;
|
|
114
140
|
}
|
|
115
141
|
}
|
|
116
|
-
async function runOsvScanUnguarded(
|
|
142
|
+
async function runOsvScanUnguarded(targetPaths, options, mode) {
|
|
117
143
|
const binaryPath = options.binaryPath ?? (await resolveOsvScannerBinary());
|
|
118
|
-
const result = await execOsvScanner(binaryPath,
|
|
119
|
-
if (result.exitCode === EXIT_NO_PACKAGES) {
|
|
120
|
-
throw new ScanToolError("no_packages_found", `OSV-Scannerがスキャン対象のパッケージを検出できませんでした: ${
|
|
144
|
+
const result = await execOsvScanner(binaryPath, targetPaths, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, mode);
|
|
145
|
+
if (result.exitCode === EXIT_NO_PACKAGES && mode === "project") {
|
|
146
|
+
throw new ScanToolError("no_packages_found", `OSV-Scannerがスキャン対象のパッケージを検出できませんでした: ${targetPaths[0]}(pom.xmlに依存関係が定義されているか確認してください)`, result.stderr);
|
|
147
|
+
}
|
|
148
|
+
if (mode !== "project" && result.exitCode === EXIT_NO_PACKAGES && result.stdout.trim() === "") {
|
|
149
|
+
return { results: [] };
|
|
121
150
|
}
|
|
122
|
-
if (result.exitCode !== EXIT_NO_VULNS && result.exitCode !== EXIT_VULNS_FOUND
|
|
151
|
+
if (result.exitCode !== EXIT_NO_VULNS && result.exitCode !== EXIT_VULNS_FOUND &&
|
|
152
|
+
!(mode !== "project" && result.exitCode === EXIT_NO_PACKAGES)) {
|
|
123
153
|
const status = result.exitCode !== null ? `exit code ${result.exitCode}` : `signal ${result.signal}`;
|
|
124
154
|
throw new ScanToolError("scan_failed", `OSV-Scannerが異常終了しました(${status})`, result.stderr);
|
|
125
155
|
}
|
|
@@ -130,5 +160,5 @@ async function runOsvScanUnguarded(projectDir, options) {
|
|
|
130
160
|
catch {
|
|
131
161
|
throw new ScanToolError("invalid_output", "OSV-Scannerの出力をJSONとして解釈できませんでした", result.stdout.slice(0, 1000));
|
|
132
162
|
}
|
|
133
|
-
return
|
|
163
|
+
return parsed;
|
|
134
164
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { ScanToolError } from "../errors.js";
|
|
2
|
+
import { sanitizeExternalText } from "../utils/externalText.js";
|
|
3
|
+
import { asRecord, asString } from "../utils/unknownJson.js";
|
|
4
|
+
import { parseOsvScanOutput } from "./scanReport.js";
|
|
5
|
+
export function buildSbomReport(raw, input, snapshotPath) {
|
|
6
|
+
const results = asRecord(raw)?.results;
|
|
7
|
+
if (!Array.isArray(results))
|
|
8
|
+
throw new ScanToolError("invalid_output", "SBOM scan output is missing its results array");
|
|
9
|
+
const identified = new Set();
|
|
10
|
+
const unidentified = new Map();
|
|
11
|
+
const scannable = [];
|
|
12
|
+
for (const value of results) {
|
|
13
|
+
const result = asRecord(value);
|
|
14
|
+
const source = asRecord(result?.source);
|
|
15
|
+
if (source?.path !== snapshotPath || source.type !== "sbom" || !Array.isArray(result?.packages)) {
|
|
16
|
+
throw new ScanToolError("invalid_output", "SBOM scan output contains an unexpected source or package list");
|
|
17
|
+
}
|
|
18
|
+
for (const item of result.packages) {
|
|
19
|
+
const pkg = asRecord(asRecord(item)?.package);
|
|
20
|
+
if (pkg === null)
|
|
21
|
+
throw new ScanToolError("invalid_output", "SBOM scanner returned a malformed package");
|
|
22
|
+
const name = asString(pkg?.name);
|
|
23
|
+
const version = asString(pkg?.version);
|
|
24
|
+
const ecosystem = asString(pkg?.ecosystem);
|
|
25
|
+
if (!name?.trim() || !version?.trim() || !ecosystem?.trim() ||
|
|
26
|
+
[name, version, ecosystem].some((s) => s.trim().toLowerCase() === "unknown") || name === "unknown:unknown") {
|
|
27
|
+
unidentified.set(JSON.stringify([ecosystem, name, version]), { name, version, ecosystem });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
identified.add(JSON.stringify([ecosystem, name, version]));
|
|
31
|
+
scannable.push(item);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const report = parseOsvScanOutput({ results: [{ packages: scannable }] });
|
|
35
|
+
return {
|
|
36
|
+
coverage: {
|
|
37
|
+
identified_package_count: identified.size,
|
|
38
|
+
unidentified_packages: [...unidentified.values()],
|
|
39
|
+
status: identified.size === 0 ? "no_packages_identified" : "packages_identified",
|
|
40
|
+
completeness: "not_verified",
|
|
41
|
+
artifact_match: "not_verified",
|
|
42
|
+
warning: "Only packages identified from this SBOM are checked. Missing, unsupported or unversioned identifiers can be skipped. SBOM completeness, freshness and correspondence to the actual build are not verified. Zero findings does not establish safety.",
|
|
43
|
+
},
|
|
44
|
+
sbom: {
|
|
45
|
+
path: sanitizeExternalText(input.sourcePath),
|
|
46
|
+
format: input.format,
|
|
47
|
+
spec_version: input.specVersion,
|
|
48
|
+
sha256: input.sha256,
|
|
49
|
+
},
|
|
50
|
+
identified_vulnerability_count: report.vulnerability_count,
|
|
51
|
+
identified_vulnerable_package_count: report.vulnerable_package_count,
|
|
52
|
+
severity_breakdown: report.severity_breakdown,
|
|
53
|
+
packages: report.packages,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { buildArtifactReport } from "../osv/artifactReport.js";
|
|
2
|
+
import { runOsvArtifactScan } from "../osv/runner.js";
|
|
3
|
+
import { detectJavaArtifacts } from "../utils/artifactDetector.js";
|
|
4
|
+
import { errorResult, jsonResult } from "./toolResult.js";
|
|
5
|
+
export async function handleScanJavaArtifact(args, options = {}) {
|
|
6
|
+
try {
|
|
7
|
+
const { artifactPaths } = await detectJavaArtifacts(args.artifact_path, options);
|
|
8
|
+
const raw = await runOsvArtifactScan(artifactPaths, options);
|
|
9
|
+
return jsonResult(buildArtifactReport(raw, artifactPaths));
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
return errorResult(error);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { runOsvSbomScan } from "../osv/runner.js";
|
|
5
|
+
import { buildSbomReport } from "../osv/sbomReport.js";
|
|
6
|
+
import { loadSbom } from "../utils/sbomInput.js";
|
|
7
|
+
import { errorResult, jsonResult } from "./toolResult.js";
|
|
8
|
+
export async function handleScanSbom(args, options = {}) {
|
|
9
|
+
try {
|
|
10
|
+
const input = await loadSbom(args.sbom_path, options);
|
|
11
|
+
const dir = await mkdtemp(path.join(await realpath(os.tmpdir()), "osv-mcp-sbom-"));
|
|
12
|
+
try {
|
|
13
|
+
// A fixed filename selects the native parser, irrespective of the user's filename.
|
|
14
|
+
const snapshotPath = path.join(dir, input.format === "CycloneDX" ? "input.cdx.json" : "input.spdx.json");
|
|
15
|
+
await writeFile(snapshotPath, input.bytes, { mode: 0o600, flag: "wx" });
|
|
16
|
+
const raw = await runOsvSbomScan(snapshotPath, options);
|
|
17
|
+
return jsonResult(buildSbomReport(raw, input, snapshotPath));
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
await rm(dir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return errorResult(error);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { opendir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ScanToolError } from "../errors.js";
|
|
4
|
+
// Build output directories must be included for artifact scans.
|
|
5
|
+
const SKIPPED_DIRS = new Set([".git", "node_modules", ".idea", ".vscode"]);
|
|
6
|
+
const isArchive = (file) => /\.(jar|war)$/i.test(file);
|
|
7
|
+
export async function detectJavaArtifacts(inputPath, options = {}) {
|
|
8
|
+
if (typeof inputPath !== "string" || inputPath.trim() === "" || !path.isAbsolute(inputPath)) {
|
|
9
|
+
throw new ScanToolError("project_not_found", "Specify an absolute JAR/WAR or directory path");
|
|
10
|
+
}
|
|
11
|
+
let targetPath;
|
|
12
|
+
try {
|
|
13
|
+
targetPath = await realpath(inputPath);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new ScanToolError("project_not_found", `Path does not exist: ${inputPath}`);
|
|
17
|
+
}
|
|
18
|
+
if (options.allowedRoot !== undefined) {
|
|
19
|
+
const root = await realpath(options.allowedRoot);
|
|
20
|
+
const relative = path.relative(root, targetPath);
|
|
21
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
22
|
+
throw new ScanToolError("path_outside_allowed_root", "Artifact path is outside the allowed root");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const info = await stat(targetPath);
|
|
26
|
+
if (info.isFile() && isArchive(targetPath))
|
|
27
|
+
return { targetPath, artifactPaths: [targetPath] };
|
|
28
|
+
if (!info.isDirectory()) {
|
|
29
|
+
throw new ScanToolError("no_scannable_artifacts", "The selected file is not a JAR/WAR archive");
|
|
30
|
+
}
|
|
31
|
+
const maxDepth = options.maxDepth ?? 8;
|
|
32
|
+
const maxArtifacts = options.maxArtifacts ?? 100;
|
|
33
|
+
const maxEntries = options.maxEntries ?? 10_000;
|
|
34
|
+
const artifactPaths = [];
|
|
35
|
+
let visited = 0;
|
|
36
|
+
const limitError = () => new ScanToolError("artifact_search_limit_exceeded", "Artifact discovery limit exceeded; select a smaller directory or an individual JAR/WAR");
|
|
37
|
+
async function walk(dir, depth) {
|
|
38
|
+
const entries = await opendir(dir);
|
|
39
|
+
for await (const entry of entries) {
|
|
40
|
+
if (++visited > maxEntries)
|
|
41
|
+
throw limitError();
|
|
42
|
+
const child = path.join(dir, entry.name);
|
|
43
|
+
if (entry.isFile() && isArchive(entry.name)) {
|
|
44
|
+
if (artifactPaths.length >= maxArtifacts)
|
|
45
|
+
throw limitError();
|
|
46
|
+
artifactPaths.push(child);
|
|
47
|
+
}
|
|
48
|
+
else if (entry.isDirectory() && !SKIPPED_DIRS.has(entry.name)) {
|
|
49
|
+
if (depth >= maxDepth)
|
|
50
|
+
throw limitError();
|
|
51
|
+
await walk(child, depth + 1);
|
|
52
|
+
}
|
|
53
|
+
// Dirent predicates do not follow symlinks. Never hand these to the scanner.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
await walk(targetPath, 1);
|
|
57
|
+
if (artifactPaths.length === 0) {
|
|
58
|
+
throw new ScanToolError("no_scannable_artifacts", "No JAR/WAR archives found in the selected directory");
|
|
59
|
+
}
|
|
60
|
+
return { targetPath, artifactPaths: artifactPaths.sort() };
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Read a fetch body without retaining more than the configured byte limit. */
|
|
2
|
+
export async function readResponseBytes(response, maxBytes, sizeError) {
|
|
3
|
+
if (Number(response.headers.get("content-length")) > maxBytes) {
|
|
4
|
+
// Cleanup must not delay or replace the size-limit error.
|
|
5
|
+
void response.body?.cancel().catch(() => { });
|
|
6
|
+
throw sizeError;
|
|
7
|
+
}
|
|
8
|
+
if (response.body === null)
|
|
9
|
+
return Buffer.alloc(0);
|
|
10
|
+
const reader = response.body.getReader();
|
|
11
|
+
const chunks = [];
|
|
12
|
+
let total = 0;
|
|
13
|
+
try {
|
|
14
|
+
while (true) {
|
|
15
|
+
const { done, value } = await reader.read();
|
|
16
|
+
if (done)
|
|
17
|
+
break;
|
|
18
|
+
if (value.byteLength > maxBytes - total)
|
|
19
|
+
throw sizeError;
|
|
20
|
+
total += value.byteLength;
|
|
21
|
+
if (value.byteLength > 0)
|
|
22
|
+
chunks.push(value);
|
|
23
|
+
}
|
|
24
|
+
return Buffer.concat(chunks, total);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
void reader.cancel().catch(() => { });
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
reader.releaseLock();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { open, realpath } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ScanToolError } from "../errors.js";
|
|
6
|
+
import { asRecord } from "./unknownJson.js";
|
|
7
|
+
const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
const CYCLONEDX_VERSIONS = new Set(["1.4", "1.5", "1.6"]);
|
|
9
|
+
const SPDX_VERSIONS = new Set(["SPDX-2.2", "SPDX-2.3"]);
|
|
10
|
+
export async function loadSbom(inputPath, options = {}) {
|
|
11
|
+
if (typeof inputPath !== "string" || !path.isAbsolute(inputPath)) {
|
|
12
|
+
throw new ScanToolError("sbom_not_found", "Specify an absolute path to a JSON SBOM file");
|
|
13
|
+
}
|
|
14
|
+
let sourcePath;
|
|
15
|
+
try {
|
|
16
|
+
sourcePath = await realpath(inputPath);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new ScanToolError("sbom_not_found", "The specified SBOM file does not exist");
|
|
20
|
+
}
|
|
21
|
+
if (options.allowedRoot !== undefined) {
|
|
22
|
+
const root = await realpath(options.allowedRoot);
|
|
23
|
+
const relative = path.relative(root, sourcePath);
|
|
24
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
25
|
+
throw new ScanToolError("path_outside_allowed_root", "SBOM path is outside the allowed root");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const maxBytes = options.maxInputBytes ?? DEFAULT_MAX_INPUT_BYTES;
|
|
29
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
30
|
+
throw new RangeError("Invalid SBOM size limit");
|
|
31
|
+
const tooLarge = () => new ScanToolError("sbom_too_large", `SBOM input exceeds ${maxBytes} bytes`);
|
|
32
|
+
let bytes;
|
|
33
|
+
try {
|
|
34
|
+
// Nonblocking open avoids hanging on FIFOs. Refuse a leaf replaced by a symlink.
|
|
35
|
+
const file = await open(sourcePath, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
|
|
36
|
+
try {
|
|
37
|
+
const info = await file.stat();
|
|
38
|
+
if (!info.isFile())
|
|
39
|
+
throw new ScanToolError("sbom_not_found", "SBOM input must be a regular file");
|
|
40
|
+
if (info.size > maxBytes)
|
|
41
|
+
throw tooLarge();
|
|
42
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes + 1));
|
|
43
|
+
const chunks = [];
|
|
44
|
+
let total = 0;
|
|
45
|
+
while (true) {
|
|
46
|
+
const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, maxBytes - total + 1), null);
|
|
47
|
+
if (bytesRead === 0)
|
|
48
|
+
break;
|
|
49
|
+
total += bytesRead;
|
|
50
|
+
if (total > maxBytes)
|
|
51
|
+
throw tooLarge();
|
|
52
|
+
chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
|
|
53
|
+
}
|
|
54
|
+
bytes = Buffer.concat(chunks, total);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await file.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error instanceof ScanToolError)
|
|
62
|
+
throw error;
|
|
63
|
+
throw new ScanToolError("sbom_not_found", "The specified SBOM file could not be read");
|
|
64
|
+
}
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new ScanToolError("invalid_sbom", "SBOM input must be UTF-8 JSON; XML and tag-value formats are not supported");
|
|
71
|
+
}
|
|
72
|
+
const document = asRecord(raw);
|
|
73
|
+
let format;
|
|
74
|
+
let specVersion;
|
|
75
|
+
if (document?.bomFormat === "CycloneDX" && document.spdxVersion === undefined) {
|
|
76
|
+
if (typeof document.specVersion !== "string" || !CYCLONEDX_VERSIONS.has(document.specVersion)) {
|
|
77
|
+
throw new ScanToolError("unsupported_sbom_format", "Supported CycloneDX JSON versions: 1.4, 1.5, 1.6");
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(document.components) || document.components.some((item) => asRecord(item) === null)) {
|
|
80
|
+
throw new ScanToolError("invalid_sbom", "CycloneDX input must contain a components array of objects");
|
|
81
|
+
}
|
|
82
|
+
format = "CycloneDX";
|
|
83
|
+
specVersion = document.specVersion;
|
|
84
|
+
}
|
|
85
|
+
else if (typeof document?.spdxVersion === "string" && document.bomFormat === undefined) {
|
|
86
|
+
if (!SPDX_VERSIONS.has(document.spdxVersion)) {
|
|
87
|
+
throw new ScanToolError("unsupported_sbom_format", "Supported SPDX JSON versions: 2.2, 2.3");
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(document.packages) || document.packages.some((item) => asRecord(item) === null)) {
|
|
90
|
+
throw new ScanToolError("invalid_sbom", "SPDX input must contain a packages array of objects");
|
|
91
|
+
}
|
|
92
|
+
format = "SPDX";
|
|
93
|
+
specVersion = document.spdxVersion;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
throw new ScanToolError("unsupported_sbom_format", "Expected a CycloneDX or SPDX JSON document");
|
|
97
|
+
}
|
|
98
|
+
return { sourcePath, format, specVersion, bytes, sha256: createHash("sha256").update(bytes).digest("hex") };
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "osv-scanner-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP server that wraps Google's OSV-Scanner to scan Java projects for known vulnerabilities",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
"prepublishOnly": "npm run typecheck && npm test"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@types/node": "^26.
|
|
46
|
-
"@vitest/coverage-v8": "^
|
|
45
|
+
"@types/node": "^26.5.0",
|
|
46
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
47
47
|
"typescript": "^7.0.2",
|
|
48
|
-
"vitest": "^
|
|
48
|
+
"vitest": "^5.0.1"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
51
|
"@modelcontextprotocol/sdk": "^1.29.0",
|