@creeperhost/modlens-mcp 1.6.19 → 1.6.21

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.
Files changed (108) hide show
  1. package/ATTRIBUTION.md +8 -1
  2. package/README.md +82 -5
  3. package/RUNTIME.md +262 -0
  4. package/TESTING.md +8 -0
  5. package/dist/cli.js +28 -8
  6. package/dist/cli.js.map +1 -1
  7. package/dist/hosted-mod-license.d.ts +36 -0
  8. package/dist/hosted-mod-license.d.ts.map +1 -0
  9. package/dist/hosted-mod-license.js +65 -0
  10. package/dist/hosted-mod-license.js.map +1 -0
  11. package/dist/hosted-policy.d.ts +32 -0
  12. package/dist/hosted-policy.d.ts.map +1 -0
  13. package/dist/hosted-policy.js +257 -0
  14. package/dist/hosted-policy.js.map +1 -0
  15. package/dist/launcher.js +104 -89
  16. package/dist/launcher.js.map +1 -1
  17. package/dist/license-notices.d.ts +8 -0
  18. package/dist/license-notices.d.ts.map +1 -0
  19. package/dist/license-notices.js +10 -0
  20. package/dist/license-notices.js.map +1 -0
  21. package/dist/license-source-references.d.ts +12 -0
  22. package/dist/license-source-references.d.ts.map +1 -0
  23. package/dist/license-source-references.js +18 -0
  24. package/dist/license-source-references.js.map +1 -0
  25. package/dist/license-templates.d.ts +6 -0
  26. package/dist/license-templates.d.ts.map +1 -0
  27. package/dist/license-templates.js +55 -0
  28. package/dist/license-templates.js.map +1 -0
  29. package/dist/local-mod.d.ts +31 -0
  30. package/dist/local-mod.d.ts.map +1 -0
  31. package/dist/local-mod.js +71 -0
  32. package/dist/local-mod.js.map +1 -0
  33. package/dist/mod-license.d.ts +53 -0
  34. package/dist/mod-license.d.ts.map +1 -0
  35. package/dist/mod-license.js +617 -0
  36. package/dist/mod-license.js.map +1 -0
  37. package/dist/primer-catalog.d.ts +15 -0
  38. package/dist/primer-catalog.d.ts.map +1 -0
  39. package/dist/primer-catalog.js +43 -0
  40. package/dist/primer-catalog.js.map +1 -0
  41. package/dist/primer-content.d.ts +7 -0
  42. package/dist/primer-content.d.ts.map +1 -0
  43. package/dist/primer-content.js +185 -0
  44. package/dist/primer-content.js.map +1 -0
  45. package/dist/runtime/cli.d.ts +2 -0
  46. package/dist/runtime/cli.d.ts.map +1 -0
  47. package/dist/runtime/cli.js +163 -0
  48. package/dist/runtime/cli.js.map +1 -0
  49. package/dist/runtime/companion.d.ts +32 -0
  50. package/dist/runtime/companion.d.ts.map +1 -0
  51. package/dist/runtime/companion.js +135 -0
  52. package/dist/runtime/companion.js.map +1 -0
  53. package/dist/runtime/guidance.d.ts +73 -0
  54. package/dist/runtime/guidance.d.ts.map +1 -0
  55. package/dist/runtime/guidance.js +40 -0
  56. package/dist/runtime/guidance.js.map +1 -0
  57. package/dist/runtime/hub.d.ts +150 -0
  58. package/dist/runtime/hub.d.ts.map +1 -0
  59. package/dist/runtime/hub.js +561 -0
  60. package/dist/runtime/hub.js.map +1 -0
  61. package/dist/runtime/modlens-agent.jar +0 -0
  62. package/dist/runtime/protocol.d.ts +202 -0
  63. package/dist/runtime/protocol.d.ts.map +1 -0
  64. package/dist/runtime/protocol.js +91 -0
  65. package/dist/runtime/protocol.js.map +1 -0
  66. package/dist/runtime/requests.d.ts +367 -0
  67. package/dist/runtime/requests.d.ts.map +1 -0
  68. package/dist/runtime/requests.js +57 -0
  69. package/dist/runtime/requests.js.map +1 -0
  70. package/dist/server.js +141 -45
  71. package/dist/server.js.map +1 -1
  72. package/dist/setup.js +9 -4
  73. package/dist/setup.js.map +1 -1
  74. package/dist/tools/primers.d.ts +133 -22
  75. package/dist/tools/primers.d.ts.map +1 -1
  76. package/dist/tools/primers.js +391 -386
  77. package/dist/tools/primers.js.map +1 -1
  78. package/dist/tools/project.d.ts +11 -7
  79. package/dist/tools/project.d.ts.map +1 -1
  80. package/dist/tools/project.js +45 -3
  81. package/dist/tools/project.js.map +1 -1
  82. package/dist/tools/report-issue.d.ts +77 -0
  83. package/dist/tools/report-issue.d.ts.map +1 -0
  84. package/dist/tools/report-issue.js +92 -0
  85. package/dist/tools/report-issue.js.map +1 -0
  86. package/dist/tools/runtime.d.ts +6 -0
  87. package/dist/tools/runtime.d.ts.map +1 -0
  88. package/dist/tools/runtime.js +18 -0
  89. package/dist/tools/runtime.js.map +1 -0
  90. package/dist/tools/source.d.ts +1 -1
  91. package/dist/tools/source.d.ts.map +1 -1
  92. package/dist/tools/source.js +5 -1
  93. package/dist/tools/source.js.map +1 -1
  94. package/package.json +8 -1
  95. package/scripts/build-runtime-agent.mjs +64 -0
  96. package/scripts/fixtures/mod-license-corpus.json +154 -0
  97. package/scripts/gradle/modlens-runtime.init.gradle +17 -0
  98. package/scripts/runtime-test-client.mjs +36 -0
  99. package/scripts/test-hosted-policy.mjs +123 -0
  100. package/scripts/test-mod-licenses-live.mjs +86 -0
  101. package/scripts/test-package.mjs +141 -5
  102. package/scripts/test-primers-live.mjs +93 -0
  103. package/scripts/test-project-http.mjs +46 -3
  104. package/scripts/test-runtime-gradle.mjs +107 -0
  105. package/scripts/test-runtime-minecraft.mjs +190 -0
  106. package/scripts/test-runtime-native.mjs +120 -0
  107. package/scripts/test-runtime-remote.mjs +209 -0
  108. package/scripts/update-license-templates.mjs +14 -0
@@ -30,9 +30,9 @@ Object.assign(env, {
30
30
  MODLENS_CACHE_ROOT: join(root, "modlens-cache"),
31
31
  });
32
32
 
33
- function start(args, home) {
33
+ function start(args, home, extraEnv = {}) {
34
34
  const child = spawn(process.execPath, [npm, ...args], {
35
- cwd: consumer, env: { ...env, MODLENS_HOME: home }, stdio: ["pipe", "pipe", "pipe"],
35
+ cwd: consumer, env: { ...env, MODLENS_HOME: home, ...extraEnv }, stdio: ["pipe", "pipe", "pipe"],
36
36
  detached: process.platform !== "win32",
37
37
  });
38
38
  let output = "";
@@ -113,6 +113,36 @@ try {
113
113
  assert.equal(initScript, join(installed, "scripts/gradle/modlens.init.gradle"));
114
114
  assert.ok(statSync(initScript).size > 0, "Gradle exporter must ship in the npm package");
115
115
  assert.ok(statSync(join(installed, "scripts/project-upload.mjs")).size > 0, "Remote uploader must ship in the npm package");
116
+ assert.ok(statSync(join(installed, "dist/runtime/modlens-agent.jar")).size > 0, "Optional runtime agent must ship prebuilt");
117
+ assert.ok(statSync(join(installed, "scripts/gradle/modlens-runtime.init.gradle")).size > 0, "Runtime launch integration must ship");
118
+ assert.ok(statSync(join(installed, "RUNTIME.md")).size > 0, "Runtime instructions must ship");
119
+ assert.ok(statSync(join(installed, "dist/license-templates.js")).size > 0, "License reference data must ship");
120
+ const localModRequest = join(root, "local-mod-request.json");
121
+ writeFileSync(localModRequest, JSON.stringify({ localJarPath: join(root, "not-opened.jar"), sha256: "a".repeat(64), className: "example.Fixture", acceptedLocalDecompilation: false }));
122
+ assert.throws(() => execFileSync(process.execPath, [join(installed, "dist/launcher.js"), "--local-mod", "--request-file", localModRequest], {
123
+ env: { ...env, MODLENS_HOME: join(root, "local-mod-home"), MCP_PORT: "9999" }, encoding: "utf8", stdio: "pipe", timeout: 30000,
124
+ }), error => error.status === 1 && String(error.stderr).includes("User acceptance required"));
125
+ assert.ok(!existsSync(join(root, "local-mod-home", "data")), "Local helper must not bootstrap a hosted database");
126
+ console.log("PASS: installed local decompilation helper refuses execution without user acceptance");
127
+ const runtimeHome=join(root,"runtime-only-home");
128
+ const runtimeEnv={...env,MODLENS_HOME:runtimeHome,MODLENS_CACHE_ROOT:join(root,"runtime-only-cache"),MCP_PORT:"9999"};
129
+ const runtime=(...arguments_)=>JSON.parse(execFileSync(process.execPath,[join(installed,"dist/launcher.js"),"--runtime",...arguments_],{env:runtimeEnv,encoding:"utf8",timeout:30000}));
130
+ const runtimeProject=join(root,"runtime project");
131
+ mkdirSync(join(runtimeProject,"gradle/wrapper"),{recursive:true});
132
+ writeFileSync(join(runtimeProject,"gradle/wrapper/gradle-wrapper.jar"),"fixture");
133
+ const runtimeRequest=join(root,"runtime-request.json");
134
+ writeFileSync(runtimeRequest,JSON.stringify({action:"setup",projectDir:runtimeProject,mode:"hidden"}));
135
+ try {
136
+ assert.ok(runtime("help").execution.includes("Remote MCP"));
137
+ const configuredRuntime = runtime("--request-file",runtimeRequest);
138
+ assert.equal(configuredRuntime.state,"configured");
139
+ assert.deepEqual(configuredRuntime.vmOptions,[configuredRuntime.vmOption,"-XX:StackShadowPages=32"]);
140
+ assert.equal(runtime("status").projects.length,1);
141
+ assert.deepEqual(runtime("sessions"),[]);
142
+ assert.ok(!existsSync(join(runtimeHome,"data")),"Runtime helper must not create a source database");
143
+ assert.ok(!existsSync(join(runtimeHome,".env")),"Runtime helper must not create MCP configuration");
144
+ } finally {runtime("stop");}
145
+ console.log("PASS: installed runtime helper setup, persistence and stop without local MCP/database setup");
116
146
  const keyFile = join(root, "project-key.txt");
117
147
  writeFileSync(keyFile, "a".repeat(64));
118
148
  assert.deepEqual(JSON.parse(execFileSync(process.execPath, [join(installed, "dist/launcher.js"), "--project", "list", `--key-file=${keyFile}`], { env, encoding: "utf8" })), []);
@@ -122,8 +152,28 @@ try {
122
152
  assert.match(require("better-sqlite3/package.json").version, /^12\./);
123
153
 
124
154
  const dataHome = join(root, "server-home");
155
+ // Deterministic upstream responses still exercise the installed Markdown dependencies,
156
+ // MCP argument schema, error flag, database writes, and reads after restart.
157
+ const primerFetchFixture = join(root, "primer-fetch-fixture.mjs");
158
+ writeFileSync(primerFetchFixture, `
159
+ const originalFetch = globalThis.fetch;
160
+ globalThis.fetch = async (input, init) => {
161
+ const url = String(input);
162
+ if (url === 'https://docs.neoforged.net/package-primer-missing') return new Response('missing', { status: 404 });
163
+ if (url === 'https://docs.neoforged.net/package-primer') return new Response(
164
+ '<article><h1>Package migration guide</h1><pre class="language-java"><code>' +
165
+ '<div class="token-line"><span>update();</span><br></div><div class="token-line"><span>finish();</span><br></div></code></pre>' +
166
+ '<p>Detailed instructions for migration and keeping code examples readable.</p><h2>Final section</h2></article>',
167
+ { headers: { 'content-type': 'text/html' } });
168
+ if (url.includes('misode/mcmeta/summary/versions/data.json')) return Response.json([
169
+ { id: '1.21.1', data_version: 3955 }, { id: '1.21.5', data_version: 4325 }
170
+ ]);
171
+ if (url.endsWith('/api/tags')) return new Response(null, { status: 503 });
172
+ return originalFetch(input, init);
173
+ };
174
+ `);
125
175
  async function checkServer() {
126
- const run = start(args, dataHome);
176
+ const run = start(args, dataHome, { NODE_OPTIONS: `--import=${pathToFileURL(primerFetchFixture).href}` });
127
177
  const pending = new Map();
128
178
  let buffer = "";
129
179
  let id = 0;
@@ -161,11 +211,94 @@ try {
161
211
  assert.ok(initialized.serverInfo);
162
212
  send({ method: "notifications/initialized" });
163
213
  const list = await request("tools/list", {});
214
+ assert.ok(list.tools.some(tool => tool.name === "runtime" && tool.description.includes("setup")), "AI must discover runtime setup through MCP");
215
+ assert.match(initialized.instructions, /runtime/);
216
+ assert.ok(list.tools.some(tool => tool.name === "report_issue"), "AI must discover GitHub issue reporting");
217
+ const issueDraft = await request("tools/call", { name: "report_issue", arguments: {
218
+ action: "prepare", title: "Package test fixture", summary: "A synthetic report; do not publish.",
219
+ } });
220
+ assert.ok(!issueDraft.isError);
221
+ const issuePlan = JSON.parse(issueDraft.content[0].text);
222
+ assert.equal(issuePlan.executed, false);
223
+ assert.equal(issuePlan.state, "draft_prepared");
224
+ assert.equal(issuePlan.repository.repo, "modlens-mcp");
225
+ const runtimeHelp = await request("tools/call", { name: "runtime", arguments: { action: "help" } });
226
+ assert.ok(!runtimeHelp.isError);
227
+ assert.match(runtimeHelp.content[0].text, /26\.3/);
164
228
  assert.ok(list.tools.some(tool => tool.name === "mod"));
165
229
  assert.ok(list.tools.some(tool => tool.name === "project"));
166
230
  const stats = await request("tools/call", { name: "mod", arguments: { action: "stats" } });
167
231
  assert.ok(!stats.isError, JSON.stringify(stats));
168
232
  assert.equal(JSON.parse(stats.content.find(item => item.type === "text").text).total, 0);
233
+ const primerTool = list.tools.find(tool => tool.name === "primers");
234
+ assert.ok(primerTool.inputSchema.properties.refresh, "Packaged primer tool must expose cache refresh");
235
+ for (const field of ["includeContent", "maxChars", "cursor"]) {
236
+ assert.ok(primerTool.inputSchema.properties[field], `Packaged primer tool must expose ${field}`);
237
+ }
238
+ const decode = response => JSON.parse(response.content.find(item => item.type === "text").text);
239
+ const primerCall = arguments_ => request("tools/call", { name: "primers", arguments: arguments_ });
240
+ const saved = await primerCall({ action: "ingest", entries: [{
241
+ fromVersion: "1.21.1", toVersion: "1.21.5", title: "Package primer", url: "https://docs.neoforged.net/package-primer",
242
+ }] });
243
+ assert.ok(!saved.isError, JSON.stringify(saved));
244
+ const primerId = decode(saved).primers[0].id;
245
+ const page = await primerCall({ action: "get", id: primerId, maxLines: 3 });
246
+ assert.ok(!page.isError, JSON.stringify(page));
247
+ assert.equal(decode(page).contentStatus, "ready");
248
+ assert.match(decode(page).content, /# Package migration guide/);
249
+ assert.equal(decode(page).nextStartLine, 4);
250
+ const tail = decode(await primerCall({ action: "get", id: primerId, startLine: 4 }));
251
+ assert.match(tail.content, /update\(\);\nfinish\(\);/);
252
+ assert.match(tail.content, /## Final section/);
253
+ assert.equal(tail.nextStartLine, null);
254
+ const longContent = "# Long migration guide\n\n" + "x".repeat(2500) + "😀\nEnd.";
255
+ const added = decode(await primerCall({ action: "ingest", entries: [{
256
+ fromVersion: "1.21.5", toVersion: "26.1", title: "Long package primer",
257
+ url: "https://docs.neoforged.net/package-primer-long", content: longContent,
258
+ }] }));
259
+ const rangeArgs = { action: "by_version", fromVersion: "1.21.1", toVersion: "26.1", modloader: "neoforge" };
260
+ const metadata = decode(await primerCall(rangeArgs));
261
+ assert.equal(metadata.count, 2);
262
+ assert.ok(metadata.primers.every(primer => !("content" in primer)));
263
+ const chunks = new Map();
264
+ let cursor;
265
+ let pages = 0;
266
+ do {
267
+ const response = await primerCall({ ...rangeArgs, includeContent: true, maxChars: 1000, ...(cursor ? { cursor } : {}) });
268
+ assert.ok(!response.isError, JSON.stringify(response));
269
+ const bundle = decode(response);
270
+ assert.equal(bundle.count, 2);
271
+ assert.ok(bundle.contentChars <= 1000);
272
+ for (const primer of bundle.primers) chunks.set(primer.id, (chunks.get(primer.id) ?? "") + primer.content);
273
+ cursor = bundle.nextCursor;
274
+ assert.ok(++pages < 10);
275
+ } while (cursor);
276
+ assert.equal(chunks.get(added.primers[0].id), longContent);
277
+ assert.match(chunks.get(primerId), /update\(\);\nfinish\(\);/);
278
+ const cliRange = JSON.parse(execFileSync(process.execPath, [join(installed, "dist/cli.js"),
279
+ "primers", "by-version", "1.21.1", "26.1", "--modloader=neoforge", "--include-content", "--fetch-content=false"], {
280
+ env: { ...env, DATABASE_URL: `file:${join(dataHome, "data/modlens.db")}` }, encoding: "utf8", timeout: 30_000,
281
+ }));
282
+ assert.equal(cliRange.count, 2);
283
+ assert.equal(cliRange.nextCursor, null);
284
+ assert.equal(cliRange.primers.find(primer => primer.id === added.primers[0].id).content, longContent);
285
+ assert.equal((await primerCall({ ...rangeArgs, includeContent: true, cursor: "invalid" })).isError, true);
286
+ const failed = await primerCall({ action: "ingest", entries: [{
287
+ fromVersion: "1.21.1", toVersion: "1.21.5", title: "Missing primer",
288
+ url: "https://docs.neoforged.net/package-primer-missing", fetchContent: true,
289
+ }] });
290
+ assert.equal(failed.isError, true, "Requested content fetch failures must be MCP errors");
291
+ assert.equal(decode(failed).failed, 1);
292
+ const missing = decode(await primerCall({ action: "ingest", entries: [{
293
+ fromVersion: "1.21.1", toVersion: "1.21.5", title: "Missing bundled primer",
294
+ url: "https://docs.neoforged.net/package-primer-missing",
295
+ }] }));
296
+ const partial = await primerCall({ ...rangeArgs, includeContent: true });
297
+ assert.equal(partial.isError, true, "Bundle fetch failures must be MCP errors with successful content retained");
298
+ assert.equal(decode(partial).failed, 1);
299
+ assert.equal(decode(partial).primers.filter(primer => primer.contentStatus === "ready").length, 2);
300
+ await primerCall({ action: "delete", id: added.primers[0].id });
301
+ await primerCall({ action: "delete", id: missing.primers[0].id });
169
302
  return run.output();
170
303
  } finally { await stop(run); }
171
304
  }
@@ -174,7 +307,7 @@ try {
174
307
  assert.match(first, /First run/);
175
308
  const database = join(dataHome, "data/modlens.db");
176
309
  assert.ok(statSync(database).size > 0);
177
- console.log("PASS: first-run SQLite bootstrap, MCP handshake, tools/list, database stats");
310
+ console.log("PASS: first-run SQLite bootstrap, MCP handshake, primer bundles/pagination/errors, CLI ranges, database stats");
178
311
 
179
312
  // Check the actual installed client/adapter with dates, BLOBs, and transactions.
180
313
  // Use a child so Windows releases the native .node file before cleanup.
@@ -212,6 +345,9 @@ try {
212
345
  console.log("PASS: second npx run preserves existing database contents");
213
346
  passed = true;
214
347
  } finally {
215
- if (passed) rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 300 });
348
+ if (passed) {
349
+ assert.equal(dirname(resolve(root)), resolve(tmpdir()), "Cleanup must stay within the test temp directory");
350
+ rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 300 });
351
+ }
216
352
  else console.error(`Package test artifacts retained at ${root}`);
217
353
  }
@@ -0,0 +1,93 @@
1
+ // Live upstream check, isolated from the user's database/cache. Run after npm run build.
2
+ import assert from "node:assert/strict";
3
+ import { copyFile, mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const repository = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ const root = await mkdtemp(join(tmpdir(), "modlens-primers-live-"));
10
+ await copyFile(join(repository, "prisma/backends/template.db"), join(root, "test.db"));
11
+ process.env.DATABASE_URL = "file:" + join(root, "test.db");
12
+ process.env.MODLENS_CACHE_ROOT = join(root, "cache");
13
+ process.env.OLLAMA_URL = "http://127.0.0.1:1"; // Embeddings are independent of this check.
14
+ const { getDb, disconnect } = await import("../dist/db.js");
15
+ const { seedDefaultPrimers, getPrimer, getPrimersByVersionRange } = await import("../dist/tools/primers.js");
16
+ const { SEED_PRIMERS } = await import("../dist/primer-catalog.js");
17
+ const db = await getDb();
18
+ let passed = false;
19
+ try {
20
+ await db.primer.create({ data: {
21
+ id: 3, fromVersion: "1.21.1", toVersion: "1.21.5", modloader: "neoforge",
22
+ title: "NeoForge Migration Guide - 1.21.1 to 1.21.5 (content)",
23
+ url: "https://docs.neoforged.net/docs/1.21.5/migrationguide/", source: "seed",
24
+ } });
25
+ console.log("Fetching the official primer catalogue into a disposable SQLite database...");
26
+ const seeded = await seedDefaultPrimers();
27
+ console.log(JSON.stringify({ guides: seeded.ingested, ready: seeded.ready, failed: seeded.failed,
28
+ errors: seeded.primers.filter(p => p.error) }, null, 2));
29
+ assert.equal(seeded.ready, SEED_PRIMERS.length);
30
+ assert.equal(seeded.failed, 0);
31
+ const range = await getPrimersByVersionRange("1.21.1", "1.21.5", "neoforge");
32
+ assert.equal(range.count, 6);
33
+ const fullRange = await getPrimersByVersionRange("1.21.1", "26.1", "neoforge");
34
+ assert.equal(fullRange.count, 17);
35
+ const bundledContent = new Map();
36
+ let cursor;
37
+ let bundlePages = 0;
38
+ do {
39
+ const bundle = await getPrimersByVersionRange("1.21.1", "26.1", "neoforge", { includeContent: true, maxChars: 60_000, cursor });
40
+ assert.equal(bundle.failed, 0);
41
+ assert.equal(bundle.missing, 0);
42
+ assert.equal(bundle.count, 17);
43
+ assert.ok(bundle.contentChars <= 60_000);
44
+ for (const primer of bundle.primers) {
45
+ const previous = bundledContent.get(primer.id) ?? "";
46
+ assert.equal(primer.startOffset, previous.length);
47
+ bundledContent.set(primer.id, previous + primer.content);
48
+ }
49
+ cursor = bundle.nextCursor ?? undefined;
50
+ assert.ok(++bundlePages < 100, "Bundle pagination must terminate");
51
+ } while (cursor);
52
+ for (const primer of fullRange.primers) {
53
+ const stored = await db.primer.findUniqueOrThrow({ where: { id: primer.id } });
54
+ assert.equal(bundledContent.get(primer.id), stored.content, `Bundle must retain all content for ${primer.title}`);
55
+ }
56
+ const reported = await getPrimer(3);
57
+ assert.equal(reported.url, "https://docs.neoforged.net/primer/docs/1.21.5/");
58
+ assert.equal(reported.contentStatus, "ready");
59
+ const saved = await db.primer.findUniqueOrThrow({ where: { id: 3 } });
60
+ assert.ok(saved.content.length > 50_000, "The guide must survive the old 50K truncation limit");
61
+ assert.ok(saved.content.includes(String.fromCharCode(96).repeat(3)), "Code fences must survive extraction");
62
+ assert.match(saved.content, /var blocker = new BlocksAttacks\(\n\s+\/\//, "Highlighted code must preserve line breaks before comments");
63
+ let page = reported;
64
+ const parts = [];
65
+ while (true) {
66
+ parts.push(page.content);
67
+ if (page.nextStartLine === null) break;
68
+ page = await getPrimer(3, { startLine: page.nextStartLine });
69
+ }
70
+ assert.equal(parts.join("\n"), saved.content);
71
+ const neo = range.primers.find(p => p.toVersion === "1.21.5" && p.modloader === "neoforge");
72
+ const loader = await db.primer.findUniqueOrThrow({ where: { id: neo.id } });
73
+ assert.match(loader.content, /Embedded source: https:\/\/neoforged.net\/news\/21.5release\//);
74
+ // A restarted, disconnected client should still be able to read the cached body.
75
+ const originalFetch = globalThis.fetch;
76
+ try {
77
+ globalThis.fetch = async () => { throw new Error("Offline check"); };
78
+ await disconnect();
79
+ assert.equal((await getPrimer(3)).content, reported.content);
80
+ const offlineBundle = await getPrimersByVersionRange("1.21.1", "26.1", "neoforge", { includeContent: true });
81
+ assert.equal(offlineBundle.failed, 0);
82
+ assert.ok(offlineBundle.contentChars > 0);
83
+ } finally { globalThis.fetch = originalFetch; }
84
+ console.log("PASS: legacy ID repaired, live guide extraction, 17-guide range bundling, lossless pagination, and offline cached reads");
85
+ console.log(JSON.stringify({ reportGuideCharacters: saved.content.length, reportGuideLines: reported.totalLines, pages: parts.length, bundleGuides: bundledContent.size, bundlePages }));
86
+ passed = true;
87
+ } finally {
88
+ await disconnect();
89
+ if (passed) {
90
+ assert.equal(dirname(resolve(root)), resolve(tmpdir()), "Cleanup must stay within the test temp directory");
91
+ await rm(root, { recursive: true, force: true, maxRetries: 5 });
92
+ } else console.error("Live primer test artifacts retained at " + root);
93
+ }
@@ -11,6 +11,8 @@ import { once } from 'node:events';
11
11
  import AdmZip from 'adm-zip';
12
12
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
13
13
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
14
+ import { licenseTemplates } from '../dist/license-templates.js';
15
+ import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
14
16
 
15
17
  const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
16
18
  const root = await mkdtemp(join(tmpdir(), 'modlens-http-smoke-'));
@@ -28,14 +30,22 @@ if (!process.argv[2]) {
28
30
  const jar = new AdmZip();
29
31
  jar.addFile('example/Target.class', Buffer.from('test class entry'));
30
32
  jar.addFile('example/Target.java', Buffer.from('package example; public class Target { public int counter; }'));
33
+ jar.addFile('LICENSE', Buffer.from(licenseTemplates['0BSD'].text));
31
34
  // An incompressible payload exercises multiple full upload chunks.
32
35
  jar.addFile('payload.bin', randomBytes(2 * 1024 * 1024));
33
36
  const bytes = jar.toBuffer();
34
37
  const bundle = new AdmZip();
35
38
  bundle.addFile('artifacts/0.jar', bytes);
39
+ const restricted = new AdmZip();
40
+ restricted.addFile('LICENSE', Buffer.from('All Rights Reserved'));
41
+ restricted.addFile('example/Restricted.class', Buffer.from('restricted class fixture'));
42
+ restricted.addFile('example/Restricted.java', Buffer.from('RESTRICTED_SOURCE_MUST_STAY_LOCAL'));
43
+ const restrictedBytes = restricted.toBuffer();
44
+ bundle.addFile('artifacts/1.jar', restrictedBytes);
36
45
  bundle.addFile('manifest.json', Buffer.from(JSON.stringify({ format: 'modlens-project-v1', project: 'http-smoke:', sourceSet: 'main',
37
46
  toolchain: 'gradle-compile-classpath', minecraftVersion: '', loaderVersion: '', mappings: 'compile-classpath', javaVersion: 21, transformations: [],
38
- artifacts: [{ path: 'artifacts/0.jar', name: 'prepared.jar', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), kind: 'classpath' }] })));
47
+ artifacts: [{ path: 'artifacts/0.jar', name: 'prepared.jar', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), kind: 'classpath' },
48
+ { path: 'artifacts/1.jar', name: 'restricted.jar', size: restrictedBytes.length, sha256: createHash('sha256').update(restrictedBytes).digest('hex'), kind: 'classpath' }] })));
39
49
  await writeFile(bundlePath, bundle.toBuffer());
40
50
  }
41
51
  const env = { ...process.env, MCP_PORT: String(port), MCP_HOST: '127.0.0.1', MODLENS_AUTO_EMBED: '0',
@@ -44,7 +54,9 @@ const server = spawn(process.execPath, [join(repo, 'dist/server.js')], { cwd: ro
44
54
  let logs = '';
45
55
  server.stderr.on('data', b => { logs += b; });
46
56
  const stopped = once(server, 'exit');
47
- const client = new Client({ name: 'project-http-test', version: '1.0.0' });
57
+ const client = new Client({ name: 'project-http-test', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } });
58
+ let acceptLocal = true;
59
+ client.setRequestHandler(ElicitRequestSchema, async () => acceptLocal ? { action: 'accept', content: { acceptLocal: true } } : { action: 'decline' });
48
60
  let passed = false;
49
61
  try {
50
62
  await new Promise((resolveReady, reject) => {
@@ -69,6 +81,37 @@ try {
69
81
  const source = await call('source', { className: target });
70
82
  assert.ok(!source.isError, JSON.stringify(source));
71
83
  assert.ok(JSON.parse(source.content[0].text).source.length > 0);
84
+ const complianceBlock = source.content.find(c => c.type === 'text' && c.text.includes('licenseCompliance'));
85
+ assert.ok(complianceBlock, 'required license notices must accompany source');
86
+ assert.ok(JSON.parse(source.content[0].text).source.split(/\r?\n/).length <= 200);
87
+ if (process.env.MODLENS_TEST_EXPECTED_LICENSE) {
88
+ const compliance = JSON.parse(complianceBlock.text).licenseCompliance[0];
89
+ assert.equal(compliance.license, process.env.MODLENS_TEST_EXPECTED_LICENSE);
90
+ assert.equal(compliance.conditions.outputLicense, compliance.license);
91
+ assert.ok(compliance.notices.every(n => n.licenseText.length > 0));
92
+ assert.ok(compliance.modifiedAt);
93
+ if (compliance.license === 'LGPL-3.0-only') assert.ok(compliance.conditions.sourceArchive);
94
+ if (compliance.license === 'CC-BY-NC-SA-4.0') assert.equal(compliance.conditions.noncommercialOnly, true);
95
+ }
96
+ if (!process.argv[2]) {
97
+ assert.ok(source.content.some(c => c.type === 'text' && c.text.includes('licenseCompliance')), 'required license notices must accompany source');
98
+ const restricted = await call('source', { className: 'example.Restricted' });
99
+ const fallback = JSON.parse(restricted.content[0].text);
100
+ assert.equal(fallback.licenseReview.disposition, 'local_only');
101
+ assert.equal(fallback.local.requiresUserConsent, true);
102
+ assert.ok(!JSON.stringify(restricted).includes('RESTRICTED_SOURCE_MUST_STAY_LOCAL'));
103
+ const searched = await call('search', { query: 'RESTRICTED_SOURCE' });
104
+ assert.deepEqual(JSON.parse(searched.content[0].text).results, []);
105
+ const consent = () => client.callTool({ name: 'mod_license', arguments: {
106
+ action: 'local_plan', projectKey: key, environmentId: imported.environmentId, className: 'example.Restricted',
107
+ } });
108
+ const accepted = JSON.parse((await consent()).content[0].text).local;
109
+ assert.equal(accepted.executed, false);
110
+ assert.equal(accepted.request.acceptedLocalDecompilation, true);
111
+ assert.equal(accepted.requiresUserConsent, false);
112
+ acceptLocal = false;
113
+ assert.equal(JSON.parse((await consent()).content[0].text).declined, true);
114
+ }
72
115
  if (process.argv[5]) {
73
116
  const members = await call('members', { className: target });
74
117
  assert.ok(!members.isError, JSON.stringify(members));
@@ -88,7 +131,7 @@ try {
88
131
  assert.equal(large.status, 413);
89
132
  assert.ok(!logs.includes(key), 'server logs must redact project key');
90
133
  assert.ok(!logs.includes('SENSITIVE_PAYLOAD'), 'server logs must redact upload bytes');
91
- console.log(`PASS: HTTP upload, ${imported.classCount} classes, source query, project isolation, host-path rejection, body limits and log redaction`);
134
+ console.log(`PASS: HTTP upload, ${imported.classCount} classes, source query and complete notices, project isolation, host-path rejection, body limits and log redaction${process.argv[2] ? '' : ', restricted-source gating and local consent'}`);
92
135
  passed = true;
93
136
  } finally {
94
137
  await client.close();
@@ -0,0 +1,107 @@
1
+ // Validates the same wrapper/init-script path used by MCP and IntelliJ.
2
+ // Requires JAVA_HOME (25+) and MODLENS_TEST_GRADLE_HOME (a Gradle 9.6 distribution).
3
+ import { mkdtemp, mkdir, writeFile, readdir, readFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { execFileSync } from "node:child_process";
7
+ import assert from "node:assert/strict";
8
+ import { RuntimeHub } from "../dist/runtime/hub.js";
9
+ import { runtimeTestClient } from "./runtime-test-client.mjs";
10
+
11
+ const gradleHome = process.env.MODLENS_TEST_GRADLE_HOME;
12
+ const javaHome = process.env.JAVA_HOME;
13
+ if (!gradleHome || !javaHome) throw new Error("Set JAVA_HOME and MODLENS_TEST_GRADLE_HOME");
14
+ const root = await mkdtemp(join(tmpdir(), "modlens-runtime-gradle-"));
15
+ const project = join(root, "project with spaces");
16
+ await mkdir(join(project, "src/main/java"), { recursive: true });
17
+ await writeFile(join(project, "settings.gradle"), "rootProject.name = 'runtime-smoke'\n");
18
+ await writeFile(join(project, "gradle.properties"), "org.gradle.daemon=false\n");
19
+ await writeFile(
20
+ join(project, "build.gradle"),
21
+ `
22
+ plugins { id 'java' }
23
+ tasks.register('runClient', JavaExec) {
24
+ classpath = sourceSets.main.runtimeClasspath
25
+ mainClass = 'Fixture'
26
+ args 'agent'
27
+ }
28
+ tasks.register('plainRun', JavaExec) {
29
+ classpath = sourceSets.main.runtimeClasspath
30
+ mainClass = 'Fixture'
31
+ args 'plain'
32
+ }
33
+ `,
34
+ );
35
+ await writeFile(
36
+ join(project, "src/main/java/Fixture.java"),
37
+ `
38
+ public class Fixture {
39
+ public static void main(String[] args) throws Exception {
40
+ long count = java.lang.management.ManagementFactory.getRuntimeMXBean().getInputArguments()
41
+ .stream().filter(s -> s.startsWith("-javaagent:") && s.contains(".modlens")).count();
42
+ if (count != (args[0].equals("agent") ? 1 : 0)) throw new AssertionError("agent count " + count);
43
+ System.out.println("AGENT_COUNT=" + count);
44
+ var vmArgs = java.lang.management.ManagementFactory.getRuntimeMXBean().getInputArguments();
45
+ boolean hasShadowOption = vmArgs.stream().anyMatch(s -> s.startsWith("-XX:StackShadowPages="));
46
+ if (hasShadowOption != args[0].equals("agent")) throw new AssertionError("Stack setting leaked or missing: " + vmArgs);
47
+ if (count == 1) {
48
+ String shadowPages = java.lang.management.ManagementFactory.getPlatformMXBean(
49
+ com.sun.management.HotSpotDiagnosticMXBean.class).getVMOption("StackShadowPages").getValue();
50
+ if (!shadowPages.equals("32")) throw new AssertionError("Effective StackShadowPages=" + shadowPages);
51
+ System.out.println("STACK_SHADOW_PAGES=" + shadowPages);
52
+ }
53
+ if (count == 1) Thread.sleep(4000);
54
+ }
55
+ }
56
+ `,
57
+ );
58
+ const launcher = (await readdir(join(gradleHome, "lib"))).find((n) =>
59
+ /^gradle-gradle-cli-main-.*\.jar$/.test(n),
60
+ );
61
+ if (!launcher) throw new Error("Expected a Gradle 9.6 distribution");
62
+ const java = join(javaHome, "bin", process.platform === "win32" ? "java.exe" : "java");
63
+ execFileSync(java, ["-jar", join(gradleHome, "lib", launcher), "--no-daemon", "--offline", "wrapper"], {
64
+ cwd: project,
65
+ stdio: "inherit",
66
+ timeout: 120000,
67
+ });
68
+ const hub = process.argv.includes("--helper")
69
+ ? runtimeTestClient(root)
70
+ : new RuntimeHub(join(root, "bridge"));
71
+ try {
72
+ const setup = await hub.setup(project, "hidden");
73
+ const init = join(project, ".modlens/runtime/init.gradle");
74
+ const plain = execFileSync(
75
+ java,
76
+ [
77
+ "-jar",
78
+ join(gradleHome, "lib", launcher),
79
+ "--no-daemon",
80
+ "--offline",
81
+ "--no-configuration-cache",
82
+ "-I",
83
+ init,
84
+ "plainRun",
85
+ ],
86
+ { cwd: project, encoding: "utf8", timeout: 120000 },
87
+ );
88
+ assert.match(plain, /AGENT_COUNT=0/);
89
+ await hub.launch(setup.projectId, javaHome);
90
+ const deadline = Date.now() + 120000;
91
+ let done;
92
+ while (Date.now() < deadline) {
93
+ const events = (await hub.events()).events;
94
+ done = events.find((e) => e.type === "launch_log");
95
+ if (done) break;
96
+ await new Promise((r) => setTimeout(r, 250));
97
+ }
98
+ assert.ok(done, "Gradle launch did not finish");
99
+ assert.match(done.data.text, /AGENT_COUNT=1/);
100
+ assert.match(done.data.text, /STACK_SHADOW_PAGES=32/);
101
+ assert.match(done.data.text, /BUILD SUCCESSFUL/);
102
+ assert.equal((await hub.list()).length, 1, "Only the game task should register");
103
+ assert.match(await readFile(setup.runConfiguration, "utf8"), /--init-script/);
104
+ console.log("Gradle optional agent launch passed. Evidence: " + root);
105
+ } finally {
106
+ await hub.close();
107
+ }