@creeperhost/modlens-mcp 1.6.20 → 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 (91) hide show
  1. package/ATTRIBUTION.md +8 -1
  2. package/README.md +67 -0
  3. package/RUNTIME.md +262 -0
  4. package/TESTING.md +8 -0
  5. package/dist/hosted-mod-license.d.ts +36 -0
  6. package/dist/hosted-mod-license.d.ts.map +1 -0
  7. package/dist/hosted-mod-license.js +65 -0
  8. package/dist/hosted-mod-license.js.map +1 -0
  9. package/dist/hosted-policy.d.ts +32 -0
  10. package/dist/hosted-policy.d.ts.map +1 -0
  11. package/dist/hosted-policy.js +257 -0
  12. package/dist/hosted-policy.js.map +1 -0
  13. package/dist/launcher.js +104 -89
  14. package/dist/launcher.js.map +1 -1
  15. package/dist/license-notices.d.ts +8 -0
  16. package/dist/license-notices.d.ts.map +1 -0
  17. package/dist/license-notices.js +10 -0
  18. package/dist/license-notices.js.map +1 -0
  19. package/dist/license-source-references.d.ts +12 -0
  20. package/dist/license-source-references.d.ts.map +1 -0
  21. package/dist/license-source-references.js +18 -0
  22. package/dist/license-source-references.js.map +1 -0
  23. package/dist/license-templates.d.ts +6 -0
  24. package/dist/license-templates.d.ts.map +1 -0
  25. package/dist/license-templates.js +55 -0
  26. package/dist/license-templates.js.map +1 -0
  27. package/dist/local-mod.d.ts +31 -0
  28. package/dist/local-mod.d.ts.map +1 -0
  29. package/dist/local-mod.js +71 -0
  30. package/dist/local-mod.js.map +1 -0
  31. package/dist/mod-license.d.ts +53 -0
  32. package/dist/mod-license.d.ts.map +1 -0
  33. package/dist/mod-license.js +617 -0
  34. package/dist/mod-license.js.map +1 -0
  35. package/dist/runtime/cli.d.ts +2 -0
  36. package/dist/runtime/cli.d.ts.map +1 -0
  37. package/dist/runtime/cli.js +163 -0
  38. package/dist/runtime/cli.js.map +1 -0
  39. package/dist/runtime/companion.d.ts +32 -0
  40. package/dist/runtime/companion.d.ts.map +1 -0
  41. package/dist/runtime/companion.js +135 -0
  42. package/dist/runtime/companion.js.map +1 -0
  43. package/dist/runtime/guidance.d.ts +73 -0
  44. package/dist/runtime/guidance.d.ts.map +1 -0
  45. package/dist/runtime/guidance.js +40 -0
  46. package/dist/runtime/guidance.js.map +1 -0
  47. package/dist/runtime/hub.d.ts +150 -0
  48. package/dist/runtime/hub.d.ts.map +1 -0
  49. package/dist/runtime/hub.js +561 -0
  50. package/dist/runtime/hub.js.map +1 -0
  51. package/dist/runtime/modlens-agent.jar +0 -0
  52. package/dist/runtime/protocol.d.ts +202 -0
  53. package/dist/runtime/protocol.d.ts.map +1 -0
  54. package/dist/runtime/protocol.js +91 -0
  55. package/dist/runtime/protocol.js.map +1 -0
  56. package/dist/runtime/requests.d.ts +367 -0
  57. package/dist/runtime/requests.d.ts.map +1 -0
  58. package/dist/runtime/requests.js +57 -0
  59. package/dist/runtime/requests.js.map +1 -0
  60. package/dist/server.js +125 -36
  61. package/dist/server.js.map +1 -1
  62. package/dist/tools/project.d.ts +7 -3
  63. package/dist/tools/project.d.ts.map +1 -1
  64. package/dist/tools/project.js +45 -3
  65. package/dist/tools/project.js.map +1 -1
  66. package/dist/tools/report-issue.d.ts +77 -0
  67. package/dist/tools/report-issue.d.ts.map +1 -0
  68. package/dist/tools/report-issue.js +92 -0
  69. package/dist/tools/report-issue.js.map +1 -0
  70. package/dist/tools/runtime.d.ts +6 -0
  71. package/dist/tools/runtime.d.ts.map +1 -0
  72. package/dist/tools/runtime.js +18 -0
  73. package/dist/tools/runtime.js.map +1 -0
  74. package/dist/tools/source.d.ts +1 -1
  75. package/dist/tools/source.d.ts.map +1 -1
  76. package/dist/tools/source.js +5 -1
  77. package/dist/tools/source.js.map +1 -1
  78. package/package.json +4 -1
  79. package/scripts/build-runtime-agent.mjs +64 -0
  80. package/scripts/fixtures/mod-license-corpus.json +154 -0
  81. package/scripts/gradle/modlens-runtime.init.gradle +17 -0
  82. package/scripts/runtime-test-client.mjs +36 -0
  83. package/scripts/test-hosted-policy.mjs +123 -0
  84. package/scripts/test-mod-licenses-live.mjs +86 -0
  85. package/scripts/test-package.mjs +44 -0
  86. package/scripts/test-project-http.mjs +46 -3
  87. package/scripts/test-runtime-gradle.mjs +107 -0
  88. package/scripts/test-runtime-minecraft.mjs +190 -0
  89. package/scripts/test-runtime-native.mjs +120 -0
  90. package/scripts/test-runtime-remote.mjs +209 -0
  91. package/scripts/update-license-templates.mjs +14 -0
@@ -0,0 +1,154 @@
1
+ [
2
+ {
3
+ "name": "ModMenu",
4
+ "version": "v6Xx3fbU",
5
+ "release": "11.0.4",
6
+ "hash": "a88e0d67c82b0434cbf56776c482587c275ed660f635d33f53ba485dfa30dcc3",
7
+ "license": "MIT",
8
+ "expected": "hosted_allowed",
9
+ "target": "com.terraformersmc.modmenu.ModMenu"
10
+ },
11
+ {
12
+ "name": "FerriteCore",
13
+ "version": "sOzRw3CG",
14
+ "release": "7.0.3-fabric",
15
+ "hash": "98c3ab1d5aab8f14b5d082d3f1a467727fd335f02d124555c94339761c4c18f2",
16
+ "license": "MIT",
17
+ "expected": "hosted_allowed",
18
+ "target": "malte0811.ferritecore.IPlatformHooks"
19
+ },
20
+ {
21
+ "name": "MouseTweaks",
22
+ "version": "9I21YYxf",
23
+ "release": "1.21-2.26.1-neoforge",
24
+ "hash": "68e6f4201c5de97b77929a7215c9552495696ca6a3bf3ae4eacc34e135f6cc8b",
25
+ "license": "BSD-3-Clause",
26
+ "expected": "hosted_allowed",
27
+ "target": "yalter.mousetweaks.Constants"
28
+ },
29
+ {
30
+ "name": "DynamicFPS",
31
+ "version": "LqevwKp5",
32
+ "release": "3.11.4",
33
+ "hash": "0929d2b9e164a08d25a7ba8a5f0750081b66dea9171cec52d2f0fdc792fc7e5e",
34
+ "license": "MIT",
35
+ "expected": "hosted_allowed",
36
+ "target": "architectury_inject_dynamicfps_common_96ad272d90fa4fcc8d67753a3449cf6d_48138490a95625983251b45ac7336d8a7e463234a1fad5dfdc4c144b1c5f439adynamicfps3114devjar.PlatformMethods"
37
+ },
38
+ {
39
+ "name": "Controlling",
40
+ "version": "FaNppCJJ",
41
+ "release": "19.0.5",
42
+ "hash": "cc525fb6b030d9e4d33176983f278919e17b33935e2ca568929bdfa98f2b44a0",
43
+ "license": "MIT",
44
+ "expected": "hosted_allowed",
45
+ "target": "com.blamejared.controlling.Controlling"
46
+ },
47
+ {
48
+ "name": "Clumps",
49
+ "version": "jo7lDoK4",
50
+ "release": "19.0.0.1",
51
+ "hash": "b524ccdace2ef8fd19f5b2074f7de1103ac5065c52553f064c00e098346c293e",
52
+ "license": "MIT",
53
+ "expected": "hosted_allowed",
54
+ "target": "com.blamejared.clumps.helper.IClumpedOrb"
55
+ },
56
+ {
57
+ "name": "AppleSkin",
58
+ "version": "uAKA6Laj",
59
+ "release": "3.0.9+mc1.21",
60
+ "hash": "38b48dd6231341c9f964ce6e42c57ec866c6cc8f72bec938390a59bafb3922df",
61
+ "license": "Unlicense",
62
+ "expected": "hosted_allowed",
63
+ "target": "squeek.appleskin.ModInfo"
64
+ },
65
+ {
66
+ "name": "Architectury",
67
+ "version": "1IiqEQGl",
68
+ "release": "13.0.11+neoforge",
69
+ "hash": "9cc92f2c09533fc5482c60f993bd891c655a0e32b637037cdcb7c2b23adedeeb",
70
+ "license": "LGPL-3.0-only",
71
+ "expected": "local_only",
72
+ "target": "architectury_inject_architectury_common_d9fb27da44a148dfa43566d20897d6de_f4ed98e062c0e85c5cc998c6fbbab16b146fa1f727cd0f469b44c896e896070earchitectury13011devjar.PlatformMethods"
73
+ },
74
+ {
75
+ "name": "ClothConfig",
76
+ "version": "izKINKFg",
77
+ "release": "15.0.140+neoforge",
78
+ "hash": "65e722e0d98431a07c45f8bdd8d529a217cc8c175fde1740248bd5c1b4f3c0d4",
79
+ "license": "LGPL-3.0-only",
80
+ "expected": "local_only",
81
+ "target": "architectury_inject_clothconfig_common_d551de04735c42148ec07a78c43007d1_c487a9cf12e7d5bea169da255b814f03602bdeb8b51cff67162252c24f285789clothconfig150140devjar.PlatformMethods"
82
+ },
83
+ {
84
+ "name": "ModernFix",
85
+ "version": "5HLHxQ2F",
86
+ "release": "5.27.24+mc1.21.1",
87
+ "hash": "e6e9446890f0feb3aab3f6e73ae18cb17575c370f232179fef7baa30e61538fe",
88
+ "license": "LGPL-3.0-only",
89
+ "expected": "local_only",
90
+ "target": "org.embeddedt.modernfix.ModernFix"
91
+ },
92
+ {
93
+ "name": "Spark",
94
+ "version": "v5qtqRQi",
95
+ "release": "1.10.124-neoforge-1.21.1",
96
+ "hash": "647e8a81afbe414dba1df4ba15fd06c5d32d4cb544e68828405e8e074c2e16db",
97
+ "license": "GPL-3.0-only",
98
+ "expected": "local_only",
99
+ "target": "me.lucko.spark.neoforge.NeoForgePlatformInfo"
100
+ },
101
+ {
102
+ "name": "Sodium",
103
+ "version": "uMOpc5uV",
104
+ "release": "mc1.21.1-0.8.13-neoforge",
105
+ "hash": "575d187b15328d7bddd8a9b1bee64333c22c14735f5780ec49eac0cd5503c08d",
106
+ "license": "LicenseRef-Polyform-Shield-1.0.0",
107
+ "expected": "local_only",
108
+ "target": "net.caffeinemc.mods.sodium.service.SodiumServiceModLocator"
109
+ },
110
+ {
111
+ "name": "Iris",
112
+ "version": "t3ruzodq",
113
+ "release": "1.8.12+1.21.1-neoforge",
114
+ "hash": "3e3f94d2d5cdcb8145c4385d521974aa2d8669b2cc212351f70cf06488182e83",
115
+ "license": "LGPL-3.0-only",
116
+ "expected": "local_only",
117
+ "target": "de.odysseus.ithaka.digraph.Digraph"
118
+ },
119
+ {
120
+ "name": "Lithium",
121
+ "version": "N08Z8wog",
122
+ "release": "mc1.21.1-0.15.4-fabric",
123
+ "hash": "92329d98c57f5a22091a0adb509fdb1d60113a5602c708b799b13134bad13baa",
124
+ "license": "LGPL-3.0-only",
125
+ "expected": "local_only",
126
+ "target": "net.caffeinemc.mods.lithium.api.inventory.LithiumCooldownReceivingInventory"
127
+ },
128
+ {
129
+ "name": "EntityCulling",
130
+ "version": "8w8FfUPs",
131
+ "release": "1.11.2",
132
+ "hash": "3983b981da4a0583c7e58184a8631a23e56cc3b81f4cb53491ca600382879b82",
133
+ "license": "LicenseRef-tr7zw-Protective-License",
134
+ "expected": "local_only",
135
+ "target": "com.logisticscraft.occlusionculling.DataProvider"
136
+ },
137
+ {
138
+ "name": "JourneyMap",
139
+ "version": "CtFE7hwT",
140
+ "release": "1.21.1-6.0.9+neoforge",
141
+ "hash": "9bd7734542233d5281750572f17a9258449e826650c081e9a67dc426262c2fde",
142
+ "license": "LicenseRef-All-Rights-Reserved",
143
+ "expected": "local_only",
144
+ "target": "earcut4j.Earcut"
145
+ },
146
+ {
147
+ "name": "FabricAPI",
148
+ "version": "Mys3P7lK",
149
+ "release": "0.116.17+1.21.1",
150
+ "hash": "79ac44b40780acbd884b34c50be1e39af682847e5f5cb3b1fddeeaa768dce800",
151
+ "license": "Apache-2.0",
152
+ "expected": "hosted_allowed"
153
+ }
154
+ ]
@@ -0,0 +1,17 @@
1
+ // Opt-in launch only. Never instrument Gradle itself or every JavaExec task.
2
+ gradle.projectsEvaluated {
3
+ def configFile = new File(gradle.rootProject.projectDir, '.modlens/runtime/launch.properties')
4
+ if (!configFile.isFile()) throw new GradleException('Call ModLens runtime.setup for this project first')
5
+ def config = new Properties()
6
+ configFile.withInputStream { config.load(it) }
7
+ def taskPath = config.getProperty('task')
8
+ if (!taskPath.startsWith(':')) taskPath = ':' + taskPath
9
+ def target = gradle.rootProject.tasks.findByPath(taskPath)
10
+ if (!(target instanceof JavaExec)) {
11
+ throw new GradleException("ModLens requires a JavaExec dev run task; ${taskPath} is ${target?.class?.name ?: 'missing'}. Use every returned vmOptions entry on the actual game launch instead.")
12
+ }
13
+ target.jvmArgs("-javaagent:${config.getProperty('agent')}=${config.getProperty('connection')}")
14
+ // Minecraft 26.3 client startup needs this even without the agent.
15
+ target.jvmArgs('-XX:StackShadowPages=32')
16
+ target.jvmArgs("-XX:ErrorFile=${new File(configFile.parentFile, 'hs_err_pid%p.log').absolutePath}")
17
+ }
@@ -0,0 +1,36 @@
1
+ // Test adapter: run the same fixture through separate CLI processes instead of
2
+ // importing RuntimeHub. Each call reconnects to the persistent companion.
3
+ import { writeFile } from "node:fs/promises";
4
+ import { execFileSync } from "node:child_process";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { randomUUID } from "node:crypto";
8
+
9
+ export function runtimeTestClient(root) {
10
+ const launcher = fileURLToPath(new URL("../dist/launcher.js", import.meta.url));
11
+ const env = { ...process.env, MODLENS_CACHE_ROOT: join(root, "helper-cache") };
12
+ const run = (args) =>
13
+ JSON.parse(
14
+ execFileSync(process.execPath, [launcher, "--runtime", ...args], {
15
+ env,
16
+ encoding: "utf8",
17
+ timeout: 45000,
18
+ }),
19
+ );
20
+ async function request(body) {
21
+ const file = join(root, `helper-${randomUUID()}.json`);
22
+ await writeFile(file, JSON.stringify(body));
23
+ return run(["--request-file", file]);
24
+ }
25
+ return {
26
+ setup: (projectDir, mode, gradleTask, minecraftHooks) =>
27
+ request({ action: "setup", projectDir, mode, gradleTask, minecraftHooks }),
28
+ list: () => request({ action: "sessions" }),
29
+ events: (afterCursor, waitMs, sessionId) =>
30
+ request({ action: "events", afterCursor, waitMs, sessionId }),
31
+ command: (sessionId, command) => request({ action: "command", sessionId, command }),
32
+ artifact: (sessionId, artifactName) => request({ action: "artifact", sessionId, artifactName }),
33
+ launch: (projectId, javaHome) => request({ action: "launch", projectId, javaHome }),
34
+ close: async () => run(["stop"]),
35
+ };
36
+ }
@@ -0,0 +1,123 @@
1
+ // Real transports, synthetic source only: no Minecraft download or decompilation.
2
+ import assert from "node:assert/strict";
3
+ import { spawn } from "node:child_process";
4
+ import { randomBytes } from "node:crypto";
5
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { createServer } from "node:net";
10
+ import { once } from "node:events";
11
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
12
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
13
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
14
+
15
+ const root = await mkdtemp(join(tmpdir(), "modlens-hosted-test-"));
16
+ const entry = fileURLToPath(new URL("../dist/server.js", import.meta.url));
17
+ const listener = createServer();
18
+ listener.listen(0, "127.0.0.1");
19
+ await once(listener, "listening");
20
+ const port = listener.address().port;
21
+ await new Promise(resolve => listener.close(resolve));
22
+ const endpoint = new URL(`http://127.0.0.1:${port}/mcp`);
23
+ const secret = randomBytes(32).toString("hex");
24
+ const env = { ...process.env, MCP_PORT: String(port), MCP_HOST: "127.0.0.1", MODLENS_HOME: root,
25
+ MODLENS_CACHE_ROOT: join(root, "cache"), DATABASE_URL: `file:${join(root, "usage.db")}`,
26
+ MODLENS_HOSTED_LIMITS: "1", MODLENS_HOSTED_PROXY_SECRET: secret, MODLENS_HOSTED_SOURCE_LINES: "200",
27
+ MODLENS_HOSTED_DAILY_BYTES: "15000", MODLENS_HOSTED_PERIOD_BYTES: "100000",
28
+ MODLENS_HOSTED_DAILY_REQUESTS: "1000", MODLENS_HOSTED_MINUTE_REQUESTS: "120",
29
+ MODLENS_HOSTED_RESPONSE_BYTES: "131072", MODLENS_AUTO_EMBED: "0", MODLENS_AUTO_GRAPH: "0" };
30
+ const source = Array.from({ length: 600 }, (_, i) => `// Synthetic fixture line ${i + 1}`).join("\n");
31
+ const fixtureDir = join(env.MODLENS_CACHE_ROOT, "mc-decompiled-named", "1.21.1", "example");
32
+ await mkdir(fixtureDir, { recursive: true });
33
+ await writeFile(join(fixtureDir, "Fixture.java"), source);
34
+ const headers = user => ({ "x-modlens-proxy-secret": secret, "x-modlens-user-id": user });
35
+ let server, exited;
36
+ const clients = [];
37
+ async function start() {
38
+ let logs = "";
39
+ server = spawn(process.execPath, [entry], { cwd: root, env, stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
40
+ exited = once(server, "exit");
41
+ server.stderr.on("data", data => { logs += data; });
42
+ await new Promise((resolve, reject) => {
43
+ const timer = setTimeout(() => reject(new Error(`Startup timed out: ${logs}`)), 15000);
44
+ const ready = () => {
45
+ if (logs.includes("listening on")) { clearTimeout(timer); server.stderr.off("data", ready); resolve(); }
46
+ };
47
+ server.stderr.on("data", ready);
48
+ server.once("error", reject);
49
+ server.once("exit", () => { clearTimeout(timer); reject(new Error(`Server exited: ${logs}`)); });
50
+ ready();
51
+ });
52
+ }
53
+ async function connect(user) {
54
+ const client = new Client({ name: "hosted-policy-test", version: "1" });
55
+ clients.push(client);
56
+ const transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: headers(user) } });
57
+ await client.connect(transport);
58
+ return { client, transport };
59
+ }
60
+ const read = (client, startLine = 1) => client.callTool({ name: "mc_source", arguments: {
61
+ action: "get_source", version: "1.21.1", className: "example.Fixture", startLine, maxLines: 10000,
62
+ } });
63
+ let passed = false;
64
+ try {
65
+ await start();
66
+ const unauthorized = await fetch(endpoint, { method: "POST", headers: { "x-modlens-user-id": "spoof" }, body: "{}" });
67
+ assert.equal(unauthorized.status, 401);
68
+ const { client, transport } = await connect("developer-a");
69
+ const listed = await client.listTools();
70
+ assert.ok(listed.tools.some(t => t.name === "report_issue"), "Hosted users must discover issue reporting");
71
+ const reporter = await connect("issue-reporter");
72
+ const preparedIssue = await reporter.client.callTool({ name: "report_issue", arguments: {
73
+ action: "prepare", title: "Hosted test fixture", summary: "Synthetic report; never submit.",
74
+ } });
75
+ assert.ok(!preparedIssue.isError, JSON.stringify(preparedIssue));
76
+ const issuePlan = JSON.parse(preparedIssue.content[0].text);
77
+ assert.equal(issuePlan.state, "draft_prepared");
78
+ assert.equal(issuePlan.executed, false);
79
+ assert.equal(issuePlan.repository.url, "https://github.com/CreeperHost/modlens-mcp");
80
+ const mc = listed.tools.find(t => t.name === "mc_source");
81
+ assert.ok(mc.inputSchema.properties.action.enum.includes("bytecode"));
82
+ for (const action of ["decompile", "get_paths", "index", "decompile_status"]) {
83
+ assert.ok(!mc.inputSchema.properties.action.enum.includes(action), action);
84
+ }
85
+ assert.ok(listed.tools.some(t => t.name === "mc_data"));
86
+ assert.ok(listed.tools.find(t => t.name === "mod_bytecode").inputSchema.properties.startLine);
87
+ const denied = await client.callTool({ name: "mc_source", arguments: { action: "get_paths", version: "1.21.1" } });
88
+ assert.ok(denied.isError);
89
+ const wrongOwner = await fetch(endpoint, { headers: { ...headers("developer-b"), "mcp-session-id": transport.sessionId, accept: "text/event-stream" } });
90
+ assert.equal(wrongOwner.status, 404);
91
+ const first = await read(client);
92
+ assert.ok(!first.isError, JSON.stringify(first));
93
+ assert.equal(first.content[0].text, source.split("\n").slice(0, 200).join("\n"));
94
+ const second = await read(client, 201);
95
+ assert.ok(!second.isError, JSON.stringify(second));
96
+ assert.equal(second.content[0].text, source.split("\n").slice(200, 400).join("\n"));
97
+ const exhausted = await read(client, 401);
98
+ assert.ok(exhausted.isError);
99
+ assert.ok(!JSON.stringify(exhausted).includes("Synthetic fixture"));
100
+ const reconnected = await connect("developer-a");
101
+ assert.ok((await read(reconnected.client)).isError, "reconnect must retain quota");
102
+ for (const c of clients.splice(0)) await c.close();
103
+ server.kill(); await exited;
104
+ await start();
105
+ const restarted = await connect("developer-a");
106
+ assert.ok((await read(restarted.client)).isError, "restart must retain quota");
107
+ const different = await connect("developer-b");
108
+ assert.ok(!(await read(different.client)).isError, "separate verified accounts have separate allowances");
109
+ const local = new Client({ name: "hosted-local-test", version: "1" });
110
+ clients.push(local);
111
+ await local.connect(new StdioClientTransport({ command: process.execPath, args: [entry], cwd: root,
112
+ env: { ...env, MCP_PORT: "" }, stderr: "pipe" }));
113
+ const localResult = await read(local);
114
+ assert.ok(!localResult.isError, JSON.stringify(localResult));
115
+ assert.equal(localResult.content[0].text, source, "local source access remains unrestricted");
116
+ passed = true;
117
+ console.log("PASS: hosted discovery, authentication, account/session binding, source paging, cumulative release limits, reconnect/restart persistence and unrestricted local access");
118
+ } finally {
119
+ for (const client of clients) await client.close().catch(() => {});
120
+ if (server && server.exitCode === null) { server.kill(); await exited; }
121
+ if (passed) await rm(root, { recursive: true, force: true });
122
+ else console.error(`Fixture retained for debugging: ${root}`);
123
+ }
@@ -0,0 +1,86 @@
1
+ // Opt-in regression against real, immutable release JARs and live evidence APIs.
2
+ // Downloads test artifacts only; no credentials, ingestion or bulk source export.
3
+ import assert from 'node:assert/strict';
4
+ import { createHash } from 'node:crypto';
5
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
6
+ import { join, resolve } from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ import { once } from 'node:events';
9
+ import AdmZip from 'adm-zip';
10
+ import { inspectModLicense } from '../dist/mod-license.js';
11
+
12
+ const root = resolve('.scratch/license-live');
13
+ await mkdir(root, { recursive: true });
14
+ const cases = [
15
+ { name: 'JEI', version: 'JhEb3Vl1', hash: 'a9418a15032d1653b4776763f2df7c54544a1c2314fe1c4269ba60d9bb51a204', license: 'MIT', target: 'mezz.jei.common.Constants' },
16
+ { name: 'Jade', version: 'yd8FKCmx', hash: '067bb4b007e1d6f6b79f0afe99c91252aa825472b99a76d33a60d24442f9e92d', license: 'CC-BY-NC-SA-4.0', target: 'snownee.jade.Jade' },
17
+ { name: 'Embeddium', version: 'UTbfe5d1', hash: 'eed3d1325f2acc2fd4e69bb495e5ccb91d962126ac5330f0582ebc2a3daf47fb', license: 'LGPL-3.0-only', target: 'org.embeddedt.embeddium.api.EmbeddiumConstants' },
18
+ ];
19
+ if (process.argv.includes('--corpus')) {
20
+ cases.push(...JSON.parse(await readFile(new URL('./fixtures/mod-license-corpus.json', import.meta.url), 'utf8')));
21
+ }
22
+ const outcomes = [];
23
+ const digest = data => createHash('sha256').update(data).digest('hex');
24
+ for (const item of cases) {
25
+ const outcome = { name: item.name, version: item.version, expected: item.expected ?? 'hosted_allowed' };
26
+ outcomes.push(outcome);
27
+ try {
28
+ const jar = join(root, `${item.name}.jar`);
29
+ let data = await readFile(jar).catch(() => null);
30
+ if (!data || digest(data) !== item.hash) {
31
+ const response = await fetch(`https://api.modrinth.com/v2/version/${item.version}`, { headers: { 'User-Agent': 'modlens-mcp/license-regression' } });
32
+ assert.equal(response.status, 200);
33
+ const version = await response.json();
34
+ const url = new URL(version.files.find(f => f.primary)?.url ?? version.files[0].url);
35
+ assert.equal(url.origin, 'https://cdn.modrinth.com');
36
+ const download = await fetch(url, { redirect: 'error' });
37
+ assert.equal(download.status, 200);
38
+ data = Buffer.from(await download.arrayBuffer());
39
+ assert.equal(digest(data), item.hash, `${item.name} exact artifact`);
40
+ await writeFile(jar, data);
41
+ }
42
+ const decision = await inspectModLicense(jar);
43
+ await writeFile(join(root, `${item.name}.decision.json`), JSON.stringify(decision, null, 2));
44
+ Object.assign(outcome, { actual: decision.disposition, license: decision.selectedLicense, origin: decision.selectedOrigin, reason: decision.reason, notices: decision.notices.length });
45
+ assert.equal(decision.disposition, outcome.expected, `${item.name}: ${decision.reason}`);
46
+ if (decision.disposition === 'hosted_allowed') {
47
+ assert.equal(decision.selectedLicense, item.license);
48
+ assert.ok(decision.notices.length);
49
+ }
50
+ if (item.name === 'Jade') assert.equal(decision.conditions.noncommercialOnly, true);
51
+ if (item.name === 'Embeddium') {
52
+ assert.ok(decision.conditions.sourceArchive?.includes('87d1a75'));
53
+ assert.ok(decision.notices.some(n => n.name.includes('COPYING.LESSER')));
54
+ assert.ok(decision.notices.some(n => n.name.startsWith('dependency/')));
55
+ const response = await fetch(decision.conditions.sourceArchive, { method: 'HEAD' });
56
+ assert.equal(response.status, 200, 'complete corresponding-source archive is available');
57
+ }
58
+ if (process.argv.includes('--http') && decision.disposition === 'hosted_allowed' && item.target) {
59
+ const bundle = new AdmZip();
60
+ bundle.addFile('artifacts/0.jar', data);
61
+ bundle.addFile('manifest.json', Buffer.from(JSON.stringify({ format: 'modlens-project-v1', project: `license-${item.name}:`, sourceSet: 'main',
62
+ toolchain: 'gradle-compile-classpath', minecraftVersion: '', loaderVersion: '', mappings: 'compile-classpath', javaVersion: 21, transformations: [],
63
+ artifacts: [{ path: 'artifacts/0.jar', name: `${item.name}.jar`, size: data.length, sha256: item.hash, kind: 'classpath' }] })));
64
+ const bundlePath = join(root, `${item.name}.zip`), keyPath = join(root, `${item.name}.key`);
65
+ await writeFile(bundlePath, bundle.toBuffer());
66
+ await writeFile(keyPath, createHash('sha256').update(`disposable-test-${item.hash}`).digest('hex'));
67
+ const child = spawn(process.execPath, ['scripts/test-project-http.mjs', bundlePath, keyPath, item.target], { stdio: 'inherit',
68
+ env: { ...process.env, MODLENS_TEST_EXPECTED_LICENSE: item.license } });
69
+ const [code] = await once(child, 'exit');
70
+ assert.equal(code, 0, `${item.name} real HTTP hosted source path`);
71
+ outcome.http = 'passed';
72
+ } else if (process.argv.includes('--http')) {
73
+ outcome.http = decision.disposition === 'hosted_allowed' ? 'not run: no root class target' : 'not run: local-only decision';
74
+ }
75
+ outcome.passed = true;
76
+ console.log(`PASS: ${item.name}: ${decision.disposition}, ${decision.selectedLicense ?? 'no selected licence'} from ${decision.selectedOrigin ?? 'no selected origin'}, ${decision.notices.length} complete notices`);
77
+ } catch (error) {
78
+ outcome.passed = false;
79
+ outcome.error = error instanceof Error ? error.message : String(error);
80
+ console.error(`FAIL: ${item.name}: ${outcome.error}`);
81
+ }
82
+ }
83
+ const summary = { checkedAt: new Date().toISOString(), total: outcomes.length, passed: outcomes.filter(o => o.passed).length, failed: outcomes.filter(o => !o.passed).length, outcomes };
84
+ await writeFile(join(root, process.argv.includes('--corpus') ? 'corpus-results.json' : 'baseline-results.json'), JSON.stringify(summary, null, 2));
85
+ console.log(`Licence corpus: ${summary.passed}/${summary.total} passed; ${summary.failed} failed.`);
86
+ if (summary.failed) process.exitCode = 1;
@@ -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" })), []);
@@ -181,6 +211,20 @@ try {
181
211
  assert.ok(initialized.serverInfo);
182
212
  send({ method: "notifications/initialized" });
183
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/);
184
228
  assert.ok(list.tools.some(tool => tool.name === "mod"));
185
229
  assert.ok(list.tools.some(tool => tool.name === "project"));
186
230
  const stats = await request("tools/call", { name: "mod", arguments: { action: "stats" } });
@@ -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();