@creeperhost/modlens-mcp 1.6.18 → 1.6.19
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 +97 -10
- package/TESTING.md +69 -0
- package/dist/cli.js +21 -5
- package/dist/cli.js.map +1 -1
- package/dist/curseforge.d.ts +9 -7
- package/dist/curseforge.d.ts.map +1 -1
- package/dist/curseforge.js +48 -55
- package/dist/curseforge.js.map +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +31 -1
- package/dist/db.js.map +1 -1
- package/dist/embeddings.d.ts.map +1 -1
- package/dist/embeddings.js +1 -0
- package/dist/embeddings.js.map +1 -1
- package/dist/feed-the-beast.d.ts +5 -5
- package/dist/feed-the-beast.d.ts.map +1 -1
- package/dist/feed-the-beast.js +5 -21
- package/dist/feed-the-beast.js.map +1 -1
- package/dist/java-tools.d.ts +1 -0
- package/dist/java-tools.d.ts.map +1 -1
- package/dist/java-tools.js +31 -7
- package/dist/java-tools.js.map +1 -1
- package/dist/launcher.js +15 -0
- package/dist/launcher.js.map +1 -1
- package/dist/mappings.d.ts +4 -1
- package/dist/mappings.d.ts.map +1 -1
- package/dist/mappings.js +120 -52
- package/dist/mappings.js.map +1 -1
- package/dist/minecraft.d.ts +0 -4
- package/dist/minecraft.d.ts.map +1 -1
- package/dist/minecraft.js +7 -23
- package/dist/minecraft.js.map +1 -1
- package/dist/mod-artifacts.d.ts +4 -0
- package/dist/mod-artifacts.d.ts.map +1 -0
- package/dist/mod-artifacts.js +58 -0
- package/dist/mod-artifacts.js.map +1 -0
- package/dist/modpacks-ch.d.ts +72 -46
- package/dist/modpacks-ch.d.ts.map +1 -1
- package/dist/modpacks-ch.js +134 -20
- package/dist/modpacks-ch.js.map +1 -1
- package/dist/modrinth.d.ts +14 -6
- package/dist/modrinth.d.ts.map +1 -1
- package/dist/modrinth.js +70 -55
- package/dist/modrinth.js.map +1 -1
- package/dist/platform-adapter.d.ts +1 -0
- package/dist/platform-adapter.d.ts.map +1 -1
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js +20 -44
- package/dist/platform.js.map +1 -1
- package/dist/processor.d.ts +5 -0
- package/dist/processor.d.ts.map +1 -1
- package/dist/processor.js +166 -46
- package/dist/processor.js.map +1 -1
- package/dist/registry-submission.d.ts +34 -0
- package/dist/registry-submission.d.ts.map +1 -0
- package/dist/registry-submission.js +128 -0
- package/dist/registry-submission.js.map +1 -0
- package/dist/repositories/array-fields.d.ts +3 -0
- package/dist/repositories/array-fields.d.ts.map +1 -0
- package/dist/repositories/array-fields.js +8 -0
- package/dist/repositories/array-fields.js.map +1 -0
- package/dist/repositories/embeddings-sqlite.d.ts +1 -0
- package/dist/repositories/embeddings-sqlite.d.ts.map +1 -1
- package/dist/repositories/embeddings-sqlite.js +26 -58
- package/dist/repositories/embeddings-sqlite.js.map +1 -1
- package/dist/repositories/embeddings.d.ts.map +1 -1
- package/dist/repositories/embeddings.js +26 -3
- package/dist/repositories/embeddings.js.map +1 -1
- package/dist/repositories/index.d.ts +1 -1
- package/dist/repositories/index.d.ts.map +1 -1
- package/dist/repositories/mod.d.ts +8 -5
- package/dist/repositories/mod.d.ts.map +1 -1
- package/dist/repositories/mod.js +25 -11
- package/dist/repositories/mod.js.map +1 -1
- package/dist/repositories/packs.d.ts.map +1 -1
- package/dist/repositories/packs.js +10 -2
- package/dist/repositories/packs.js.map +1 -1
- package/dist/search-adapter.d.ts +2 -0
- package/dist/search-adapter.d.ts.map +1 -1
- package/dist/search-adapter.js +21 -9
- package/dist/search-adapter.js.map +1 -1
- package/dist/security.d.ts +2 -0
- package/dist/security.d.ts.map +1 -1
- package/dist/security.js +5 -1
- package/dist/security.js.map +1 -1
- package/dist/server.js +63 -25
- package/dist/server.js.map +1 -1
- package/dist/sqlite-json.d.ts +3 -0
- package/dist/sqlite-json.d.ts.map +1 -0
- package/dist/sqlite-json.js +78 -0
- package/dist/sqlite-json.js.map +1 -0
- package/dist/sqlite-parameters.d.ts +3 -0
- package/dist/sqlite-parameters.d.ts.map +1 -0
- package/dist/sqlite-parameters.js +19 -0
- package/dist/sqlite-parameters.js.map +1 -0
- package/dist/sqlite-schema.d.ts +4 -0
- package/dist/sqlite-schema.d.ts.map +1 -0
- package/dist/sqlite-schema.js +76 -0
- package/dist/sqlite-schema.js.map +1 -0
- package/dist/tools/catalog.d.ts +1 -6
- package/dist/tools/catalog.d.ts.map +1 -1
- package/dist/tools/catalog.js +10 -14
- package/dist/tools/catalog.js.map +1 -1
- package/dist/tools/compat-check.d.ts.map +1 -1
- package/dist/tools/compat-check.js +18 -30
- package/dist/tools/compat-check.js.map +1 -1
- package/dist/tools/diagnostics.js.map +1 -1
- package/dist/tools/docs.d.ts.map +1 -1
- package/dist/tools/docs.js +8 -3
- package/dist/tools/docs.js.map +1 -1
- package/dist/tools/embed-registry.d.ts +20 -4
- package/dist/tools/embed-registry.d.ts.map +1 -1
- package/dist/tools/embed-registry.js +30 -19
- package/dist/tools/embed-registry.js.map +1 -1
- package/dist/tools/graphify.d.ts +19 -4
- package/dist/tools/graphify.d.ts.map +1 -1
- package/dist/tools/graphify.js +61 -13
- package/dist/tools/graphify.js.map +1 -1
- package/dist/tools/ingest.d.ts.map +1 -1
- package/dist/tools/ingest.js +4 -4
- package/dist/tools/ingest.js.map +1 -1
- package/dist/tools/kubejs.js +1 -1
- package/dist/tools/kubejs.js.map +1 -1
- package/dist/tools/mc-fts.d.ts.map +1 -1
- package/dist/tools/mc-fts.js +9 -4
- package/dist/tools/mc-fts.js.map +1 -1
- package/dist/tools/mcmeta.d.ts +10 -1
- package/dist/tools/mcmeta.d.ts.map +1 -1
- package/dist/tools/mcmeta.js +43 -29
- package/dist/tools/mcmeta.js.map +1 -1
- package/dist/tools/mixin-scan.d.ts +12 -6
- package/dist/tools/mixin-scan.d.ts.map +1 -1
- package/dist/tools/mixin-scan.js +12 -7
- package/dist/tools/mixin-scan.js.map +1 -1
- package/dist/tools/mod-data.d.ts.map +1 -1
- package/dist/tools/mod-data.js +40 -27
- package/dist/tools/mod-data.js.map +1 -1
- package/dist/tools/modpacks-ch.d.ts +9 -5
- package/dist/tools/modpacks-ch.d.ts.map +1 -1
- package/dist/tools/modpacks-ch.js +54 -33
- package/dist/tools/modpacks-ch.js.map +1 -1
- package/dist/tools/packtools.js +3 -3
- package/dist/tools/packtools.js.map +1 -1
- package/dist/tools/platform.d.ts +2 -2
- package/dist/tools/platform.d.ts.map +1 -1
- package/dist/tools/platform.js +33 -23
- package/dist/tools/platform.js.map +1 -1
- package/dist/tools/project.d.ts +342 -0
- package/dist/tools/project.d.ts.map +1 -0
- package/dist/tools/project.js +519 -0
- package/dist/tools/project.js.map +1 -0
- package/dist/tools/reports.d.ts.map +1 -1
- package/dist/tools/reports.js +5 -3
- package/dist/tools/reports.js.map +1 -1
- package/dist/tools/vanilla-data.d.ts.map +1 -1
- package/dist/tools/vanilla-data.js +3 -33
- package/dist/tools/vanilla-data.js.map +1 -1
- package/dist/tools/vanilla.d.ts +3 -1
- package/dist/tools/vanilla.d.ts.map +1 -1
- package/dist/tools/vanilla.js +26 -12
- package/dist/tools/vanilla.js.map +1 -1
- package/dist/tools/version-diff.d.ts.map +1 -1
- package/dist/tools/version-diff.js +4 -24
- package/dist/tools/version-diff.js.map +1 -1
- package/dist/version-ranges.d.ts +5 -0
- package/dist/version-ranges.d.ts.map +1 -0
- package/dist/version-ranges.js +93 -0
- package/dist/version-ranges.js.map +1 -0
- package/package.json +5 -1
- package/prisma/backends/template.db +0 -0
- package/scripts/build-template-db.mjs +3 -1
- package/scripts/enable-sqlite-vec.mjs +2 -106
- package/scripts/gradle/modlens.init.gradle +154 -0
- package/scripts/project-upload.mjs +74 -0
- package/scripts/test-minecraft-eras.mjs +202 -0
- package/scripts/test-package.mjs +8 -0
- package/scripts/test-project-gradle.mjs +76 -0
- package/scripts/test-project-http.mjs +98 -0
- package/scripts/test-setup.mjs +18 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Apply without modifying a project: ./gradlew -I /path/to/modlens.init.gradle :modlensExport
|
|
2
|
+
// ModDevGradle 2.x adapter plus a generic Java source-set compile-classpath exporter.
|
|
3
|
+
import groovy.json.JsonOutput
|
|
4
|
+
import java.security.MessageDigest
|
|
5
|
+
import java.security.SecureRandom
|
|
6
|
+
import java.nio.file.Files
|
|
7
|
+
import java.nio.file.StandardCopyOption
|
|
8
|
+
import java.nio.file.StandardOpenOption
|
|
9
|
+
import java.nio.file.FileAlreadyExistsException
|
|
10
|
+
import java.util.zip.ZipEntry
|
|
11
|
+
import java.util.zip.ZipOutputStream
|
|
12
|
+
import org.gradle.api.GradleException
|
|
13
|
+
import org.gradle.api.plugins.JavaPluginExtension
|
|
14
|
+
import org.gradle.api.provider.Provider
|
|
15
|
+
|
|
16
|
+
def sha256 = { File file ->
|
|
17
|
+
def md = MessageDigest.getInstance('SHA-256')
|
|
18
|
+
file.withInputStream { input ->
|
|
19
|
+
byte[] buffer = new byte[65536]
|
|
20
|
+
int count
|
|
21
|
+
while ((count = input.read(buffer)) != -1) md.update(buffer, 0, count)
|
|
22
|
+
}
|
|
23
|
+
md.digest().encodeHex().toString()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
gradle.projectsEvaluated {
|
|
27
|
+
allprojects { p ->
|
|
28
|
+
def javaExtension = p.extensions.findByType(JavaPluginExtension)
|
|
29
|
+
if (javaExtension == null) return
|
|
30
|
+
String selected = p.findProperty('modlensSourceSet')?.toString() ?: 'main'
|
|
31
|
+
def sourceSet = javaExtension.sourceSets.findByName(selected)
|
|
32
|
+
if (sourceSet == null) {
|
|
33
|
+
// A sibling module may not have the source set selected for the requested module.
|
|
34
|
+
p.tasks.register('modlensExport') {
|
|
35
|
+
doLast { throw new GradleException("Unknown ModLens source set '${selected}' in ${p.path}") }
|
|
36
|
+
}
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
boolean moddev = p.plugins.hasPlugin('net.neoforged.moddev') || p.plugins.hasPlugin('net.neoforged.moddev.legacyforge')
|
|
40
|
+
def artifactsTask = moddev ? p.tasks.findByName('createMinecraftArtifacts') : null
|
|
41
|
+
if (moddev && artifactsTask == null) throw new GradleException('ModLens requires a ModDevGradle version with createMinecraftArtifacts (2.x).')
|
|
42
|
+
|
|
43
|
+
p.tasks.register('modlensExport') { task ->
|
|
44
|
+
group = 'modlens'
|
|
45
|
+
description = 'Export the resolved compile environment for local or remote ModLens.'
|
|
46
|
+
// Gradle resolves/builds the actual compilation inputs, including AT processing.
|
|
47
|
+
dependsOn(sourceSet.compileClasspath)
|
|
48
|
+
if (artifactsTask != null) dependsOn(artifactsTask)
|
|
49
|
+
// This task accesses the evaluated model and is intentionally not configuration-cache compatible.
|
|
50
|
+
notCompatibleWithConfigurationCache('ModLens exports the evaluated compile classpath and modding task outputs.')
|
|
51
|
+
doLast {
|
|
52
|
+
File output = p.layout.buildDirectory.dir("modlens/${selected}").get().asFile
|
|
53
|
+
output.mkdirs()
|
|
54
|
+
def artifacts = []
|
|
55
|
+
def files = []
|
|
56
|
+
def byFile = [:]
|
|
57
|
+
def addArtifact = { File file, String kind, String sourceFor ->
|
|
58
|
+
if (!file.isFile()) throw new GradleException("Missing ModLens artifact: ${file.name}")
|
|
59
|
+
String path = "artifacts/${artifacts.size()}.jar"
|
|
60
|
+
def info = [path: path, name: file.name, sha256: sha256(file), size: file.length(), kind: kind]
|
|
61
|
+
if (sourceFor != null) info.sourceFor = sourceFor
|
|
62
|
+
artifacts.add(info)
|
|
63
|
+
files.add(file)
|
|
64
|
+
if (kind == 'classpath') byFile[file.canonicalPath] = path
|
|
65
|
+
path
|
|
66
|
+
}
|
|
67
|
+
sourceSet.compileClasspath.files.eachWithIndex { File file, int index ->
|
|
68
|
+
if (!file.exists()) throw new GradleException("Compile classpath entry does not exist: ${file.name}")
|
|
69
|
+
if (file.isDirectory()) {
|
|
70
|
+
// Project dependencies often expose class directories instead of JARs.
|
|
71
|
+
File jar = new File(output, "directory-${index}.jar")
|
|
72
|
+
def entries = p.fileTree(file).matching { include '**/*.class'; include '**/*.java' }.files.sort { a, b ->
|
|
73
|
+
file.toPath().relativize(a.toPath()).toString() <=> file.toPath().relativize(b.toPath()).toString()
|
|
74
|
+
}
|
|
75
|
+
jar.withOutputStream { stream ->
|
|
76
|
+
def zip = new ZipOutputStream(stream)
|
|
77
|
+
entries.each { entry ->
|
|
78
|
+
def name = file.toPath().relativize(entry.toPath()).toString().replace('\\', '/')
|
|
79
|
+
def ze = new ZipEntry(name); ze.time = 0
|
|
80
|
+
zip.putNextEntry(ze); entry.withInputStream { zip << it }; zip.closeEntry()
|
|
81
|
+
}
|
|
82
|
+
zip.finish()
|
|
83
|
+
}
|
|
84
|
+
addArtifact(jar, 'classpath', null)
|
|
85
|
+
} else if (file.name.endsWith('.jar')) {
|
|
86
|
+
addArtifact(file, 'classpath', null)
|
|
87
|
+
} else {
|
|
88
|
+
throw new GradleException("Unsupported compile classpath entry: ${file.name}")
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (artifacts.empty) throw new GradleException('Compile classpath is empty; there is no dependency environment to export.')
|
|
92
|
+
|
|
93
|
+
def transforms = []
|
|
94
|
+
if (artifactsTask != null) {
|
|
95
|
+
// Bind sources to the binary actually selected by the compile classpath, not a guessed cache path.
|
|
96
|
+
def binaryPaths = ['gameJarArtifact', 'gameJarWithSourcesArtifact'].collect { property ->
|
|
97
|
+
artifactsTask.hasProperty(property) ? artifactsTask.property(property).orNull?.asFile?.canonicalPath : null
|
|
98
|
+
}.findAll { it != null && byFile.containsKey(it) }
|
|
99
|
+
if (binaryPaths.empty) throw new GradleException('ModLens cannot locate ModDevGradle Minecraft output on this source set compile classpath. Select a mod-enabled source set.')
|
|
100
|
+
def sourceFile = artifactsTask.hasProperty('gameSourcesArtifact') ? artifactsTask.gameSourcesArtifact.orNull?.asFile : null
|
|
101
|
+
if (sourceFile?.isFile()) binaryPaths.each { path -> addArtifact(sourceFile, 'sources', byFile[path]) }
|
|
102
|
+
if (artifactsTask.hasProperty('accessTransformers')) {
|
|
103
|
+
artifactsTask.accessTransformers.files.each { File file -> transforms.add([name: file.name, sha256: sha256(file)]) }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
def neo = p.extensions.findByName('neoForge') ?: p.extensions.findByName('legacyForge')
|
|
107
|
+
def propertyText = { String name -> p.findProperty(name)?.toString() ?: '' }
|
|
108
|
+
String loaderVersion = propertyText('neo_version') ?: propertyText('forge_version')
|
|
109
|
+
if (neo != null && neo.hasProperty('version')) {
|
|
110
|
+
def version = neo.version
|
|
111
|
+
loaderVersion = (version instanceof Provider ? version.orNull : version)?.toString() ?: loaderVersion
|
|
112
|
+
}
|
|
113
|
+
String mappings = propertyText('modlensMappings')
|
|
114
|
+
if (!mappings && neo != null && neo.hasProperty('parchment') && neo.parchment.enabled.getOrElse(false)) {
|
|
115
|
+
mappings = "parchment:${neo.parchment.minecraftVersion.orNull ?: ''}:${neo.parchment.mappingsVersion.orNull ?: ''}"
|
|
116
|
+
}
|
|
117
|
+
if (!mappings) mappings = moddev ? 'toolchain-prepared (official names where applicable)' : 'compile-classpath'
|
|
118
|
+
def manifest = [format: 'modlens-project-v1', project: "${p.rootProject.name}${p.path}", sourceSet: selected,
|
|
119
|
+
toolchain: moddev ? 'moddevgradle' : 'gradle-compile-classpath',
|
|
120
|
+
minecraftVersion: propertyText('minecraft_version') ?: propertyText('modlensMinecraftVersion'),
|
|
121
|
+
loaderVersion: loaderVersion, mappings: mappings,
|
|
122
|
+
javaVersion: p.tasks.named(sourceSet.compileJavaTaskName).get().options.release.orNull ?: javaExtension.toolchain.languageVersion.orNull?.asInt() ?: Integer.parseInt(org.gradle.api.JavaVersion.current().majorVersion),
|
|
123
|
+
transformations: transforms, artifacts: artifacts]
|
|
124
|
+
File temporary = new File(output, 'environment.zip.tmp')
|
|
125
|
+
temporary.withOutputStream { stream ->
|
|
126
|
+
def zip = new ZipOutputStream(stream)
|
|
127
|
+
def metadata = new ZipEntry('manifest.json'); metadata.time = 0
|
|
128
|
+
zip.putNextEntry(metadata); zip.write(JsonOutput.toJson(manifest).getBytes('UTF-8')); zip.closeEntry()
|
|
129
|
+
artifacts.eachWithIndex { artifact, index ->
|
|
130
|
+
def entry = new ZipEntry(artifact.path); entry.time = 0
|
|
131
|
+
zip.putNextEntry(entry); files[index].withInputStream { zip << it }; zip.closeEntry()
|
|
132
|
+
}
|
|
133
|
+
zip.finish()
|
|
134
|
+
}
|
|
135
|
+
File destination = new File(output, 'environment.zip')
|
|
136
|
+
Files.move(temporary.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
|
137
|
+
File keyFile = new File(p.projectDir, '.gradle/modlens/project-key.txt')
|
|
138
|
+
keyFile.parentFile.mkdirs()
|
|
139
|
+
if (!keyFile.exists()) {
|
|
140
|
+
byte[] key = new byte[32]; new SecureRandom().nextBytes(key)
|
|
141
|
+
// CREATE_NEW prevents concurrent exports from silently replacing a project key.
|
|
142
|
+
try { Files.write(keyFile.toPath(), (key.encodeHex().toString() + '\n').getBytes('UTF-8'), StandardOpenOption.CREATE_NEW) }
|
|
143
|
+
catch (FileAlreadyExistsException ignored) { }
|
|
144
|
+
keyFile.setReadable(false, false); keyFile.setWritable(false, false)
|
|
145
|
+
keyFile.setReadable(true, true); keyFile.setWritable(true, true)
|
|
146
|
+
}
|
|
147
|
+
if (!(keyFile.text.trim() ==~ /[a-f0-9]{64}/)) throw new GradleException('Invalid existing ModLens project key; expected 64 lowercase hex characters.')
|
|
148
|
+
logger.lifecycle("ModLens bundle: ${destination}")
|
|
149
|
+
logger.lifecycle("ModLens private key file: ${keyFile}")
|
|
150
|
+
logger.lifecycle('Re-export and import after changing dependencies, mappings or access transformers.')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Transfer a Gradle export over MCP without exposing a local filesystem path to the server.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { createReadStream } from 'node:fs';
|
|
5
|
+
import { readFile, open, stat } from 'node:fs/promises';
|
|
6
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
7
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
8
|
+
|
|
9
|
+
const [endpoint, bundlePath, keyFile] = process.argv.slice(2);
|
|
10
|
+
if (!endpoint || !bundlePath || !keyFile || process.argv.length !== 5) {
|
|
11
|
+
console.error('Usage: node scripts/project-upload.mjs <https://host/mcp> <environment.zip> <project-key.txt>');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const url = new URL(endpoint);
|
|
15
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) {
|
|
16
|
+
throw new Error('Use HTTPS for remote project uploads (HTTP is allowed only on loopback).');
|
|
17
|
+
}
|
|
18
|
+
if (url.username || url.password || url.search || url.hash) throw new Error('Endpoint must not contain credentials, query parameters or a fragment. Use MODLENS_AUTH_TOKEN for proxy authentication.');
|
|
19
|
+
const projectKey = (await readFile(keyFile, 'utf8')).trim();
|
|
20
|
+
if (!/^[a-f0-9]{64}$/.test(projectKey)) throw new Error('Invalid project key file');
|
|
21
|
+
const size = (await stat(bundlePath)).size;
|
|
22
|
+
if (!size || size > 512 * 1024 * 1024) throw new Error('Bundle must contain 1 byte to 512 MiB');
|
|
23
|
+
const hash = createHash('sha256');
|
|
24
|
+
for await (const chunk of createReadStream(bundlePath)) hash.update(chunk);
|
|
25
|
+
const client = new Client({ name: 'modlens-project-upload', version: '1.0.0' });
|
|
26
|
+
const transport = new StreamableHTTPClientTransport(url, { requestInit: {
|
|
27
|
+
// Never follow redirects carrying the private project capability in the request body.
|
|
28
|
+
redirect: 'error',
|
|
29
|
+
headers: process.env.MODLENS_AUTH_TOKEN ? { Authorization: `Bearer ${process.env.MODLENS_AUTH_TOKEN}` } : {},
|
|
30
|
+
} });
|
|
31
|
+
let uploadId;
|
|
32
|
+
try {
|
|
33
|
+
await client.connect(transport);
|
|
34
|
+
const call = async (args) => {
|
|
35
|
+
const response = await client.callTool({ name: 'project', arguments: { ...args, projectKey } }, undefined, { timeout: 300_000 });
|
|
36
|
+
const text = response.content?.filter(c => c.type === 'text').map(c => c.text).join('\n');
|
|
37
|
+
if (response.isError) throw new Error(text || 'Project upload failed');
|
|
38
|
+
return JSON.parse(text);
|
|
39
|
+
};
|
|
40
|
+
const begin = await call({ action: 'upload_begin', size, sha256: hash.digest('hex') });
|
|
41
|
+
uploadId = begin.uploadId;
|
|
42
|
+
const file = await open(bundlePath, 'r');
|
|
43
|
+
try {
|
|
44
|
+
let offset = 0;
|
|
45
|
+
while (offset < size) {
|
|
46
|
+
const bytes = Buffer.alloc(Math.min(begin.chunkBytes, size - offset));
|
|
47
|
+
const { bytesRead } = await file.read(bytes, 0, bytes.length, offset);
|
|
48
|
+
if (bytesRead !== bytes.length) throw new Error('Bundle changed during upload; re-export it.');
|
|
49
|
+
// Retrying an acknowledged range is checked byte-for-byte by the server.
|
|
50
|
+
let result;
|
|
51
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
52
|
+
try { result = await call({ action: 'upload_chunk', uploadId, offset, data: bytes.toString('base64') }); break; }
|
|
53
|
+
catch (error) { if (attempt === 2) throw error; }
|
|
54
|
+
}
|
|
55
|
+
if (result.offset !== offset + bytes.length) throw new Error('Unexpected upload offset');
|
|
56
|
+
offset = result.offset;
|
|
57
|
+
}
|
|
58
|
+
} finally { await file.close(); }
|
|
59
|
+
let result;
|
|
60
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
61
|
+
try { result = await call({ action: 'upload_finish', uploadId }); break; }
|
|
62
|
+
catch (error) { if (attempt === 2) throw error; }
|
|
63
|
+
}
|
|
64
|
+
console.log(JSON.stringify(result, null, 2));
|
|
65
|
+
await call({ action: 'upload_abort', uploadId }); // receipt no longer needed
|
|
66
|
+
uploadId = undefined;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (uploadId) {
|
|
69
|
+
try { await client.callTool({ name: 'project', arguments: { action: 'upload_abort', projectKey, uploadId } }); } catch { }
|
|
70
|
+
}
|
|
71
|
+
// Do not print transport objects or request bodies, which contain the project capability.
|
|
72
|
+
console.error(`Project upload failed: ${String(error.message).split(projectKey).join('[redacted]')}`);
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
} finally { await client.close(); }
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/** Live MCP compatibility checks. Uses public downloads and an isolated database/cache. */
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { appendFile, copyFile, cp, mkdir, mkdtemp, writeFile } 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
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
8
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
9
|
+
|
|
10
|
+
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
const arguments_ = process.argv.slice(2);
|
|
12
|
+
const runMods = !arguments_.length || arguments_.includes("--mods");
|
|
13
|
+
const selectedVersions = arguments_.filter(arg => arg !== "--mods");
|
|
14
|
+
const versions = selectedVersions.length ? selectedVersions : runMods && arguments_.length ? [] : [
|
|
15
|
+
"b1.7.3", "1.2.5", "1.5.2", "1.6.4", "1.7.10", "1.8.9", "1.12.2",
|
|
16
|
+
"1.13.2", "1.16.5", "1.18.2", "1.20.1", "1.20.6", "1.21.1", "26.1.2",
|
|
17
|
+
];
|
|
18
|
+
const root = await mkdtemp(join(tmpdir(), "modlens-minecraft-eras-"));
|
|
19
|
+
const cache = join(root, "cache");
|
|
20
|
+
await mkdir(cache, { recursive: true });
|
|
21
|
+
if (process.env.MODLENS_TEST_ARTIFACT_CACHE) {
|
|
22
|
+
for (const directory of ["mc-jars", "mappings", "tools", "jdk"]) {
|
|
23
|
+
await cp(join(process.env.MODLENS_TEST_ARTIFACT_CACHE, directory), join(cache, directory), { recursive: true }).catch(error => {
|
|
24
|
+
if (error.code !== "ENOENT") throw error;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
await copyFile(join(repository, "prisma/backends/template.db"), join(root, "database.db"));
|
|
29
|
+
|
|
30
|
+
// An explicit read-only seed can avoid re-downloading the analysis tools.
|
|
31
|
+
if (process.env.MODLENS_TEST_TOOL_CACHE) {
|
|
32
|
+
await mkdir(join(cache, "tools"), { recursive: true });
|
|
33
|
+
for (const name of ["mcsrc-indexer.jar", "vineflower.jar", "tiny-remapper.jar", "SpecialSource.jar"]) {
|
|
34
|
+
await copyFile(join(process.env.MODLENS_TEST_TOOL_CACHE, name), join(cache, "tools", name)).catch(error => {
|
|
35
|
+
if (error.code !== "ENOENT") throw error;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const environment = Object.fromEntries(Object.entries(process.env).filter(([key, value]) =>
|
|
40
|
+
typeof value === "string" && !/TOKEN|API_KEY|SECRET|^(MCP_|DATABASE_URL$|MODLENS_|OLLAMA_|AUTO_)/i.test(key),
|
|
41
|
+
));
|
|
42
|
+
Object.assign(environment, {
|
|
43
|
+
DATABASE_URL: `file:${join(root, "database.db").replaceAll("\\", "/")}`,
|
|
44
|
+
MODLENS_HOME: join(root, "home"), MODLENS_CACHE_ROOT: cache,
|
|
45
|
+
MODLENS_AUTO_EMBED: "0", MODLENS_AUTO_GRAPH: "0", OLLAMA_URL: "http://127.0.0.1:1",
|
|
46
|
+
});
|
|
47
|
+
const client = new Client({ name: "minecraft-era-validation", version: "1.0" });
|
|
48
|
+
const transport = new StdioClientTransport({
|
|
49
|
+
command: process.execPath, args: [join(repository, "dist/server.js")],
|
|
50
|
+
cwd: root, env: environment, stderr: "pipe",
|
|
51
|
+
});
|
|
52
|
+
const errors = [];
|
|
53
|
+
const results = [];
|
|
54
|
+
let serverLog = "";
|
|
55
|
+
transport.stderr?.on("data", data => { serverLog += data; });
|
|
56
|
+
console.log(`Evidence: ${root}`);
|
|
57
|
+
|
|
58
|
+
async function call(name, args, validate, expectError = false) {
|
|
59
|
+
const start = Date.now();
|
|
60
|
+
const result = { name, args };
|
|
61
|
+
try {
|
|
62
|
+
const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 300_000 });
|
|
63
|
+
const text = response.content.filter(item => item.type === "text").map(item => item.text).join("\n");
|
|
64
|
+
let data;
|
|
65
|
+
try { data = JSON.parse(text); } catch { data = text; }
|
|
66
|
+
if (args.action === "search_class" && typeof data === "string") data = data.split("\n").filter(Boolean);
|
|
67
|
+
assert.equal(response.isError === true, expectError, text);
|
|
68
|
+
validate?.(data);
|
|
69
|
+
result.passed = true;
|
|
70
|
+
return data;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
result.passed = false;
|
|
73
|
+
result.error = error.message;
|
|
74
|
+
throw error;
|
|
75
|
+
} finally {
|
|
76
|
+
result.elapsedMs = Date.now() - start;
|
|
77
|
+
results.push(result);
|
|
78
|
+
await appendFile(join(root, "results.jsonl"), JSON.stringify(result) + "\n");
|
|
79
|
+
console.log(`${result.passed ? "PASS" : "FAIL"} ${args.version ?? ""} ${name}.${args.action} (${result.elapsedMs}ms)${result.error ? `: ${result.error.slice(0, 220)}` : ""}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await client.connect(transport);
|
|
85
|
+
await call("mc_versions", { action: "list_mc", type: "all" }, rows => {
|
|
86
|
+
for (const version of versions) assert.ok(rows.some(row => row.id === version), `Version list omits ${version}`);
|
|
87
|
+
}).catch(error => errors.push({ version: "catalog", error: error.message }));
|
|
88
|
+
for (const version of versions) {
|
|
89
|
+
try {
|
|
90
|
+
const classes = await call("mc_source", { action: "search_class", version, query: "Minecraft" }, rows => {
|
|
91
|
+
assert.ok(rows.some(name => /(?:^|\/)Minecraft$/.test(name)), "No named Minecraft class found");
|
|
92
|
+
});
|
|
93
|
+
const className = classes.find(name => /(?:^|\/)Minecraft$/.test(name));
|
|
94
|
+
await call("mc_source", { action: "get_source", version, className, maxLines: 20 }, text => {
|
|
95
|
+
assert.equal(typeof text, "string");
|
|
96
|
+
assert.match(text, /(?:package|import) net\.minecraft\./);
|
|
97
|
+
});
|
|
98
|
+
await call("mc_source", { action: "class_members", version, className }, data => {
|
|
99
|
+
assert.ok(data.methods.length > 5, "Missing class methods");
|
|
100
|
+
assert.ok(data.fields.length > 5, "Missing class fields");
|
|
101
|
+
});
|
|
102
|
+
await call("mc_source", { action: "bytecode", version, className }, text => {
|
|
103
|
+
assert.equal(typeof text, "string");
|
|
104
|
+
assert.match(text, /Minecraft/);
|
|
105
|
+
assert.match(text, /descriptor:/);
|
|
106
|
+
});
|
|
107
|
+
await call("mc_source", { action: "inheritance", version, className }, data => {
|
|
108
|
+
assert.equal(data.className, className);
|
|
109
|
+
assert.ok(data.superClass);
|
|
110
|
+
});
|
|
111
|
+
if (version === "1.5.2") {
|
|
112
|
+
for (const [className, method] of [
|
|
113
|
+
["net/minecraft/server/MinecraftServer", "tick"],
|
|
114
|
+
["net/minecraft/src/IntegratedServer", "tickIntegrated"],
|
|
115
|
+
]) await call("mc_source", { action: "class_members", version, className }, data => {
|
|
116
|
+
assert.ok(data.methods.some(entry => entry.name === method), `Lost explicit mapping for ${className}.${method}`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
errors.push({ version, error: error.message });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (runMods) {
|
|
124
|
+
await call("mod", { action: "get" }, text => assert.match(text, /modId or dbId is required/), true);
|
|
125
|
+
const mods = [
|
|
126
|
+
{ project: 73488, version: "1.6.4", loader: "forge", name: "Waila" },
|
|
127
|
+
{ project: 73488, version: "1.7.10", loader: "forge", name: "Waila" },
|
|
128
|
+
{ project: 238222, version: "1.8.9", loader: "forge", name: "JEI" },
|
|
129
|
+
{ project: 238222, version: "1.12.2", loader: "forge", name: "JEI" },
|
|
130
|
+
{ project: 238222, version: "1.16.5", loader: "forge", name: "JEI" },
|
|
131
|
+
{ project: "AANobbMI", version: "1.16.5", loader: "fabric", name: "Sodium" },
|
|
132
|
+
{ project: 238222, version: "1.20.1", loader: "forge", name: "JEI" },
|
|
133
|
+
{ project: 238222, version: "1.21.1", loader: "neoforge", name: "JEI" },
|
|
134
|
+
];
|
|
135
|
+
for (const mod of mods) {
|
|
136
|
+
try {
|
|
137
|
+
const infoArgs = { modId: mod.project, mcVersionFilter: mod.version, loader: mod.loader };
|
|
138
|
+
const before = await call("mod", { action: "stats" });
|
|
139
|
+
const info = await call("modpacks_ch", { action: "mod_info", ...infoArgs }, data => {
|
|
140
|
+
assert.ok(data.versions.length, `No ${mod.name} versions for ${mod.version}/${mod.loader}`);
|
|
141
|
+
assert.equal(data.versionLimit, 20);
|
|
142
|
+
assert.ok(data.versions.length <= data.versionLimit);
|
|
143
|
+
assert.ok(data.versions.every(version => version.loaders.includes(mod.loader)), "Accepted an unverified loader");
|
|
144
|
+
});
|
|
145
|
+
const compatibilityAlias = await call("modpacks_ch", { action: "ftb_mod_info", ...infoArgs });
|
|
146
|
+
assert.deepEqual(compatibilityAlias, info);
|
|
147
|
+
if (mod.name === "Waila" && mod.version === "1.7.10") {
|
|
148
|
+
await call("platform", { action: "search", query: "Waila", mcVersion: mod.version, loader: "forge", limit: 5 }, data => {
|
|
149
|
+
assert.ok(data.results.some(hit => String(hit.projectId) === String(mod.project)), "Loader-filtered search omitted Waila");
|
|
150
|
+
assert.ok(data.results.every(hit => hit.loaders.includes("forge")), "Search returned an unverified loader");
|
|
151
|
+
});
|
|
152
|
+
await call("modpacks_ch", { action: "mod_info", ...infoArgs, loader: "fabric", limit: 1 }, data => {
|
|
153
|
+
assert.deepEqual(data.versions, [], "Accepted a Forge-only release for Fabric");
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
await call("mod", { action: "stats" }, data => assert.equal(data.total, before.total, "Metadata inspection ingested candidate files"));
|
|
157
|
+
const downloaded = await call("modpacks_ch", { action: "download_mod", ...infoArgs }, data => {
|
|
158
|
+
assert.ok(data.modId > 0, "Download did not produce an ingested mod");
|
|
159
|
+
});
|
|
160
|
+
const dbId = downloaded.modId;
|
|
161
|
+
await call("mod", { action: "get", dbId }, data => {
|
|
162
|
+
assert.equal(data.id, dbId, "Returned the wrong database record");
|
|
163
|
+
assert.equal(data.loader, mod.loader);
|
|
164
|
+
assert.ok(data.modId && data.modId !== "unknown");
|
|
165
|
+
assert.ok(data.version && !data.version.includes("${"), "Unresolved version placeholder");
|
|
166
|
+
});
|
|
167
|
+
const files = await call("mod_jar", { action: "list_files", modId: dbId });
|
|
168
|
+
const englishPath = files.entries.find(path => /(?:^|\/)lang\/en_us\.(json|lang)$/i.test(path));
|
|
169
|
+
const languageFile = englishPath ? await call("mod_jar", { action: "get_file", modId: dbId, path: englishPath }) : undefined;
|
|
170
|
+
const emptyLanguage = languageFile && (languageFile.data
|
|
171
|
+
? Object.keys(languageFile.data).length === 0 : !languageFile.raw?.trim());
|
|
172
|
+
await call("mod_jar", { action: "lang", modId: dbId }, data => {
|
|
173
|
+
if (emptyLanguage) assert.equal(data.total, 0, "Invented translations for an empty language file");
|
|
174
|
+
else if (englishPath) assert.ok(data.total > 0, "Existing language file was not read");
|
|
175
|
+
else assert.equal(data.found, false, "Reported language entries for a JAR without English translations");
|
|
176
|
+
});
|
|
177
|
+
await call("mod", { action: "reindex", dbId });
|
|
178
|
+
const query = mod.name === "JEI" ? "JustEnoughItems" : mod.name;
|
|
179
|
+
const classes = await call("mod_bytecode", { action: "search_class", dbId, query });
|
|
180
|
+
const className = classes.find(name => !name.includes("$"));
|
|
181
|
+
assert.ok(className, "No matching mod class found");
|
|
182
|
+
await call("mod_bytecode", { action: "class_members", dbId, className }, data => {
|
|
183
|
+
assert.equal(data.className, className);
|
|
184
|
+
assert.ok(data.methods.length, "No methods returned for mod class");
|
|
185
|
+
});
|
|
186
|
+
await call("mod_bytecode", { action: "bytecode", dbId, className }, text => assert.match(text, /descriptor:/));
|
|
187
|
+
await call("mod", { action: "decompile_class", dbId, className }, text => {
|
|
188
|
+
assert.match(text, /package /);
|
|
189
|
+
assert.ok(text.includes(className.split("/").at(-1)), "Decompiled the wrong class");
|
|
190
|
+
});
|
|
191
|
+
} catch (error) {
|
|
192
|
+
errors.push({ version: `${mod.name} ${mod.version}/${mod.loader}`, error: error.message });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
await client.close();
|
|
198
|
+
await writeFile(join(root, "server.log"), serverLog);
|
|
199
|
+
await writeFile(join(root, "summary.json"), JSON.stringify({ versions, results, errors }, null, 2));
|
|
200
|
+
}
|
|
201
|
+
console.log(`${results.filter(row => row.passed).length}/${results.length} checks passed; ${errors.length} failing versions/catalogs. Evidence: ${root}`);
|
|
202
|
+
if (errors.length) process.exitCode = 1;
|
package/scripts/test-package.mjs
CHANGED
|
@@ -109,6 +109,13 @@ try {
|
|
|
109
109
|
.filter(dir => existsSync(join(dir, "package.json")));
|
|
110
110
|
assert.equal(installs.length, 1);
|
|
111
111
|
const installed = installs[0];
|
|
112
|
+
const initScript = execFileSync(process.execPath, [join(installed, "dist/launcher.js"), "--gradle-init-script"], { env, encoding: "utf8" }).trim();
|
|
113
|
+
assert.equal(initScript, join(installed, "scripts/gradle/modlens.init.gradle"));
|
|
114
|
+
assert.ok(statSync(initScript).size > 0, "Gradle exporter must ship in the npm package");
|
|
115
|
+
assert.ok(statSync(join(installed, "scripts/project-upload.mjs")).size > 0, "Remote uploader must ship in the npm package");
|
|
116
|
+
const keyFile = join(root, "project-key.txt");
|
|
117
|
+
writeFileSync(keyFile, "a".repeat(64));
|
|
118
|
+
assert.deepEqual(JSON.parse(execFileSync(process.execPath, [join(installed, "dist/launcher.js"), "--project", "list", `--key-file=${keyFile}`], { env, encoding: "utf8" })), []);
|
|
112
119
|
const require = createRequire(join(installed, "package.json"));
|
|
113
120
|
const adapterRequire = createRequire(require.resolve("@prisma/adapter-better-sqlite3"));
|
|
114
121
|
assert.equal(require.resolve("better-sqlite3"), adapterRequire.resolve("better-sqlite3"), "Prisma must share the v12 driver");
|
|
@@ -155,6 +162,7 @@ try {
|
|
|
155
162
|
send({ method: "notifications/initialized" });
|
|
156
163
|
const list = await request("tools/list", {});
|
|
157
164
|
assert.ok(list.tools.some(tool => tool.name === "mod"));
|
|
165
|
+
assert.ok(list.tools.some(tool => tool.name === "project"));
|
|
158
166
|
const stats = await request("tools/call", { name: "mod", arguments: { action: "stats" } });
|
|
159
167
|
assert.ok(!stats.isError, JSON.stringify(stats));
|
|
160
168
|
assert.equal(JSON.parse(stats.content.find(item => item.type === "text").text).total, 0);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Real Gradle -> export -> ModLens source/member/search assertions. No game launch.
|
|
2
|
+
// Set MODLENS_TEST_GRADLE_HOME and JAVA_HOME. Optional argument: moddev (NeoForge 1.21.1).
|
|
3
|
+
// Uses a disposable fixture; retained on failure for diagnosis.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { mkdtemp, mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { ProjectStore } from '../dist/tools/project.js';
|
|
11
|
+
|
|
12
|
+
const moddev = process.argv[2] === 'moddev';
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(), 'modlens-gradle-smoke-'));
|
|
14
|
+
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const gradleHome = process.env.MODLENS_TEST_GRADLE_HOME;
|
|
16
|
+
if (!gradleHome || !process.env.JAVA_HOME) throw new Error('Set MODLENS_TEST_GRADLE_HOME and JAVA_HOME');
|
|
17
|
+
const launcher = (await readdir(join(gradleHome, 'lib'))).find(n => /^gradle-launcher-.*\.jar$/.test(n));
|
|
18
|
+
if (!launcher) throw new Error('Gradle launcher JAR missing');
|
|
19
|
+
const java = join(process.env.JAVA_HOME, 'bin', process.platform === 'win32' ? 'java.exe' : 'java');
|
|
20
|
+
const write = async (path, text) => { await mkdir(dirname(join(root, path)), { recursive: true }); await writeFile(join(root, path), text); };
|
|
21
|
+
const run = (sourceSet = 'main') => execFileSync(java, ['-classpath', join(gradleHome, 'lib', launcher), 'org.gradle.launcher.GradleMain',
|
|
22
|
+
'--no-daemon', '--console=plain', '--no-configuration-cache', '-I', join(repo, 'scripts/gradle/modlens.init.gradle'), `-PmodlensSourceSet=${sourceSet}`, ':modlensExport'],
|
|
23
|
+
{ cwd: root, env: process.env, stdio: 'inherit', timeout: 1200_000 });
|
|
24
|
+
let passed = false;
|
|
25
|
+
try {
|
|
26
|
+
if (moddev) {
|
|
27
|
+
await write('settings.gradle', "pluginManagement { repositories { gradlePluginPortal(); maven { url = 'https://maven.neoforged.net/releases' } } }\nrootProject.name = 'modlens-at-smoke'\n");
|
|
28
|
+
await write('build.gradle', "plugins { id 'java'; id 'net.neoforged.moddev' version '2.0.141' }\njava { toolchain { languageVersion = JavaLanguageVersion.of(21) } }\nneoForge { version = '21.1.209' }\n");
|
|
29
|
+
await write('gradle.properties', 'minecraft_version=1.21.1\norg.gradle.jvmargs=-Xmx3G\n');
|
|
30
|
+
await write('src/main/resources/META-INF/accesstransformer.cfg', '# baseline\n');
|
|
31
|
+
} else {
|
|
32
|
+
await write('settings.gradle', "rootProject.name = 'modlens-classpath-smoke'\ninclude 'library'\n");
|
|
33
|
+
await write('build.gradle', "plugins { id 'java' }\ndependencies { implementation project(':library') }\nsourceSets { client { compileClasspath = sourceSets.main.compileClasspath } }\n");
|
|
34
|
+
await write('library/build.gradle', "plugins { id 'java' }\n");
|
|
35
|
+
await write('library/src/main/java/example/Target.java', 'package example; public class Target { private final int counter = 7; public int read() { return counter; } }\n');
|
|
36
|
+
}
|
|
37
|
+
run();
|
|
38
|
+
const key = (await readFile(join(root, '.gradle/modlens/project-key.txt'), 'utf8')).trim();
|
|
39
|
+
const store = new ProjectStore(join(root, 'server-projects'));
|
|
40
|
+
const first = await store.importBuffer(key, await readFile(join(root, 'build/modlens/main/environment.zip')));
|
|
41
|
+
const target = moddev ? 'net.minecraft.world.level.Level' : 'example.Target';
|
|
42
|
+
const fieldName = moddev ? 'isDebug' : 'counter';
|
|
43
|
+
const field = async id => (await store.members(key, id, target)).result.fields.find(f => f.name === fieldName);
|
|
44
|
+
const before = await field(first.environmentId);
|
|
45
|
+
assert.ok(before, `Missing ${fieldName}`);
|
|
46
|
+
assert.ok(before.access & 0x10, 'baseline field must be final');
|
|
47
|
+
if (moddev) await write('src/main/resources/META-INF/accesstransformer.cfg', 'public-f net.minecraft.world.level.Level isDebug\n');
|
|
48
|
+
else await write('library/src/main/java/example/Target.java', 'package example; public class Target { public int counter = 7; public int read() { return counter; } }\n');
|
|
49
|
+
run();
|
|
50
|
+
assert.equal((await readFile(join(root, '.gradle/modlens/project-key.txt'), 'utf8')).trim(), key, 'export must preserve private key');
|
|
51
|
+
const second = await store.importBuffer(key, await readFile(join(root, 'build/modlens/main/environment.zip')));
|
|
52
|
+
assert.notEqual(first.environmentId, second.environmentId);
|
|
53
|
+
const after = await field(second.environmentId);
|
|
54
|
+
assert.ok(after.access & 1, 'transformed field must be public');
|
|
55
|
+
assert.equal(after.access & 0x10, 0, 'transformed field must not be final');
|
|
56
|
+
assert.ok((await field(first.environmentId)).access & 0x10, 'baseline must stay final');
|
|
57
|
+
const source = await store.source(key, second.environmentId, target, 1, 1000);
|
|
58
|
+
assert.match(source.source, new RegExp(`public (?!final)\\w+ ${fieldName}`));
|
|
59
|
+
if (moddev) assert.equal(source.origin, 'gradle-sources', 'adapter must supply actual transformed sources');
|
|
60
|
+
const search = await store.search(key, second.environmentId, `public ${moddev ? 'boolean' : 'int'} ${fieldName}`, 200);
|
|
61
|
+
assert.ok(search.results.some(r => r.className === target.replaceAll('.', '/')));
|
|
62
|
+
assert.equal((await store.list('f'.repeat(64))).length, 0);
|
|
63
|
+
if (!moddev) {
|
|
64
|
+
run('client'); // sibling :library has no client source set and must not break this export
|
|
65
|
+
const client = await store.importBuffer(key, await readFile(join(root, 'build/modlens/client/environment.zip')));
|
|
66
|
+
assert.equal(client.sourceSet, 'client');
|
|
67
|
+
assert.equal((await field(client.environmentId)).access, 1);
|
|
68
|
+
}
|
|
69
|
+
console.log(JSON.stringify({ passed: true, toolchain: second.toolchain, beforeAccess: before.access, afterAccess: after.access,
|
|
70
|
+
classCount: second.classCount, sourceCount: second.sourceCount, first: first.environmentId, second: second.environmentId }, null, 2));
|
|
71
|
+
// Keep a bundle for the optional HTTP smoke, without copying any project key into the repository.
|
|
72
|
+
if (process.env.MODLENS_KEEP_FIXTURE !== '1') passed = true;
|
|
73
|
+
} finally {
|
|
74
|
+
if (passed) await rm(root, { recursive: true, force: true });
|
|
75
|
+
else console.log(`Fixture retained: ${root}`);
|
|
76
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Real HTTP MCP transport and shipped uploader smoke. Uses a disposable server/cache on loopback.
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
|
+
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { createServer } from 'node:net';
|
|
10
|
+
import { once } from 'node:events';
|
|
11
|
+
import AdmZip from 'adm-zip';
|
|
12
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
13
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
14
|
+
|
|
15
|
+
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
|
+
const root = await mkdtemp(join(tmpdir(), 'modlens-http-smoke-'));
|
|
17
|
+
const listener = createServer();
|
|
18
|
+
listener.listen(0, '127.0.0.1'); await once(listener, 'listening');
|
|
19
|
+
const port = listener.address().port;
|
|
20
|
+
await new Promise(resolveClose => listener.close(resolveClose));
|
|
21
|
+
const endpoint = `http://127.0.0.1:${port}/mcp`;
|
|
22
|
+
const keyFile = process.argv[3] ?? join(root, 'key.txt');
|
|
23
|
+
const bundlePath = process.argv[2] ?? join(root, 'environment.zip');
|
|
24
|
+
const target = process.argv[4] ?? 'example.Target';
|
|
25
|
+
const key = process.argv[3] ? (await readFile(keyFile, 'utf8')).trim() : randomBytes(32).toString('hex');
|
|
26
|
+
if (!process.argv[2]) {
|
|
27
|
+
await writeFile(keyFile, `${key}\n`);
|
|
28
|
+
const jar = new AdmZip();
|
|
29
|
+
jar.addFile('example/Target.class', Buffer.from('test class entry'));
|
|
30
|
+
jar.addFile('example/Target.java', Buffer.from('package example; public class Target { public int counter; }'));
|
|
31
|
+
// An incompressible payload exercises multiple full upload chunks.
|
|
32
|
+
jar.addFile('payload.bin', randomBytes(2 * 1024 * 1024));
|
|
33
|
+
const bytes = jar.toBuffer();
|
|
34
|
+
const bundle = new AdmZip();
|
|
35
|
+
bundle.addFile('artifacts/0.jar', bytes);
|
|
36
|
+
bundle.addFile('manifest.json', Buffer.from(JSON.stringify({ format: 'modlens-project-v1', project: 'http-smoke:', sourceSet: 'main',
|
|
37
|
+
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' }] })));
|
|
39
|
+
await writeFile(bundlePath, bundle.toBuffer());
|
|
40
|
+
}
|
|
41
|
+
const env = { ...process.env, MCP_PORT: String(port), MCP_HOST: '127.0.0.1', MODLENS_AUTO_EMBED: '0',
|
|
42
|
+
MODLENS_CACHE_ROOT: join(root, 'cache'), MODLENS_HOME: root, DATABASE_URL: `file:${join(root, 'unused.db')}`, MODLENS_VERBOSE: '1' };
|
|
43
|
+
const server = spawn(process.execPath, [join(repo, 'dist/server.js')], { cwd: root, env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
44
|
+
let logs = '';
|
|
45
|
+
server.stderr.on('data', b => { logs += b; });
|
|
46
|
+
const stopped = once(server, 'exit');
|
|
47
|
+
const client = new Client({ name: 'project-http-test', version: '1.0.0' });
|
|
48
|
+
let passed = false;
|
|
49
|
+
try {
|
|
50
|
+
await new Promise((resolveReady, reject) => {
|
|
51
|
+
const timer = setTimeout(() => reject(new Error('HTTP server startup timed out')), 30_000);
|
|
52
|
+
const ready = () => { if (logs.includes('listening on')) { clearTimeout(timer); server.stderr.off('data', ready); resolveReady(); } };
|
|
53
|
+
server.stderr.on('data', ready);
|
|
54
|
+
server.once('error', reject);
|
|
55
|
+
server.once('exit', () => { clearTimeout(timer); reject(new Error(`Server exited before startup: ${logs}`)); });
|
|
56
|
+
ready();
|
|
57
|
+
});
|
|
58
|
+
const uploader = spawn(process.execPath, [join(repo, 'scripts/project-upload.mjs'), endpoint, bundlePath, keyFile], { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
59
|
+
let output = ''; let error = '';
|
|
60
|
+
uploader.stdout.on('data', b => { output += b; }); uploader.stderr.on('data', b => { error += b; });
|
|
61
|
+
const [code] = await once(uploader, 'exit');
|
|
62
|
+
assert.equal(code, 0, error);
|
|
63
|
+
assert.ok(!output.includes(key), 'uploader must not print project key');
|
|
64
|
+
const imported = JSON.parse(output);
|
|
65
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(endpoint)));
|
|
66
|
+
const call = (action, args = {}) => client.callTool({ name: 'project', arguments: { action, projectKey: key, environmentId: imported.environmentId, ...args } }, undefined, { timeout: 300_000 });
|
|
67
|
+
const tools = await client.listTools();
|
|
68
|
+
assert.ok(tools.tools.some(t => t.name === 'project'));
|
|
69
|
+
const source = await call('source', { className: target });
|
|
70
|
+
assert.ok(!source.isError, JSON.stringify(source));
|
|
71
|
+
assert.ok(JSON.parse(source.content[0].text).source.length > 0);
|
|
72
|
+
if (process.argv[5]) {
|
|
73
|
+
const members = await call('members', { className: target });
|
|
74
|
+
assert.ok(!members.isError, JSON.stringify(members));
|
|
75
|
+
const field = JSON.parse(members.content[0].text).result.fields.find(f => f.name === process.argv[5]);
|
|
76
|
+
assert.equal(field?.access, 1, 'remote member view must preserve the public, non-final AT result');
|
|
77
|
+
const searched = await call('search', { query: `public boolean ${process.argv[5]}`, limit: 200 });
|
|
78
|
+
assert.ok(!searched.isError, JSON.stringify(searched));
|
|
79
|
+
assert.ok(JSON.parse(searched.content[0].text).results.some(r => r.className === target.replaceAll('.', '/')));
|
|
80
|
+
}
|
|
81
|
+
const wrongKey = await call('info', { projectKey: randomBytes(32).toString('hex') });
|
|
82
|
+
assert.ok(wrongKey.isError);
|
|
83
|
+
const local = await call('import_local', { bundlePath });
|
|
84
|
+
assert.ok(local.isError, 'HTTP must reject host-local import');
|
|
85
|
+
const failedChunk = await call('upload_chunk', { uploadId: 'f'.repeat(64), offset: 0, data: 'SENSITIVE_PAYLOAD' });
|
|
86
|
+
assert.ok(failedChunk.isError);
|
|
87
|
+
const large = await fetch(endpoint, { method: 'POST', body: 'x'.repeat(9 * 1024 * 1024) });
|
|
88
|
+
assert.equal(large.status, 413);
|
|
89
|
+
assert.ok(!logs.includes(key), 'server logs must redact project key');
|
|
90
|
+
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`);
|
|
92
|
+
passed = true;
|
|
93
|
+
} finally {
|
|
94
|
+
await client.close();
|
|
95
|
+
server.kill(); await stopped;
|
|
96
|
+
if (passed) await rm(root, { recursive: true, force: true });
|
|
97
|
+
else console.log(`HTTP fixture retained: ${root}`);
|
|
98
|
+
}
|