Add MCP server over Streamable HTTP

Seven read-only tools over the catalog database: list_titles, search_scripts,
get_script, get_edition, list_files, find_file and collection_stats. Writing
stays in the CLI, where the effect of a command is visible.

Transport is Streamable HTTP on the JDK's com.sun.net.httpserver, so Gson is
the only new dependency. No sessions are kept: every tool is stateless, so
Mcp-Session-Id is omitted and GET returns 405 rather than opening an SSE
stream we would never write to. Requests carrying a non-loopback Origin are
rejected, since a page in a browser can POST to localhost.

Tool results are formatted text rather than JSON. The consumer is a model
reading the answer, and prose costs less context than the same data wrapped
in objects.

Required arguments are validated against each tool's inputSchema before
dispatch, so a missing parameter is an explicit tool error instead of a
result computed from a default. find_file groups by the canonical lowercase
path, otherwise an extracted directory and its ISO look like two files.

Database access is serialized on one lock because sqlite-jdbc shares a single
connection; the workload is read-only and single-client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Patryk Gensch
2026-08-20 22:25:36 +02:00
co-authored by Claude Opus 5
parent 2a051c561b
commit 393bcde6a8
4 changed files with 794 additions and 0 deletions
+4
View File
@@ -29,6 +29,10 @@ dependencies {
runtimeOnly "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop"
implementation 'org.xerial:sqlite-jdbc:3.46.1.3'
// JSON-RPC serwera MCP. Transport stoi na com.sun.net.httpserver z JDK,
// więc poza parserem nie dokładamy nic.
implementation 'com.google.code.gson:gson:2.11.0'
}
application {
@@ -5,6 +5,7 @@ import pl.genschu.rexcatalog.catalog.Promoter;
import pl.genschu.rexcatalog.db.Database;
import pl.genschu.rexcatalog.ingest.Ingestor;
import pl.genschu.rexcatalog.ingest.MetadataDetector;
import pl.genschu.rexcatalog.mcp.McpServer;
import pl.genschu.rexcatalog.script.ScriptDecoder;
import pl.genschu.rexcatalog.script.ScriptSearch;
@@ -44,6 +45,7 @@ public final class Main {
case "titles" -> titles(dataDir);
case "set" -> set(dataDir, args);
case "lang" -> lang(dataDir, args);
case "mcp" -> mcp(dataDir, args);
case "find" -> find(dataDir, args);
case "cat" -> cat(dataDir, args);
default -> usage();
@@ -294,6 +296,33 @@ public final class Main {
}
}
private static void mcp(Path dataDir, String[] args) throws Exception {
String host = option(args, "--host", "127.0.0.1");
int port = Integer.parseInt(option(args, "--port", "8765"));
try (Database db = Database.open(dataDir)) {
McpServer server = new McpServer(db, dataDir);
server.start(host, port);
System.out.printf("Serwer MCP nasłuchuje na http://%s:%d/mcp%n", host, port);
System.out.println("Transport: Streamable HTTP, bez sesji, tylko do odczytu.");
System.out.println("Zatrzymanie: Ctrl+C");
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
// HttpServer pracuje na własnych wątkach, więc główny musi zaczekać
Thread.currentThread().join();
}
}
private static String option(String[] args, String name, String fallback) {
for (int i = 1; i < args.length - 1; i++) {
if (args[i].equals(name)) {
return args[i + 1];
}
}
return fallback;
}
private static void find(Path dataDir, String[] args) throws Exception {
if (args.length < 2) {
System.err.println("find wymaga zapytania, np. find SHOWCURSOR");
@@ -453,6 +482,8 @@ public final class Main {
find <zapytanie> przeszukaj skrypty (składnia FTS5)
cat <ścieżka|sha1> wypisz odszyfrowany skrypt
mcp [--host H] [--port P] serwer MCP (Streamable HTTP, domyślnie 127.0.0.1:8765)
Baza: -Dcatalog.data=<katalog> (domyślnie ./data)
""");
}
@@ -0,0 +1,306 @@
package pl.genschu.rexcatalog.mcp;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import pl.genschu.rexcatalog.db.Database;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executors;
/**
* Serwer MCP w transporcie Streamable HTTP, na {@code com.sun.net.httpserver} z JDK.
*
* <p>Sesji nie prowadzimy: wszystkie narzędzia są bezstanowe i tylko do odczytu,
* a specyfikacja pozwala pominąć {@code Mcp-Session-Id}. Z tego samego powodu
* {@code GET} na endpoint zwraca 405 — nie mamy komunikatów inicjowanych przez serwer,
* więc nie ma po co otwierać strumienia SSE.
*/
public final class McpServer {
/** Wersje protokołu, które umiemy obsłużyć; pierwsza jest domyślna. */
private static final List<String> SUPPORTED_PROTOCOLS =
List.of("2025-06-18", "2025-03-26", "2024-11-05");
private static final String SERVER_NAME = "rex-catalog";
private static final String SERVER_VERSION = "0.1.0";
private final Tools tools;
private final Map<String, Tools.Tool> byName = new LinkedHashMap<>();
private final Object lock = new Object();
private HttpServer http;
public McpServer(Database db, Path dataDir) {
this.tools = new Tools(db, dataDir);
for (Tools.Tool tool : tools.tools()) {
byName.put(tool.name(), tool);
}
}
public void start(String host, int port) throws IOException {
http = HttpServer.create(new InetSocketAddress(host, port), 0);
http.createContext("/mcp", this::handle);
// pula wątków, ale dostęp do bazy i tak serializujemy — patrz dispatch()
http.setExecutor(Executors.newFixedThreadPool(4));
http.start();
}
public void stop() {
if (http != null) {
http.stop(0);
}
}
private void handle(HttpExchange exchange) throws IOException {
try {
if (!isOriginAllowed(exchange)) {
// ochrona przed DNS rebinding — wymagana przez specyfikację transportu
respond(exchange, 403, "text/plain", "Niedozwolony nagłówek Origin");
return;
}
switch (exchange.getRequestMethod()) {
case "POST" -> handlePost(exchange);
case "GET" -> respond(exchange, 405, "text/plain",
"Ten serwer nie wysyła komunikatów z własnej inicjatywy");
case "DELETE" -> respond(exchange, 405, "text/plain", "Brak sesji do zamknięcia");
default -> respond(exchange, 405, "text/plain", "Nieobsługiwana metoda");
}
} catch (Exception e) {
respond(exchange, 500, "text/plain", "Błąd serwera: " + e);
} finally {
exchange.close();
}
}
private void handlePost(HttpExchange exchange) throws IOException {
String body;
try (InputStream in = exchange.getRequestBody()) {
body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
JsonElement parsed;
try {
parsed = JsonParser.parseString(body);
} catch (Exception e) {
respond(exchange, 400, "application/json",
error(null, -32700, "Nieparsowalny JSON").toString());
return;
}
// wsadu nie rozdzielamy na osobne odpowiedzi w strumieniu — zwracamy tablicę
if (parsed.isJsonArray()) {
JsonArray responses = new JsonArray();
for (JsonElement element : parsed.getAsJsonArray()) {
JsonObject response = dispatch(element.getAsJsonObject());
if (response != null) {
responses.add(response);
}
}
if (responses.isEmpty()) {
respond(exchange, 202, "text/plain", "");
} else {
respond(exchange, 200, "application/json", responses.toString());
}
return;
}
JsonObject response = dispatch(parsed.getAsJsonObject());
if (response == null) {
// powiadomienie: brak treści odpowiedzi
respond(exchange, 202, "text/plain", "");
} else {
respond(exchange, 200, "application/json", response.toString());
}
}
/** Zwraca odpowiedź albo {@code null}, gdy przyszło powiadomienie. */
private JsonObject dispatch(JsonObject request) {
JsonElement id = request.get("id");
String method = request.has("method") ? request.get("method").getAsString() : "";
boolean notification = id == null || id.isJsonNull();
try {
JsonObject result = switch (method) {
case "initialize" -> initialize(request);
case "tools/list" -> toolsList();
case "tools/call" -> toolsCall(request);
case "ping" -> new JsonObject();
default -> null;
};
if (notification) {
return null;
}
if (result == null) {
return error(id, -32601, "Nieznana metoda: " + method);
}
JsonObject response = new JsonObject();
response.addProperty("jsonrpc", "2.0");
response.add("id", id);
response.add("result", result);
return response;
} catch (Exception e) {
if (notification) {
return null;
}
String message = e.getMessage() == null ? e.toString() : e.getMessage();
return error(id, -32603, message);
}
}
private JsonObject initialize(JsonObject request) {
String requested = null;
if (request.has("params") && request.getAsJsonObject("params").has("protocolVersion")) {
requested = request.getAsJsonObject("params").get("protocolVersion").getAsString();
}
// odbijamy wersję klienta, jeśli ją znamy — inaczej proponujemy własną najnowszą
String agreed = SUPPORTED_PROTOCOLS.contains(requested)
? requested : SUPPORTED_PROTOCOLS.get(0);
JsonObject result = new JsonObject();
result.addProperty("protocolVersion", agreed);
JsonObject capabilities = new JsonObject();
capabilities.add("tools", new JsonObject());
result.add("capabilities", capabilities);
JsonObject info = new JsonObject();
info.addProperty("name", SERVER_NAME);
info.addProperty("version", SERVER_VERSION);
result.add("serverInfo", info);
result.addProperty("instructions",
"Katalog kolekcji gier Aidem Media (seria Reksio, Poznaj Mity): obrazy płyt, "
+ "sumy kontrolne, metadane wydań i odszyfrowane skrypty. "
+ "Zacznij od list_titles, żeby poznać zawartość; "
+ "search_scripts przeszukuje treść skryptów gier.");
return result;
}
private JsonObject toolsList() {
JsonArray array = new JsonArray();
for (Tools.Tool tool : byName.values()) {
JsonObject entry = new JsonObject();
entry.addProperty("name", tool.name());
entry.addProperty("description", tool.description());
entry.add("inputSchema", tool.inputSchema());
array.add(entry);
}
JsonObject result = new JsonObject();
result.add("tools", array);
return result;
}
private JsonObject toolsCall(JsonObject request) {
JsonObject params = request.has("params")
? request.getAsJsonObject("params") : new JsonObject();
String name = params.has("name") ? params.get("name").getAsString() : "";
JsonObject arguments = params.has("arguments") && params.get("arguments").isJsonObject()
? params.getAsJsonObject("arguments") : new JsonObject();
Tools.Tool tool = byName.get(name);
if (tool == null) {
return toolResult("Nie ma narzędzia o nazwie " + name
+ ". Dostępne: " + String.join(", ", byName.keySet()), true);
}
// wymagane argumenty sprawdzamy ze schematu, żeby brak parametru był błędem
// narzędzia, a nie cichym wynikiem policzonym z wartości domyślnej
List<String> missing = new ArrayList<>();
for (JsonElement required : tool.inputSchema().getAsJsonArray("required")) {
String key = required.getAsString();
if (!arguments.has(key) || arguments.get(key).isJsonNull()) {
missing.add(key);
}
}
if (!missing.isEmpty()) {
return toolResult("Brakuje wymaganych argumentów narzędzia " + name + ": "
+ String.join(", ", missing), true);
}
try {
String text;
// sqlite-jdbc dzieli jedno połączenie, więc wywołania serializujemy;
// obciążenie jest czysto odczytowe i pojedynczy klient go nie odczuje
synchronized (lock) {
text = tool.handler().apply(arguments);
}
return toolResult(text, false);
} catch (Exception e) {
String message = e.getMessage() == null ? e.toString() : e.getMessage();
return toolResult("Narzędzie " + name + " zawiodło: " + message, true);
}
}
private static JsonObject toolResult(String text, boolean isError) {
JsonObject content = new JsonObject();
content.addProperty("type", "text");
content.addProperty("text", text);
JsonArray array = new JsonArray();
array.add(content);
JsonObject result = new JsonObject();
result.add("content", array);
result.addProperty("isError", isError);
return result;
}
private static JsonObject error(JsonElement id, int code, String message) {
JsonObject error = new JsonObject();
error.addProperty("code", code);
error.addProperty("message", message);
JsonObject response = new JsonObject();
response.addProperty("jsonrpc", "2.0");
if (id == null) {
response.add("id", com.google.gson.JsonNull.INSTANCE);
} else {
response.add("id", id);
}
response.add("error", error);
return response;
}
/**
* Przeglądarka na dowolnej stronie może wysłać POST na localhost, więc żądanie
* z nagłówkiem Origin spoza pętli zwrotnej odrzucamy. Klienci MCP (procesy,
* nie strony) Origin zwykle nie wysyłają i takie żądania przechodzą.
*/
private static boolean isOriginAllowed(HttpExchange exchange) {
String origin = exchange.getRequestHeaders().getFirst("Origin");
if (origin == null || origin.isBlank()) {
return true;
}
try {
String host = URI.create(origin).getHost();
return host != null && Set.of("localhost", "127.0.0.1", "::1", "[::1]").contains(host);
} catch (Exception e) {
return false;
}
}
private static void respond(HttpExchange exchange, int status, String contentType, String body)
throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
if (!body.isEmpty()) {
exchange.getResponseHeaders().set("Content-Type", contentType + "; charset=utf-8");
}
exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length);
if (bytes.length > 0) {
exchange.getResponseBody().write(bytes);
}
}
}
@@ -0,0 +1,453 @@
package pl.genschu.rexcatalog.mcp;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import pl.genschu.rexcatalog.db.Database;
import pl.genschu.rexcatalog.script.ScriptSearch;
import java.nio.file.Path;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Narzędzia MCP nad bazą katalogu. Wszystkie są tylko do odczytu — serwer ma
* odpowiadać na pytania o kolekcję, a nie ją modyfikować; indeksowanie i edycja
* zostają w CLI, gdzie widać, co się dzieje.
*
* <p>Odpowiedzi są tekstem sformatowanym dla modelu, nie JSON-em: agent ma je
* czytać, a nie parsować, a tekst zużywa mniej kontekstu niż to samo w obiektach.
*/
public final class Tools {
private final Database db;
private final ScriptSearch search;
public Tools(Database db, Path dataDir) {
this.db = db;
this.search = new ScriptSearch(db, dataDir);
}
public interface Handler {
String apply(JsonObject arguments) throws Exception;
}
public record Tool(String name, String description, JsonObject inputSchema, Handler handler) {
}
public List<Tool> tools() {
List<Tool> tools = new ArrayList<>();
tools.add(new Tool("list_titles",
"Lista tytułów w kolekcji wraz z wydaniami: silnik, data builda, "
+ "wersje językowe i liczba posiadanych kopii. Zacznij od tego, "
+ "żeby poznać zawartość kolekcji.",
schema(Map.of(), List.of()),
args -> listTitles()));
tools.add(new Tool("search_scripts",
"Przeszukuje pełnotekstowo odszyfrowane skrypty gier (CNV, DEF, CLASS). "
+ "Składnia FTS5; identyfikatory z podkreśleniem są jednym tokenem. "
+ "Zwraca ścieżkę, gry, w których plik występuje, fragment i SHA-1.",
schema(Map.of(
"query", property("string", "Zapytanie, np. CANVAS_OBSERVER albo TYPE=EPISODE"),
"limit", property("integer", "Maksymalna liczba trafień (domyślnie 20)")
), List.of("query")),
args -> searchScripts(string(args, "query"), integer(args, "limit", 20))));
tools.add(new Tool("get_script",
"Zwraca pełną odszyfrowaną treść skryptu. Wskaż go pełnym SHA-1 "
+ "albo fragmentem ścieżki; przy niejednoznaczności dostaniesz listę kandydatów.",
schema(Map.of("ref", property("string", "SHA-1 bloba albo fragment ścieżki")),
List.of("ref")),
args -> getScript(string(args, "ref"))));
tools.add(new Tool("get_edition",
"Pełne metadane wydania: tytuł, silnik, daty, języki, kopie na dysku "
+ "oraz wszystkie wykryte fakty wraz z dowodem i pewnością.",
schema(Map.of("edition_id", property("integer", "Identyfikator wydania z list_titles")),
List.of("edition_id")),
args -> getEdition(integer(args, "edition_id", -1))));
tools.add(new Tool("list_files",
"Wypisuje pliki należące do wydania. Można zawęzić po formacie "
+ "(CNV, IMG, ANN, WAV...) i po fragmencie ścieżki.",
schema(Map.of(
"edition_id", property("integer", "Identyfikator wydania"),
"format", property("string", "Filtr formatu, np. CNV"),
"path_contains", property("string", "Fragment ścieżki"),
"limit", property("integer", "Maksymalna liczba plików (domyślnie 100)")
), List.of("edition_id")),
args -> listFiles(integer(args, "edition_id", -1), string(args, "format"),
string(args, "path_contains"), integer(args, "limit", 100))));
tools.add(new Tool("find_file",
"Szuka pliku po fragmencie ścieżki w całej kolekcji i pokazuje, w których "
+ "grach występuje oraz czy jest bit w bit ten sam. Przydatne do "
+ "porównywania wydań.",
schema(Map.of(
"path_contains", property("string", "Fragment ścieżki, np. arcade.cnv"),
"limit", property("integer", "Maksymalna liczba plików (domyślnie 50)")
), List.of("path_contains")),
args -> findFile(string(args, "path_contains"), integer(args, "limit", 50))));
tools.add(new Tool("collection_stats",
"Statystyki kolekcji: liczba kopii, plików, unikalnych blobów, "
+ "rozkład formatów i stopień współdzielenia zasobów między grami.",
schema(Map.of(), List.of()),
args -> stats()));
return tools;
}
// ---------- implementacje ----------
private String listTitles() throws Exception {
StringBuilder sb = new StringBuilder();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT e.id AS edition_id, t.name AS title, t.series, e.label,
COALESCE(e.release_date_override, (
SELECT d.value FROM game_root g
JOIN detection d ON d.copy_id = g.copy_id AND d.root_path = g.root_path
WHERE g.edition_id = e.id AND d.field = 'release_date' LIMIT 1)) AS release_date,
COALESCE(e.engine_override, (
SELECT g.engine_detected FROM game_root g
WHERE g.edition_id = e.id LIMIT 1)) AS engine,
(SELECT GROUP_CONCAT(lang || '/' || role, ', ') FROM edition_language
WHERE edition_id = e.id) AS langs,
(SELECT COUNT(*) FROM game_root WHERE edition_id = e.id) AS roots
FROM edition e LEFT JOIN title t ON t.id = e.title_id
ORDER BY t.series IS NULL, t.series, t.name, release_date
""");
ResultSet rs = st.executeQuery()) {
while (rs.next()) {
sb.append("#").append(rs.getInt("edition_id")).append(" ")
.append(rs.getString("title"));
if (rs.getString("label") != null) {
sb.append(" [").append(rs.getString("label")).append("]");
}
sb.append("\n silnik: ").append(nvl(rs.getString("engine")))
.append(" data: ").append(nvl(rs.getString("release_date")))
.append(" kopie: ").append(rs.getInt("roots"))
.append(" języki: ").append(nvl(rs.getString("langs")))
.append("\n");
}
}
if (sb.isEmpty()) {
return "Katalog jest pusty — uruchom ingest, decode, analyze, promote.";
}
return sb + "\nDaty pochodzą z LASTMODIFYTIME w application.def i przybliżają "
+ "datę builda, nie datę wydania.";
}
private String searchScripts(String query, int limit) throws Exception {
ScriptSearch.Result result = search.find(query, Math.min(limit, 100));
if (result.hits().isEmpty()) {
return "Brak trafień dla: " + result.effectiveQuery();
}
StringBuilder sb = new StringBuilder("Trafienia dla ")
.append(result.effectiveQuery())
.append(result.quoted() ? " (potraktowane jako fraza)" : "")
.append(": ").append(result.hits().size()).append("\n\n");
for (ScriptSearch.Hit hit : result.hits()) {
sb.append(String.join("\n", hit.paths())).append("\n")
.append(" gry: ").append(String.join(", ", hit.games())).append("\n")
.append(" ").append(hit.snippet().replaceAll("\\s+", " ").trim()).append("\n")
.append(" sha1: ").append(hit.sha1()).append("\n\n");
}
return sb.toString();
}
private String getScript(String ref) throws Exception {
List<String> candidates = search.resolve(ref);
if (candidates.isEmpty()) {
return "Nic nie pasuje do: " + ref;
}
if (candidates.size() > 1) {
StringBuilder sb = new StringBuilder("Niejednoznaczne — "
+ candidates.size() + " różnych wersji tego pliku:\n");
for (String sha1 : candidates) {
sb.append(" ").append(sha1).append(" ")
.append(String.join(", ", search.gamesFor(sha1))).append("\n");
}
return sb + "\nPowtórz z wybranym SHA-1.";
}
String sha1 = candidates.get(0);
String body = search.body(sha1);
if (body == null) {
return "Ten plik nie jest odszyfrowany (nie jest skryptem albo brak go w cache'u).";
}
return "# " + String.join(", ", search.pathsFor(sha1))
+ "\n# gry: " + String.join(", ", search.gamesFor(sha1))
+ "\n# sha1: " + sha1 + "\n\n" + body;
}
private String getEdition(int editionId) throws Exception {
StringBuilder sb = new StringBuilder();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT e.id, t.name AS title, t.series, t.publisher, e.label, e.fingerprint,
e.dll_sha1, e.distributor, e.notes,
e.engine_override, e.engine_version_override,
e.compiler_override, e.release_date_override
FROM edition e LEFT JOIN title t ON t.id = e.title_id WHERE e.id = ?
""")) {
st.setInt(1, editionId);
try (ResultSet rs = st.executeQuery()) {
if (!rs.next()) {
return "Nie ma wydania o id " + editionId + ".";
}
sb.append("Tytuł: ").append(rs.getString("title")).append("\n");
append(sb, "Seria", rs.getString("series"));
append(sb, "Wydawca", rs.getString("publisher"));
append(sb, "Etykieta wydania", rs.getString("label"));
append(sb, "Dystrybutor", rs.getString("distributor"));
append(sb, "Notatki", rs.getString("notes"));
append(sb, "Hash biblioteki silnika", rs.getString("dll_sha1"));
append(sb, "Override silnika", rs.getString("engine_override"));
append(sb, "Override wersji silnika", rs.getString("engine_version_override"));
append(sb, "Override kompilera", rs.getString("compiler_override"));
append(sb, "Override daty", rs.getString("release_date_override"));
}
}
sb.append("\nWersje językowe:\n");
boolean any = false;
try (PreparedStatement st = db.connection().prepareStatement(
"SELECT lang, role FROM edition_language WHERE edition_id = ? ORDER BY lang, role")) {
st.setInt(1, editionId);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
any = true;
sb.append(" ").append(rs.getString("lang"))
.append(" (").append(rs.getString("role")).append(")\n");
}
}
}
if (!any) {
sb.append(" (nie uzupełniono)\n");
}
sb.append("\nKopie na dysku:\n");
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT c.display_name, c.path, c.container, c.fs_type, c.status, c.file_count,
c.size, c.sha256, c.source_kind, c.source_url, g.root_path
FROM game_root g JOIN copy c ON c.id = g.copy_id
WHERE g.edition_id = ? ORDER BY c.display_name
""")) {
st.setInt(1, editionId);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
sb.append(" ").append(rs.getString("display_name"));
String root = rs.getString("root_path");
if (root != null && !root.isEmpty()) {
sb.append(" (korzeń ").append(root).append("/)");
}
sb.append("\n ").append(rs.getString("path"))
.append("\n ").append(rs.getString("container"))
.append(" / ").append(nvl(rs.getString("fs_type")))
.append(", ").append(rs.getInt("file_count")).append(" plików, ")
.append(rs.getLong("size") / 1024 / 1024).append(" MB")
.append(", status ").append(rs.getString("status"))
.append("\n źródło: ").append(nvl(rs.getString("source_kind")));
if (rs.getString("source_url") != null) {
sb.append("").append(rs.getString("source_url"));
}
sb.append("\n sha256: ").append(nvl(rs.getString("sha256"))).append("\n");
}
}
}
sb.append("\nWykryte fakty (z dowodem):\n");
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT DISTINCT d.field, d.value, d.confidence, d.evidence, d.detector
FROM game_root g
JOIN detection d ON d.copy_id = g.copy_id AND d.root_path = g.root_path
WHERE g.edition_id = ? ORDER BY d.detector, d.field
""")) {
st.setInt(1, editionId);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
sb.append(String.format(" %-16s %-30s pewność %.2f%n",
rs.getString("field"), truncate(rs.getString("value"), 30),
rs.getDouble("confidence")));
sb.append(" dowód: ").append(truncate(rs.getString("evidence"), 100))
.append("\n");
}
}
}
return sb.toString();
}
private String listFiles(int editionId, String format, String pathContains, int limit)
throws Exception {
StringBuilder sql = new StringBuilder("""
SELECT DISTINCT COALESCE(f.path_raw, f.path) AS path, b.format, b.size,
b.encrypted, f.blob_sha1
FROM game_root g
JOIN file f ON f.copy_id = g.copy_id
JOIN blob b ON b.sha1 = f.blob_sha1
WHERE g.edition_id = ?
""");
List<Object> params = new ArrayList<>();
params.add(editionId);
if (format != null && !format.isBlank()) {
sql.append(" AND b.format = ?\n");
params.add(format.toUpperCase(java.util.Locale.ROOT));
}
if (pathContains != null && !pathContains.isBlank()) {
sql.append(" AND f.path LIKE ?\n");
params.add("%" + pathContains.toLowerCase(java.util.Locale.ROOT) + "%");
}
sql.append("ORDER BY path LIMIT ?");
params.add(Math.min(limit, 1000));
StringBuilder sb = new StringBuilder();
int count = 0;
try (PreparedStatement st = db.connection().prepareStatement(sql.toString())) {
for (int i = 0; i < params.size(); i++) {
st.setObject(i + 1, params.get(i));
}
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
count++;
sb.append(String.format("%-60s %-8s %8d B%s %s%n",
truncate(rs.getString("path"), 60), nvl(rs.getString("format")),
rs.getLong("size"), rs.getInt("encrypted") == 1 ? " [szyfr]" : "",
rs.getString("blob_sha1")));
}
}
}
if (count == 0) {
return "Brak plików spełniających kryteria.";
}
return "Plików: " + count + (count >= Math.min(limit, 1000)
? " (obcięte do limitu — zawęź filtr)" : "") + "\n\n" + sb;
}
private String findFile(String pathContains, int limit) throws Exception {
Map<String, List<String>> byPath = new LinkedHashMap<>();
Map<String, List<String>> hashes = new LinkedHashMap<>();
try (PreparedStatement st = db.connection().prepareStatement("""
SELECT f.path AS path, f.blob_sha1, c.display_name
FROM file f JOIN copy c ON c.id = f.copy_id
WHERE f.path LIKE ? ORDER BY f.path, c.display_name LIMIT ?
""")) {
st.setString(1, "%" + pathContains.toLowerCase(java.util.Locale.ROOT) + "%");
st.setInt(2, Math.min(limit, 500) * 8);
try (ResultSet rs = st.executeQuery()) {
while (rs.next()) {
String key = rs.getString("path");
byPath.computeIfAbsent(key, k -> new ArrayList<>())
.add(rs.getString("display_name") + " ["
+ rs.getString("blob_sha1").substring(0, 8) + "]");
hashes.computeIfAbsent(key, k -> new ArrayList<>())
.add(rs.getString("blob_sha1"));
}
}
}
if (byPath.isEmpty()) {
return "Nic nie pasuje do: " + pathContains;
}
StringBuilder sb = new StringBuilder();
int shown = 0;
for (Map.Entry<String, List<String>> entry : byPath.entrySet()) {
if (shown++ >= limit) {
sb.append("\n(obcięte — zawęź fragment ścieżki)\n");
break;
}
long distinct = hashes.get(entry.getKey()).stream().distinct().count();
sb.append(entry.getKey()).append("\n ")
.append(String.join("\n ", entry.getValue())).append("\n")
.append(distinct == 1
? " → identyczna treść we wszystkich\n"
: "" + distinct + " różne wersje treści\n")
.append("\n");
}
return sb.toString();
}
private String stats() throws Exception {
StringBuilder sb = new StringBuilder();
sb.append("kopie: ").append(scalar("SELECT COUNT(*) FROM copy")).append("\n")
.append("tytuły: ").append(scalar("SELECT COUNT(*) FROM title")).append("\n")
.append("wydania: ").append(scalar("SELECT COUNT(*) FROM edition")).append("\n")
.append("pliki (wystąpienia): ").append(scalar("SELECT COUNT(*) FROM file")).append("\n")
.append("bloby (unikalne): ").append(scalar("SELECT COUNT(*) FROM blob")).append("\n")
.append("bloby w >1 kopii: ").append(scalar(
"SELECT COUNT(*) FROM (SELECT blob_sha1 FROM file GROUP BY blob_sha1 "
+ "HAVING COUNT(DISTINCT copy_id) > 1)")).append("\n")
.append("skrypty w indeksie: ").append(scalar("SELECT COUNT(*) FROM script_fts"))
.append("\n\nFormaty:\n");
try (PreparedStatement st = db.connection().prepareStatement(
"SELECT format, COUNT(*) n, SUM(size) bytes FROM blob GROUP BY format "
+ "ORDER BY n DESC LIMIT 20");
ResultSet rs = st.executeQuery()) {
while (rs.next()) {
sb.append(String.format(" %-10s %6d %8.1f MB%n", nvl(rs.getString("format")),
rs.getInt("n"), rs.getLong("bytes") / 1024.0 / 1024.0));
}
}
return sb.toString();
}
// ---------- pomocnicze ----------
private long scalar(String sql) throws Exception {
try (PreparedStatement st = db.connection().prepareStatement(sql);
ResultSet rs = st.executeQuery()) {
return rs.next() ? rs.getLong(1) : 0;
}
}
private static void append(StringBuilder sb, String label, String value) {
if (value != null && !value.isBlank()) {
sb.append(label).append(": ").append(value).append("\n");
}
}
private static JsonObject property(String type, String description) {
JsonObject property = new JsonObject();
property.addProperty("type", type);
property.addProperty("description", description);
return property;
}
private static JsonObject schema(Map<String, JsonObject> properties, List<String> required) {
JsonObject schema = new JsonObject();
schema.addProperty("type", "object");
JsonObject props = new JsonObject();
properties.forEach(props::add);
schema.add("properties", props);
JsonArray requiredArray = new JsonArray();
required.forEach(requiredArray::add);
schema.add("required", requiredArray);
return schema;
}
private static String string(JsonObject args, String name) {
return args != null && args.has(name) && !args.get(name).isJsonNull()
? args.get(name).getAsString() : null;
}
private static int integer(JsonObject args, String name, int fallback) {
return args != null && args.has(name) && !args.get(name).isJsonNull()
? args.get(name).getAsInt() : fallback;
}
private static String nvl(String value) {
return value == null ? "" : value;
}
private static String truncate(String value, int max) {
if (value == null) {
return "";
}
return value.length() <= max ? value : value.substring(0, max - 1) + "";
}
}