jar-viewer-mcp 0.1.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 +28 -0
- package/dist/index.js +914 -0
- package/dist/lib/cfr-0.152.jar +0 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Java Jar Viewer MCP
|
|
2
|
+
|
|
3
|
+
MCP server that lets an LLM browse JAR contents, attach `*-sources.jar` source, and decompile `.class` files with CFR. It also runs Maven/Gradle dependency resolution to surface absolute paths for local artifacts.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
- Node.js 18+
|
|
7
|
+
- Java 8+ (JDK with `javap`) on PATH (for CFR and describe_class)
|
|
8
|
+
- Maven on PATH when using `scan_project_dependencies` for Maven projects
|
|
9
|
+
- Gradle Wrapper (`./gradlew`) or Gradle on PATH when using `scan_project_dependencies` for Gradle projects
|
|
10
|
+
|
|
11
|
+
## Install & Run
|
|
12
|
+
```bash
|
|
13
|
+
npm install
|
|
14
|
+
npm run build
|
|
15
|
+
node dist/index.js # or add to your MCP registry
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Tools
|
|
19
|
+
- `list_jar_entries(jarPath, innerPath?)`: Lists up to 100 items from the JAR, folding by directory level for quick navigation.
|
|
20
|
+
- `read_jar_entry(jarPath, entryPath)`: Reads the requested entry. For `.class`, it first looks for a sibling `*-sources.jar` and otherwise decompiles with CFR; falls back to `javap` signatures if needed.
|
|
21
|
+
- `describe_class(jarPath, className?, entryPath?, memberVisibility?, methodQuery?, limit?)`: Returns method signatures for a class using `javap` (no decompilation). Use `memberVisibility="public"` (default) or `"all"` for all members.
|
|
22
|
+
- `resolve_class(projectPath, className, dependencyQuery?, includeMembers?, memberVisibility?, methodQuery?, limit?)`: Locates the class inside project dependency jars. If `includeMembers=true`, also returns method signatures (same filters as `describe_class`).
|
|
23
|
+
- `scan_project_dependencies(projectPath, excludeTransitive?, configurations?, includeLogTail?, query?)`: Detects Maven/Gradle projects (by `pom.xml` or `build.gradle(.kts)`/`settings.gradle(.kts)`), then resolves absolute artifact paths. Uses `mvn dependency:list` for Maven, and an injected Gradle init script (`mcpListDeps`) for Gradle. Results are cached per project root. `query` does a case-insensitive substring match on `groupId:artifactId` and the artifact path.
|
|
24
|
+
- `excludeTransitive`: set to `true` to return only first-level dependencies.
|
|
25
|
+
- `configurations`: Gradle-only list of configuration names to include (e.g. `["runtimeClasspath"]`).
|
|
26
|
+
- `includeLogTail`: set to `true` to include the last lines of build output for debugging.
|
|
27
|
+
|
|
28
|
+
`lib/cfr-0.152.jar` is bundled and copied into `dist/lib` during `npm run build`; paths are resolved at runtime via `import.meta.url` to avoid hard-coding.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import fsPromises from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import AdmZip from "adm-zip";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const CFR_FILENAME = "cfr-0.152.jar";
|
|
14
|
+
const MAX_LIST_ENTRIES = 100;
|
|
15
|
+
const MAX_TEXT_BYTES = 200_000;
|
|
16
|
+
const listJarEntriesSchema = z.object({
|
|
17
|
+
jarPath: z.string().min(1, "jarPath is required"),
|
|
18
|
+
innerPath: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
const readJarEntrySchema = z.object({
|
|
21
|
+
jarPath: z.string().min(1, "jarPath is required"),
|
|
22
|
+
entryPath: z.string().min(1, "entryPath is required"),
|
|
23
|
+
});
|
|
24
|
+
const scanDependenciesSchema = z.object({
|
|
25
|
+
projectPath: z.string().min(1, "projectPath is required"),
|
|
26
|
+
excludeTransitive: z.boolean().optional(),
|
|
27
|
+
configurations: z.array(z.string().min(1)).optional(),
|
|
28
|
+
includeLogTail: z.boolean().optional(),
|
|
29
|
+
query: z.string().optional(),
|
|
30
|
+
});
|
|
31
|
+
const describeClassSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
jarPath: z.string().min(1, "jarPath is required"),
|
|
34
|
+
className: z.string().optional(),
|
|
35
|
+
entryPath: z.string().optional(),
|
|
36
|
+
memberVisibility: z.enum(["public", "all"]).optional(),
|
|
37
|
+
methodQuery: z.string().optional(),
|
|
38
|
+
limit: z.number().int().positive().optional(),
|
|
39
|
+
})
|
|
40
|
+
.refine((data) => Boolean(data.className || data.entryPath), {
|
|
41
|
+
message: "className or entryPath is required",
|
|
42
|
+
});
|
|
43
|
+
const resolveClassSchema = z.object({
|
|
44
|
+
projectPath: z.string().min(1, "projectPath is required"),
|
|
45
|
+
className: z.string().min(1, "className is required"),
|
|
46
|
+
dependencyQuery: z.string().optional(),
|
|
47
|
+
includeMembers: z.boolean().optional(),
|
|
48
|
+
memberVisibility: z.enum(["public", "all"]).optional(),
|
|
49
|
+
methodQuery: z.string().optional(),
|
|
50
|
+
limit: z.number().int().positive().optional(),
|
|
51
|
+
});
|
|
52
|
+
function normalizeJarEntry(p) {
|
|
53
|
+
if (!p)
|
|
54
|
+
return "";
|
|
55
|
+
return p.replace(/^[/\\]+/, "").replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, "");
|
|
56
|
+
}
|
|
57
|
+
async function fileExists(filePath) {
|
|
58
|
+
try {
|
|
59
|
+
await fsPromises.access(filePath, fs.constants.R_OK);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function normalizeConfigurations(configurations) {
|
|
67
|
+
if (!configurations || configurations.length === 0)
|
|
68
|
+
return [];
|
|
69
|
+
const unique = new Set(configurations.map((value) => value.trim()).filter((value) => value.length > 0));
|
|
70
|
+
return Array.from(unique).sort((a, b) => a.localeCompare(b));
|
|
71
|
+
}
|
|
72
|
+
function normalizeQuery(value) {
|
|
73
|
+
if (!value)
|
|
74
|
+
return null;
|
|
75
|
+
const trimmed = value.trim();
|
|
76
|
+
if (!trimmed)
|
|
77
|
+
return null;
|
|
78
|
+
return trimmed.toLowerCase();
|
|
79
|
+
}
|
|
80
|
+
function filterDependenciesByQuery(dependencies, query) {
|
|
81
|
+
const normalized = normalizeQuery(query);
|
|
82
|
+
if (!normalized)
|
|
83
|
+
return dependencies;
|
|
84
|
+
return dependencies.filter((dep) => {
|
|
85
|
+
const name = `${dep.groupId}:${dep.artifactId}`.toLowerCase();
|
|
86
|
+
if (name.includes(normalized))
|
|
87
|
+
return true;
|
|
88
|
+
return dep.path.toLowerCase().includes(normalized);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function buildDependencyCacheKey(projectRoot, options) {
|
|
92
|
+
return JSON.stringify({
|
|
93
|
+
projectRoot,
|
|
94
|
+
excludeTransitive: Boolean(options.excludeTransitive),
|
|
95
|
+
configurations: normalizeConfigurations(options.configurations),
|
|
96
|
+
includeLogTail: Boolean(options.includeLogTail),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function escapeGroovyString(value) {
|
|
100
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
101
|
+
}
|
|
102
|
+
function formatGroovyStringList(values) {
|
|
103
|
+
const normalized = normalizeConfigurations(values);
|
|
104
|
+
if (normalized.length === 0)
|
|
105
|
+
return "[]";
|
|
106
|
+
const items = normalized.map((value) => `"${escapeGroovyString(value)}"`);
|
|
107
|
+
return `[${items.join(", ")}]`;
|
|
108
|
+
}
|
|
109
|
+
async function findProjectMarkers(dir, markers) {
|
|
110
|
+
const found = [];
|
|
111
|
+
for (const marker of markers) {
|
|
112
|
+
if (await fileExists(path.join(dir, marker))) {
|
|
113
|
+
found.push(marker);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return found;
|
|
117
|
+
}
|
|
118
|
+
async function detectProjectType(startPath) {
|
|
119
|
+
let current = path.resolve(startPath);
|
|
120
|
+
const stat = await fsPromises.stat(current).catch(() => null);
|
|
121
|
+
if (stat?.isFile()) {
|
|
122
|
+
current = path.dirname(current);
|
|
123
|
+
}
|
|
124
|
+
const mavenMarkers = ["pom.xml"];
|
|
125
|
+
const gradleMarkers = ["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"];
|
|
126
|
+
while (true) {
|
|
127
|
+
const foundMaven = await findProjectMarkers(current, mavenMarkers);
|
|
128
|
+
if (foundMaven.length > 0) {
|
|
129
|
+
return { type: "maven", root: current, markers: foundMaven };
|
|
130
|
+
}
|
|
131
|
+
const foundGradle = await findProjectMarkers(current, gradleMarkers);
|
|
132
|
+
if (foundGradle.length > 0) {
|
|
133
|
+
return { type: "gradle", root: current, markers: foundGradle };
|
|
134
|
+
}
|
|
135
|
+
const parent = path.dirname(current);
|
|
136
|
+
if (parent === current)
|
|
137
|
+
break;
|
|
138
|
+
current = parent;
|
|
139
|
+
}
|
|
140
|
+
return { type: "native", root: null, markers: [] };
|
|
141
|
+
}
|
|
142
|
+
function requiredProjectTypeForCommand(command) {
|
|
143
|
+
const normalized = path.basename(command).toLowerCase();
|
|
144
|
+
if (normalized === "mvn" || normalized === "mvn.cmd")
|
|
145
|
+
return "maven";
|
|
146
|
+
if (normalized === "gradle" ||
|
|
147
|
+
normalized === "gradle.bat" ||
|
|
148
|
+
normalized === "gradlew" ||
|
|
149
|
+
normalized === "gradlew.bat") {
|
|
150
|
+
return "gradle";
|
|
151
|
+
}
|
|
152
|
+
return "any";
|
|
153
|
+
}
|
|
154
|
+
function escapeRegExp(value) {
|
|
155
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
156
|
+
}
|
|
157
|
+
function limitText(text, limit = MAX_TEXT_BYTES) {
|
|
158
|
+
if (text.length <= limit) {
|
|
159
|
+
return { text, truncated: false };
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
text: `${text.slice(0, limit)}\n\n// [Truncated output at ${limit} characters]`,
|
|
163
|
+
truncated: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async function runCommand(command, args, options = {}) {
|
|
167
|
+
const projectContext = options.projectPath ?? options.cwd ?? process.cwd();
|
|
168
|
+
const project = await detectProjectType(projectContext);
|
|
169
|
+
const requiredType = requiredProjectTypeForCommand(command);
|
|
170
|
+
if (requiredType !== "any" && project.type !== requiredType) {
|
|
171
|
+
const rootLabel = project.root ? ` (root: ${project.root})` : "";
|
|
172
|
+
throw new Error(`Command "${command}" requires a ${requiredType} project, but detected ${project.type}${rootLabel}.`);
|
|
173
|
+
}
|
|
174
|
+
return await new Promise((resolve, reject) => {
|
|
175
|
+
const child = spawn(command, args, {
|
|
176
|
+
cwd: options.cwd,
|
|
177
|
+
shell: options.shell ?? false,
|
|
178
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
179
|
+
});
|
|
180
|
+
const stdoutChunks = [];
|
|
181
|
+
const stderrChunks = [];
|
|
182
|
+
child.stdout?.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
|
|
183
|
+
child.stderr?.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
|
|
184
|
+
child.on("error", (error) => reject(error));
|
|
185
|
+
child.on("close", (code) => resolve({
|
|
186
|
+
code,
|
|
187
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
|
|
188
|
+
stderr: Buffer.concat(stderrChunks).toString("utf-8"),
|
|
189
|
+
project,
|
|
190
|
+
}));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
function isEnoent(error) {
|
|
194
|
+
return Boolean(error &&
|
|
195
|
+
typeof error === "object" &&
|
|
196
|
+
"code" in error &&
|
|
197
|
+
error.code === "ENOENT");
|
|
198
|
+
}
|
|
199
|
+
async function resolveCfrJar() {
|
|
200
|
+
const candidates = [
|
|
201
|
+
path.resolve(__dirname, "lib", CFR_FILENAME),
|
|
202
|
+
path.resolve(__dirname, "../lib", CFR_FILENAME),
|
|
203
|
+
path.resolve(process.cwd(), "lib", CFR_FILENAME),
|
|
204
|
+
];
|
|
205
|
+
for (const candidate of candidates) {
|
|
206
|
+
if (await fileExists(candidate)) {
|
|
207
|
+
return candidate;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
throw new Error(`Unable to locate ${CFR_FILENAME}. Ensure it is available in ./dist/lib or ./lib.`);
|
|
211
|
+
}
|
|
212
|
+
async function readEntryTextFromJar(jarPath, entryPath) {
|
|
213
|
+
const normalizedEntry = normalizeJarEntry(entryPath);
|
|
214
|
+
const zip = new AdmZip(jarPath);
|
|
215
|
+
const entry = zip.getEntry(normalizedEntry);
|
|
216
|
+
if (!entry || entry.isDirectory) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
const data = entry.getData();
|
|
220
|
+
const { text } = limitText(data.toString("utf-8"));
|
|
221
|
+
return text;
|
|
222
|
+
}
|
|
223
|
+
function sourceJarPathFor(jarPath) {
|
|
224
|
+
const jarDir = path.dirname(jarPath);
|
|
225
|
+
const baseName = path.basename(jarPath, ".jar");
|
|
226
|
+
return path.join(jarDir, `${baseName}-sources.jar`);
|
|
227
|
+
}
|
|
228
|
+
function classNameFromEntry(entryPath) {
|
|
229
|
+
return normalizeJarEntry(entryPath)
|
|
230
|
+
.replace(/\.class$/i, "")
|
|
231
|
+
.replace(/\//g, ".")
|
|
232
|
+
.replace(/^\.+/, "");
|
|
233
|
+
}
|
|
234
|
+
function normalizeClassName(value) {
|
|
235
|
+
return value
|
|
236
|
+
.trim()
|
|
237
|
+
.replace(/<.*$/, "")
|
|
238
|
+
.replace(/\.class$/i, "")
|
|
239
|
+
.replace(/[\\/]/g, ".")
|
|
240
|
+
.replace(/^\.+/, "");
|
|
241
|
+
}
|
|
242
|
+
function classEntryPathFromName(value) {
|
|
243
|
+
const normalized = normalizeClassName(value);
|
|
244
|
+
if (!normalized)
|
|
245
|
+
return "";
|
|
246
|
+
return `${normalized.replace(/\./g, "/")}.class`;
|
|
247
|
+
}
|
|
248
|
+
function resolveClassNameInput(className, entryPath) {
|
|
249
|
+
if (className && className.trim()) {
|
|
250
|
+
return normalizeClassName(className);
|
|
251
|
+
}
|
|
252
|
+
if (entryPath && entryPath.trim()) {
|
|
253
|
+
const normalizedEntry = normalizeJarEntry(entryPath);
|
|
254
|
+
if (!normalizedEntry.toLowerCase().endsWith(".class")) {
|
|
255
|
+
throw new Error("entryPath must point to a .class entry");
|
|
256
|
+
}
|
|
257
|
+
return classNameFromEntry(normalizedEntry);
|
|
258
|
+
}
|
|
259
|
+
throw new Error("className or entryPath is required");
|
|
260
|
+
}
|
|
261
|
+
function parseJavapMethodSignatures(output) {
|
|
262
|
+
const methods = [];
|
|
263
|
+
for (const rawLine of output.split(/\r?\n/)) {
|
|
264
|
+
const line = rawLine.trim();
|
|
265
|
+
if (!line)
|
|
266
|
+
continue;
|
|
267
|
+
if (line === "{" || line === "}")
|
|
268
|
+
continue;
|
|
269
|
+
if (!line.includes("(") || !line.includes(")"))
|
|
270
|
+
continue;
|
|
271
|
+
if (!line.endsWith(";"))
|
|
272
|
+
continue;
|
|
273
|
+
methods.push(line);
|
|
274
|
+
}
|
|
275
|
+
return methods;
|
|
276
|
+
}
|
|
277
|
+
function filterMethodSignatures(methods, query) {
|
|
278
|
+
const normalized = normalizeQuery(query);
|
|
279
|
+
if (!normalized)
|
|
280
|
+
return methods;
|
|
281
|
+
return methods.filter((method) => method.toLowerCase().includes(normalized));
|
|
282
|
+
}
|
|
283
|
+
class JarViewerService {
|
|
284
|
+
dependencyCache = new Map();
|
|
285
|
+
async listJarEntries(jarPath, innerPath) {
|
|
286
|
+
const resolvedJar = path.resolve(jarPath);
|
|
287
|
+
if (!(await fileExists(resolvedJar))) {
|
|
288
|
+
throw new Error(`Jar file not found at ${resolvedJar}`);
|
|
289
|
+
}
|
|
290
|
+
const normalizedInner = normalizeJarEntry(innerPath);
|
|
291
|
+
const zip = new AdmZip(resolvedJar);
|
|
292
|
+
const directories = new Map();
|
|
293
|
+
const files = [];
|
|
294
|
+
for (const entry of zip.getEntries()) {
|
|
295
|
+
const name = normalizeJarEntry(entry.entryName);
|
|
296
|
+
if (!name)
|
|
297
|
+
continue;
|
|
298
|
+
if (normalizedInner) {
|
|
299
|
+
if (name === normalizedInner)
|
|
300
|
+
continue;
|
|
301
|
+
if (!name.startsWith(`${normalizedInner}/`))
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const relative = normalizedInner ? name.slice(normalizedInner.length).replace(/^\/+/, "") : name;
|
|
305
|
+
if (!relative)
|
|
306
|
+
continue;
|
|
307
|
+
const [head, ...rest] = relative.split("/");
|
|
308
|
+
if (!head)
|
|
309
|
+
continue;
|
|
310
|
+
if (rest.length > 0) {
|
|
311
|
+
directories.set(`${head}/`, true);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
files.push({
|
|
315
|
+
path: head,
|
|
316
|
+
directory: entry.isDirectory,
|
|
317
|
+
size: entry.header?.size ?? 0,
|
|
318
|
+
compressedSize: entry.header?.compressedSize ?? 0,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const entries = [
|
|
323
|
+
...Array.from(directories.keys()).map((dir) => ({
|
|
324
|
+
path: dir,
|
|
325
|
+
directory: true,
|
|
326
|
+
size: 0,
|
|
327
|
+
compressedSize: 0,
|
|
328
|
+
})),
|
|
329
|
+
...files,
|
|
330
|
+
].sort((a, b) => a.path.localeCompare(b.path));
|
|
331
|
+
const truncated = entries.length > MAX_LIST_ENTRIES;
|
|
332
|
+
return {
|
|
333
|
+
jarPath: resolvedJar,
|
|
334
|
+
innerPath: normalizedInner || "/",
|
|
335
|
+
total: entries.length,
|
|
336
|
+
truncated,
|
|
337
|
+
entries: entries.slice(0, MAX_LIST_ENTRIES),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
async readJarEntry(jarPath, entryPath) {
|
|
341
|
+
const resolvedJar = path.resolve(jarPath);
|
|
342
|
+
const normalizedEntry = normalizeJarEntry(entryPath);
|
|
343
|
+
if (!(await fileExists(resolvedJar))) {
|
|
344
|
+
throw new Error(`Jar file not found at ${resolvedJar}`);
|
|
345
|
+
}
|
|
346
|
+
if (!normalizedEntry) {
|
|
347
|
+
throw new Error("entryPath is required");
|
|
348
|
+
}
|
|
349
|
+
const ext = path.extname(normalizedEntry).toLowerCase();
|
|
350
|
+
if (ext !== ".class") {
|
|
351
|
+
const resourceContent = await readEntryTextFromJar(resolvedJar, normalizedEntry);
|
|
352
|
+
if (resourceContent === null) {
|
|
353
|
+
throw new Error(`Entry ${normalizedEntry} not found in ${resolvedJar}`);
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
content: resourceContent,
|
|
357
|
+
entryPath: normalizedEntry,
|
|
358
|
+
source: "resource",
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const sourceResult = await this.tryReadFromSourceJar(resolvedJar, normalizedEntry);
|
|
362
|
+
if (sourceResult) {
|
|
363
|
+
return sourceResult;
|
|
364
|
+
}
|
|
365
|
+
return await this.decompileWithCfr(resolvedJar, normalizedEntry);
|
|
366
|
+
}
|
|
367
|
+
async describeClass(options) {
|
|
368
|
+
const { jarPath, className, entryPath, memberVisibility = "public", methodQuery, limit, } = options;
|
|
369
|
+
const resolvedJar = path.resolve(jarPath);
|
|
370
|
+
if (!(await fileExists(resolvedJar))) {
|
|
371
|
+
throw new Error(`Jar file not found at ${resolvedJar}`);
|
|
372
|
+
}
|
|
373
|
+
const resolvedClassName = resolveClassNameInput(className, entryPath);
|
|
374
|
+
const visibility = memberVisibility === "all" ? "all" : "public";
|
|
375
|
+
const visibilityFlag = visibility === "all" ? "-private" : "-public";
|
|
376
|
+
let commandResult;
|
|
377
|
+
try {
|
|
378
|
+
commandResult = await runCommand("javap", [visibilityFlag, "-classpath", resolvedJar, resolvedClassName], { projectPath: resolvedJar });
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
if (isEnoent(error)) {
|
|
382
|
+
throw new Error("Java class inspector (javap) was not found on PATH.");
|
|
383
|
+
}
|
|
384
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
385
|
+
}
|
|
386
|
+
const { code, stdout, stderr } = commandResult;
|
|
387
|
+
if (code !== 0) {
|
|
388
|
+
throw new Error(`javap failed with code ${code}: ${stderr || stdout || "no output"}`);
|
|
389
|
+
}
|
|
390
|
+
const output = stdout || stderr || "";
|
|
391
|
+
let methods = parseJavapMethodSignatures(output);
|
|
392
|
+
methods = filterMethodSignatures(methods, methodQuery);
|
|
393
|
+
const total = methods.length;
|
|
394
|
+
const limitValue = limit && limit > 0 ? limit : undefined;
|
|
395
|
+
const truncated = Boolean(limitValue && total > limitValue);
|
|
396
|
+
if (limitValue) {
|
|
397
|
+
methods = methods.slice(0, limitValue);
|
|
398
|
+
}
|
|
399
|
+
const normalizedMethodQuery = methodQuery?.trim() ? methodQuery.trim() : undefined;
|
|
400
|
+
return {
|
|
401
|
+
jarPath: resolvedJar,
|
|
402
|
+
className: resolvedClassName,
|
|
403
|
+
visibility,
|
|
404
|
+
methodQuery: normalizedMethodQuery,
|
|
405
|
+
total,
|
|
406
|
+
truncated,
|
|
407
|
+
methods,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
async resolveClass(options) {
|
|
411
|
+
const { projectPath, className, dependencyQuery, includeMembers = false, memberVisibility, methodQuery, limit, } = options;
|
|
412
|
+
const resolvedProject = path.resolve(projectPath);
|
|
413
|
+
const normalizedClassName = normalizeClassName(className);
|
|
414
|
+
if (!normalizedClassName) {
|
|
415
|
+
throw new Error("className is required");
|
|
416
|
+
}
|
|
417
|
+
const entryPath = classEntryPathFromName(normalizedClassName);
|
|
418
|
+
if (!entryPath) {
|
|
419
|
+
throw new Error("className is required");
|
|
420
|
+
}
|
|
421
|
+
const dependenciesResult = await this.scanProjectDependencies({
|
|
422
|
+
projectPath: resolvedProject,
|
|
423
|
+
query: dependencyQuery,
|
|
424
|
+
});
|
|
425
|
+
let searchedJars = 0;
|
|
426
|
+
let resolvedJarPath = null;
|
|
427
|
+
for (const dependency of dependenciesResult.dependencies) {
|
|
428
|
+
const depPath = dependency.path;
|
|
429
|
+
if (!depPath)
|
|
430
|
+
continue;
|
|
431
|
+
if (path.extname(depPath).toLowerCase() !== ".jar")
|
|
432
|
+
continue;
|
|
433
|
+
const candidate = path.resolve(depPath);
|
|
434
|
+
if (!(await fileExists(candidate)))
|
|
435
|
+
continue;
|
|
436
|
+
searchedJars += 1;
|
|
437
|
+
try {
|
|
438
|
+
const zip = new AdmZip(candidate);
|
|
439
|
+
const entry = zip.getEntry(entryPath);
|
|
440
|
+
if (entry && !entry.isDirectory) {
|
|
441
|
+
resolvedJarPath = candidate;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
let methods;
|
|
450
|
+
let methodCount;
|
|
451
|
+
let methodsTruncated;
|
|
452
|
+
if (resolvedJarPath && includeMembers) {
|
|
453
|
+
const describeResult = await this.describeClass({
|
|
454
|
+
jarPath: resolvedJarPath,
|
|
455
|
+
className: normalizedClassName,
|
|
456
|
+
memberVisibility,
|
|
457
|
+
methodQuery,
|
|
458
|
+
limit,
|
|
459
|
+
});
|
|
460
|
+
methods = describeResult.methods;
|
|
461
|
+
methodCount = describeResult.total;
|
|
462
|
+
methodsTruncated = describeResult.truncated;
|
|
463
|
+
}
|
|
464
|
+
const trimmedQuery = dependencyQuery?.trim();
|
|
465
|
+
return {
|
|
466
|
+
projectPath: resolvedProject,
|
|
467
|
+
className: normalizedClassName,
|
|
468
|
+
entryPath,
|
|
469
|
+
jarPath: resolvedJarPath,
|
|
470
|
+
projectRoot: dependenciesResult.projectRoot,
|
|
471
|
+
projectType: dependenciesResult.projectType,
|
|
472
|
+
dependencyQuery: trimmedQuery ? trimmedQuery : undefined,
|
|
473
|
+
dependencyCount: dependenciesResult.dependencies.length,
|
|
474
|
+
searchedJars,
|
|
475
|
+
cachedDependencies: dependenciesResult.cached,
|
|
476
|
+
found: Boolean(resolvedJarPath),
|
|
477
|
+
methods,
|
|
478
|
+
methodCount,
|
|
479
|
+
methodsTruncated,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
async tryReadFromSourceJar(jarPath, entryPath) {
|
|
483
|
+
const sourceJar = sourceJarPathFor(jarPath);
|
|
484
|
+
if (!(await fileExists(sourceJar))) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
const javaEntry = entryPath.replace(/\.class$/i, ".java");
|
|
488
|
+
const content = await readEntryTextFromJar(sourceJar, javaEntry);
|
|
489
|
+
if (content === null) {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
content: `// Source: Attached (${path.basename(sourceJar)})\n${content}`,
|
|
494
|
+
entryPath: javaEntry,
|
|
495
|
+
sourceJar,
|
|
496
|
+
source: "source",
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
async decompileWithCfr(jarPath, entryPath) {
|
|
500
|
+
const cfrJar = await resolveCfrJar();
|
|
501
|
+
const className = classNameFromEntry(entryPath);
|
|
502
|
+
const outputDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "jar-viewer-cfr-"));
|
|
503
|
+
const jarFilter = `^${escapeRegExp(className)}$`;
|
|
504
|
+
const outputJavaPath = path.join(outputDir, entryPath.replace(/\.class$/i, ".java"));
|
|
505
|
+
try {
|
|
506
|
+
let commandResult;
|
|
507
|
+
try {
|
|
508
|
+
commandResult = await runCommand("java", [
|
|
509
|
+
"-jar",
|
|
510
|
+
cfrJar,
|
|
511
|
+
jarPath,
|
|
512
|
+
"--outputdir",
|
|
513
|
+
outputDir,
|
|
514
|
+
"--jarfilter",
|
|
515
|
+
jarFilter,
|
|
516
|
+
"--silent",
|
|
517
|
+
"true",
|
|
518
|
+
], { projectPath: jarPath });
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
if (isEnoent(error)) {
|
|
522
|
+
throw new Error("Java runtime (java) was not found on PATH.");
|
|
523
|
+
}
|
|
524
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
525
|
+
}
|
|
526
|
+
const { code, stdout, stderr } = commandResult;
|
|
527
|
+
if (code !== 0) {
|
|
528
|
+
throw new Error(`CFR exited with code ${code}: ${stderr || stdout}`);
|
|
529
|
+
}
|
|
530
|
+
const javaSource = await fsPromises.readFile(outputJavaPath, "utf-8").catch(() => null);
|
|
531
|
+
if (!javaSource) {
|
|
532
|
+
const signature = await this.tryJavapSignature(jarPath, className);
|
|
533
|
+
if (signature) {
|
|
534
|
+
return {
|
|
535
|
+
content: `// javap signature fallback\n${signature}`,
|
|
536
|
+
entryPath,
|
|
537
|
+
source: "summary",
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
throw new Error(`CFR did not produce output for ${entryPath}`);
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
content: `// Decompiled via CFR\n${javaSource}`,
|
|
544
|
+
entryPath: entryPath.replace(/\.class$/i, ".java"),
|
|
545
|
+
source: "decompiled",
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
finally {
|
|
549
|
+
await fsPromises.rm(outputDir, { recursive: true, force: true }).catch(() => { });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async tryJavapSignature(jarPath, className) {
|
|
553
|
+
try {
|
|
554
|
+
const { code, stdout, stderr } = await runCommand("javap", ["-classpath", jarPath, "-public", className], { projectPath: jarPath });
|
|
555
|
+
if (code !== 0) {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
return stdout || stderr || null;
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
async resolveGradleCommand(projectRoot) {
|
|
565
|
+
const wrapperName = process.platform === "win32" ? "gradlew.bat" : "gradlew";
|
|
566
|
+
const wrapperPath = path.join(projectRoot, wrapperName);
|
|
567
|
+
if (await fileExists(wrapperPath)) {
|
|
568
|
+
return { command: wrapperPath, shell: process.platform === "win32" };
|
|
569
|
+
}
|
|
570
|
+
return { command: "gradle", shell: false };
|
|
571
|
+
}
|
|
572
|
+
async scanProjectDependencies(options) {
|
|
573
|
+
const { projectPath, excludeTransitive = false, configurations, includeLogTail = false, query, } = options;
|
|
574
|
+
const resolvedProject = path.resolve(projectPath);
|
|
575
|
+
const projectInfo = await detectProjectType(resolvedProject);
|
|
576
|
+
if (projectInfo.type === "native") {
|
|
577
|
+
throw new Error(`No Maven or Gradle project detected at or above ${resolvedProject}.`);
|
|
578
|
+
}
|
|
579
|
+
const projectRoot = projectInfo.root ?? resolvedProject;
|
|
580
|
+
const cacheKey = buildDependencyCacheKey(projectRoot, {
|
|
581
|
+
excludeTransitive,
|
|
582
|
+
configurations,
|
|
583
|
+
includeLogTail,
|
|
584
|
+
});
|
|
585
|
+
const cached = this.dependencyCache.get(cacheKey);
|
|
586
|
+
if (cached) {
|
|
587
|
+
const filteredDependencies = filterDependenciesByQuery(cached.dependencies, query);
|
|
588
|
+
return {
|
|
589
|
+
...cached,
|
|
590
|
+
cached: true,
|
|
591
|
+
projectPath: resolvedProject,
|
|
592
|
+
projectRoot,
|
|
593
|
+
projectType: projectInfo.type,
|
|
594
|
+
dependencies: filteredDependencies,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
let result;
|
|
598
|
+
if (projectInfo.type === "maven") {
|
|
599
|
+
result = await this.scanMavenDependencies(resolvedProject, projectRoot, {
|
|
600
|
+
excludeTransitive,
|
|
601
|
+
includeLogTail,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
result = await this.scanGradleDependencies(resolvedProject, projectRoot, {
|
|
606
|
+
excludeTransitive,
|
|
607
|
+
configurations,
|
|
608
|
+
includeLogTail,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
this.dependencyCache.set(cacheKey, result);
|
|
612
|
+
const filteredDependencies = filterDependenciesByQuery(result.dependencies, query);
|
|
613
|
+
if (filteredDependencies === result.dependencies) {
|
|
614
|
+
return result;
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
...result,
|
|
618
|
+
dependencies: filteredDependencies,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
async scanMavenDependencies(resolvedProject, projectRoot, options) {
|
|
622
|
+
const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "jar-viewer-mvn-"));
|
|
623
|
+
const outputFile = path.join(tempDir, "dependencies.txt");
|
|
624
|
+
try {
|
|
625
|
+
const mvnArgs = [
|
|
626
|
+
"dependency:list",
|
|
627
|
+
"-DoutputAbsoluteArtifactFilename=true",
|
|
628
|
+
"-DincludeScope=runtime",
|
|
629
|
+
"-DappendOutput=false",
|
|
630
|
+
`-DoutputFile=${outputFile}`,
|
|
631
|
+
"-B",
|
|
632
|
+
];
|
|
633
|
+
if (options.excludeTransitive) {
|
|
634
|
+
mvnArgs.push("-DexcludeTransitive=true");
|
|
635
|
+
}
|
|
636
|
+
const { code, stdout, stderr } = await runCommand("mvn", mvnArgs, {
|
|
637
|
+
cwd: projectRoot,
|
|
638
|
+
projectPath: projectRoot,
|
|
639
|
+
});
|
|
640
|
+
const fileContent = await fsPromises.readFile(outputFile, "utf-8").catch(() => "");
|
|
641
|
+
if (code !== 0) {
|
|
642
|
+
throw new Error(`mvn dependency:list failed with code ${code}. stderr: ${stderr || stdout || "no output"}`);
|
|
643
|
+
}
|
|
644
|
+
const dependencies = parseMavenDependencyList(fileContent);
|
|
645
|
+
const logTail = options.includeLogTail
|
|
646
|
+
? (stderr || stdout || "").trim().split("\n").slice(-10).join("\n")
|
|
647
|
+
: undefined;
|
|
648
|
+
const result = {
|
|
649
|
+
projectPath: resolvedProject,
|
|
650
|
+
projectRoot,
|
|
651
|
+
projectType: "maven",
|
|
652
|
+
dependencies,
|
|
653
|
+
cached: false,
|
|
654
|
+
logTail,
|
|
655
|
+
};
|
|
656
|
+
return result;
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
if (isEnoent(error)) {
|
|
660
|
+
throw new Error("Maven executable (mvn) was not found on PATH.");
|
|
661
|
+
}
|
|
662
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
663
|
+
}
|
|
664
|
+
finally {
|
|
665
|
+
await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => { });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
async scanGradleDependencies(resolvedProject, projectRoot, options) {
|
|
669
|
+
const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "jar-viewer-gradle-"));
|
|
670
|
+
const initScriptPath = path.join(tempDir, "mcp-init.gradle");
|
|
671
|
+
const allowedConfigs = formatGroovyStringList(options.configurations);
|
|
672
|
+
const initScript = [
|
|
673
|
+
`def mcpAllowedConfigs = ${allowedConfigs}`,
|
|
674
|
+
`def mcpExcludeTransitive = ${options.excludeTransitive ? "true" : "false"}`,
|
|
675
|
+
"allprojects {",
|
|
676
|
+
" task mcpListDeps {",
|
|
677
|
+
" doLast {",
|
|
678
|
+
" configurations.each { config ->",
|
|
679
|
+
" if (config.canBeResolved) {",
|
|
680
|
+
" if (!mcpAllowedConfigs.isEmpty() && !mcpAllowedConfigs.contains(config.name)) {",
|
|
681
|
+
" return",
|
|
682
|
+
" }",
|
|
683
|
+
" def artifacts = []",
|
|
684
|
+
" if (mcpExcludeTransitive) {",
|
|
685
|
+
" config.resolvedConfiguration.firstLevelModuleDependencies.each { dep ->",
|
|
686
|
+
" dep.moduleArtifacts.each { art ->",
|
|
687
|
+
" artifacts << art",
|
|
688
|
+
" }",
|
|
689
|
+
" }",
|
|
690
|
+
" } else {",
|
|
691
|
+
" artifacts = config.resolvedConfiguration.resolvedArtifacts",
|
|
692
|
+
" }",
|
|
693
|
+
" artifacts.each { art ->",
|
|
694
|
+
" def file = art.file",
|
|
695
|
+
" if (file != null) {",
|
|
696
|
+
" println \"MCP_DEP|${config.name}|${file.name}|${file.absolutePath}\"",
|
|
697
|
+
" }",
|
|
698
|
+
" }",
|
|
699
|
+
" }",
|
|
700
|
+
" }",
|
|
701
|
+
" }",
|
|
702
|
+
" }",
|
|
703
|
+
"}",
|
|
704
|
+
"",
|
|
705
|
+
].join("\n");
|
|
706
|
+
try {
|
|
707
|
+
await fsPromises.writeFile(initScriptPath, initScript, "utf-8");
|
|
708
|
+
const { command, shell } = await this.resolveGradleCommand(projectRoot);
|
|
709
|
+
const gradleArgs = ["--init-script", initScriptPath, "mcpListDeps", "-q"];
|
|
710
|
+
const { code, stdout, stderr } = await runCommand(command, gradleArgs, {
|
|
711
|
+
cwd: projectRoot,
|
|
712
|
+
projectPath: projectRoot,
|
|
713
|
+
shell,
|
|
714
|
+
});
|
|
715
|
+
if (code !== 0) {
|
|
716
|
+
throw new Error(`gradle mcpListDeps failed with code ${code}. stderr: ${stderr || stdout || "no output"}`);
|
|
717
|
+
}
|
|
718
|
+
const dependencies = parseGradleDependencyOutput(stdout);
|
|
719
|
+
const logTail = options.includeLogTail
|
|
720
|
+
? (stderr || stdout || "").trim().split("\n").slice(-10).join("\n")
|
|
721
|
+
: undefined;
|
|
722
|
+
return {
|
|
723
|
+
projectPath: resolvedProject,
|
|
724
|
+
projectRoot,
|
|
725
|
+
projectType: "gradle",
|
|
726
|
+
dependencies,
|
|
727
|
+
cached: false,
|
|
728
|
+
logTail,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
if (isEnoent(error)) {
|
|
733
|
+
throw new Error("Gradle executable (gradle/gradlew) was not found on PATH.");
|
|
734
|
+
}
|
|
735
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
736
|
+
}
|
|
737
|
+
finally {
|
|
738
|
+
await fsPromises.rm(tempDir, { recursive: true, force: true }).catch(() => { });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
function parseMavenDependencyList(output) {
|
|
743
|
+
const dependencies = [];
|
|
744
|
+
const lines = output.split(/\r?\n/);
|
|
745
|
+
for (const rawLine of lines) {
|
|
746
|
+
const line = rawLine.replace(/^\[INFO\]\s+/, "").trim();
|
|
747
|
+
if (!line || !line.includes(":"))
|
|
748
|
+
continue;
|
|
749
|
+
if (line.startsWith("---") || line.startsWith("The following"))
|
|
750
|
+
continue;
|
|
751
|
+
const parts = line.split(":");
|
|
752
|
+
if (parts.length < 5)
|
|
753
|
+
continue;
|
|
754
|
+
const pathPart = parts.pop();
|
|
755
|
+
const scope = parts.pop();
|
|
756
|
+
const version = parts.pop();
|
|
757
|
+
const classifier = parts.length > 3 ? parts.pop() : null;
|
|
758
|
+
const type = parts.pop() || "";
|
|
759
|
+
const artifactId = parts.pop() || "";
|
|
760
|
+
const groupId = parts.join(":");
|
|
761
|
+
dependencies.push({
|
|
762
|
+
groupId,
|
|
763
|
+
artifactId,
|
|
764
|
+
type,
|
|
765
|
+
classifier,
|
|
766
|
+
version,
|
|
767
|
+
scope,
|
|
768
|
+
path: pathPart,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
return dependencies;
|
|
772
|
+
}
|
|
773
|
+
function parseGradleDependencyOutput(output) {
|
|
774
|
+
const dependencies = new Map();
|
|
775
|
+
const lines = output.split(/\r?\n/);
|
|
776
|
+
for (const rawLine of lines) {
|
|
777
|
+
const line = rawLine.trim();
|
|
778
|
+
if (!line.startsWith("MCP_DEP|"))
|
|
779
|
+
continue;
|
|
780
|
+
const entry = parseGradleDependencyLine(line);
|
|
781
|
+
if (!entry)
|
|
782
|
+
continue;
|
|
783
|
+
if (dependencies.has(entry.filePath))
|
|
784
|
+
continue;
|
|
785
|
+
dependencies.set(entry.filePath, buildGradleDependencyInfo(entry));
|
|
786
|
+
}
|
|
787
|
+
return Array.from(dependencies.values());
|
|
788
|
+
}
|
|
789
|
+
function parseGradleDependencyLine(line) {
|
|
790
|
+
const parts = line.split("|");
|
|
791
|
+
if (parts.length < 4)
|
|
792
|
+
return null;
|
|
793
|
+
const configuration = parts[1]?.trim();
|
|
794
|
+
const fileName = parts[2]?.trim();
|
|
795
|
+
const filePath = parts.slice(3).join("|").trim();
|
|
796
|
+
if (!configuration || !fileName || !filePath)
|
|
797
|
+
return null;
|
|
798
|
+
return { configuration, fileName, filePath };
|
|
799
|
+
}
|
|
800
|
+
function buildGradleDependencyInfo(entry) {
|
|
801
|
+
const ext = path.extname(entry.fileName);
|
|
802
|
+
const type = ext.replace(/^\./, "") || "jar";
|
|
803
|
+
const baseName = path.basename(entry.fileName, ext);
|
|
804
|
+
const cacheCoords = parseGradleCachePath(entry.filePath, entry.fileName);
|
|
805
|
+
const groupId = cacheCoords?.groupId ?? "unknown";
|
|
806
|
+
const artifactId = cacheCoords?.artifactId ?? (baseName || "unknown");
|
|
807
|
+
const version = cacheCoords?.version ?? "unknown";
|
|
808
|
+
const classifier = cacheCoords?.classifier ?? null;
|
|
809
|
+
return {
|
|
810
|
+
groupId,
|
|
811
|
+
artifactId,
|
|
812
|
+
type,
|
|
813
|
+
classifier,
|
|
814
|
+
version,
|
|
815
|
+
scope: entry.configuration,
|
|
816
|
+
path: entry.filePath,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
function parseGradleCachePath(filePath, fileName) {
|
|
820
|
+
const normalized = filePath.split(path.sep).join("/");
|
|
821
|
+
const marker = "/modules-2/files-2.1/";
|
|
822
|
+
const markerIndex = normalized.indexOf(marker);
|
|
823
|
+
if (markerIndex === -1)
|
|
824
|
+
return null;
|
|
825
|
+
const rest = normalized.slice(markerIndex + marker.length);
|
|
826
|
+
const parts = rest.split("/");
|
|
827
|
+
if (parts.length < 4)
|
|
828
|
+
return null;
|
|
829
|
+
const groupId = parts[0];
|
|
830
|
+
const artifactId = parts[1];
|
|
831
|
+
const version = parts[2];
|
|
832
|
+
if (!groupId || !artifactId || !version)
|
|
833
|
+
return null;
|
|
834
|
+
const ext = path.extname(fileName);
|
|
835
|
+
const baseName = path.basename(fileName, ext);
|
|
836
|
+
const prefix = `${artifactId}-${version}`;
|
|
837
|
+
const classifier = baseName.startsWith(`${prefix}-`) ? baseName.slice(prefix.length + 1) : null;
|
|
838
|
+
return { groupId, artifactId, version, classifier };
|
|
839
|
+
}
|
|
840
|
+
function formatToolResult(result) {
|
|
841
|
+
return {
|
|
842
|
+
content: [
|
|
843
|
+
{
|
|
844
|
+
type: "text",
|
|
845
|
+
text: JSON.stringify(result),
|
|
846
|
+
},
|
|
847
|
+
],
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
async function main() {
|
|
851
|
+
const server = new McpServer({
|
|
852
|
+
name: "java-jar-viewer-mcp",
|
|
853
|
+
version: "0.1.0",
|
|
854
|
+
}, {
|
|
855
|
+
capabilities: {
|
|
856
|
+
tools: {},
|
|
857
|
+
},
|
|
858
|
+
instructions: "Use the provided tools to inspect JAR files. Prefer resolve_class/describe_class for method signatures or API surface and list_jar_entries before read_jar_entry to confirm exact paths; only read sources when implementation detail is needed.",
|
|
859
|
+
});
|
|
860
|
+
const service = new JarViewerService();
|
|
861
|
+
server.registerTool("list_jar_entries", {
|
|
862
|
+
title: "List JAR entries",
|
|
863
|
+
description: "List top-level entries inside a JAR (folder-style view).",
|
|
864
|
+
inputSchema: listJarEntriesSchema,
|
|
865
|
+
}, async ({ jarPath, innerPath }) => {
|
|
866
|
+
const result = await service.listJarEntries(jarPath, innerPath);
|
|
867
|
+
return formatToolResult(result);
|
|
868
|
+
});
|
|
869
|
+
server.registerTool("read_jar_entry", {
|
|
870
|
+
title: "Read JAR entry",
|
|
871
|
+
description: "Read a specific file from a JAR. For .class files, prefer attached source (-sources.jar). Falls back to CFR decompilation.",
|
|
872
|
+
inputSchema: readJarEntrySchema,
|
|
873
|
+
}, async ({ jarPath, entryPath }) => {
|
|
874
|
+
const result = await service.readJarEntry(jarPath, entryPath);
|
|
875
|
+
return {
|
|
876
|
+
content: [
|
|
877
|
+
{
|
|
878
|
+
type: "text",
|
|
879
|
+
text: result.content,
|
|
880
|
+
},
|
|
881
|
+
],
|
|
882
|
+
};
|
|
883
|
+
});
|
|
884
|
+
server.registerTool("describe_class", {
|
|
885
|
+
title: "Describe class members",
|
|
886
|
+
description: "Return method signatures for a class in a JAR using javap (no decompilation). Supports visibility and methodQuery filters for low-token API inspection.",
|
|
887
|
+
inputSchema: describeClassSchema,
|
|
888
|
+
}, async (input) => {
|
|
889
|
+
const result = await service.describeClass(input);
|
|
890
|
+
return formatToolResult(result);
|
|
891
|
+
});
|
|
892
|
+
server.registerTool("resolve_class", {
|
|
893
|
+
title: "Resolve class location",
|
|
894
|
+
description: "Locate a class inside project dependency jars and optionally return method signatures. Supports dependencyQuery to narrow jars and includeMembers for low-token API lookup.",
|
|
895
|
+
inputSchema: resolveClassSchema,
|
|
896
|
+
}, async (input) => {
|
|
897
|
+
const result = await service.resolveClass(input);
|
|
898
|
+
return formatToolResult(result);
|
|
899
|
+
});
|
|
900
|
+
server.registerTool("scan_project_dependencies", {
|
|
901
|
+
title: "Scan project dependencies",
|
|
902
|
+
description: "Resolve absolute paths for Maven/Gradle dependencies. Supports excludeTransitive, query, and Gradle configurations filters; cached per project root.",
|
|
903
|
+
inputSchema: scanDependenciesSchema,
|
|
904
|
+
}, async (input) => {
|
|
905
|
+
const result = await service.scanProjectDependencies(input);
|
|
906
|
+
return formatToolResult(result);
|
|
907
|
+
});
|
|
908
|
+
const transport = new StdioServerTransport();
|
|
909
|
+
await server.connect(transport);
|
|
910
|
+
}
|
|
911
|
+
main().catch((error) => {
|
|
912
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
913
|
+
process.exitCode = 1;
|
|
914
|
+
});
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "jar-viewer-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server that lets LLMs browse and decompile Java JARs with optional source attachment.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"jar-viewer-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"clean": "rm -rf dist",
|
|
14
|
+
"build": "npm run clean && tsc -p tsconfig.json && node scripts/copy-assets.mjs",
|
|
15
|
+
"dev": "tsx src/index.ts",
|
|
16
|
+
"lint": "tsc --noEmit",
|
|
17
|
+
"mcp:test": "node scripts/mcp-test.mjs"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"java",
|
|
22
|
+
"jar",
|
|
23
|
+
"decompiler",
|
|
24
|
+
"cfr"
|
|
25
|
+
],
|
|
26
|
+
"author": "",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
30
|
+
"adm-zip": "^0.5.16",
|
|
31
|
+
"zod": "^4.2.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/adm-zip": "^0.5.7",
|
|
35
|
+
"@types/node": "^20.17.16",
|
|
36
|
+
"tsx": "^4.21.0",
|
|
37
|
+
"typescript": "^5.7.3"
|
|
38
|
+
}
|
|
39
|
+
}
|