@gripforgeai/mcp 0.1.15 → 0.1.17

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.
@@ -0,0 +1,67 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { mkdir, readdir, realpath } from 'node:fs/promises';
4
+ import { GameImportAgent, confinedFile, saveImportJob } from './game-import-agent.js';
5
+ const exec = promisify(execFile);
6
+ /** Explicit adapters; never execute a binary discovered inside the source game. */
7
+ export async function extractGame(job) {
8
+ const e = job.extraction;
9
+ if (!e || !job.rights || !job.report)
10
+ throw Error('Extraction plan and authorized rights basis required');
11
+ if (job.report.engine.name !== 'unity')
12
+ throw Error('This adapter only handles Unity. Use the existing Unreal source-project exporter for Unreal.');
13
+ await mkdir(e.output, { recursive: true, mode: 0o700 });
14
+ const agent = new GameImportAgent(job);
15
+ await agent.checkpoint();
16
+ if (!e.completed) {
17
+ if (e.tool === 'assetripper') {
18
+ const url = new URL(e.endpoint ?? '');
19
+ if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.username || url.password || url.pathname !== '/')
20
+ throw Error('AssetRipper requires a dedicated local loopback HTTP instance');
21
+ // Version-compatible route discovery prevents invoking imaginary CLI options or a different local service.
22
+ const spec = await fetch(new URL('/openapi/v1.json', url), { signal: AbortSignal.timeout(10000) });
23
+ if (!spec.ok)
24
+ throw Error('AssetRipper OpenAPI unavailable; use a compatible web build or manual export');
25
+ const api = await spec.json();
26
+ if (!api.paths?.['/LoadFolder']?.post || !api.paths?.['/Export/PrimaryContent']?.post)
27
+ throw Error('AssetRipper API lacks required export routes');
28
+ e.version = String(api.info?.version ?? 'unreported');
29
+ await saveImportJob(job);
30
+ const post = async (route, path) => { await agent.checkpoint(); const res = await fetch(new URL(route, url), { method: 'POST', body: new URLSearchParams({ Path: path, CreateSubfolder: 'false' }), redirect: 'manual', signal: AbortSignal.timeout(20 * 60 * 1000) }); if (![200, 302, 303].includes(res.status))
31
+ throw Error(`AssetRipper ${route}: HTTP ${res.status}`); };
32
+ await post('/LoadFolder', e.sourceRoot);
33
+ await post('/Export/PrimaryContent', e.output);
34
+ if (!(await readdir(e.output)).length)
35
+ throw Error('Extractor returned no files; inspect AssetRipper failed-file log');
36
+ }
37
+ else {
38
+ const bin = process.env[e.tool === 'cpp2il' ? 'GRIPFORGE_CPP2IL_BIN' : 'GRIPFORGE_ILSPY_BIN'];
39
+ if (!bin)
40
+ throw Error(`Configure the installed ${e.tool} executable; no automatic installation`);
41
+ const version = await exec(bin, ['--version'], { cwd: e.output, timeout: 10000, maxBuffer: 1024 * 1024 }).catch(() => ({ stdout: 'version not reported' }));
42
+ e.version = version.stdout.trim().slice(0, 200);
43
+ await saveImportJob(job);
44
+ const args = e.tool === 'cpp2il' ? ['--game-path', e.sourceRoot, '--output-to', e.output] : ['--disable-updatecheck', '-p', '-o', e.output, await confinedFile(e.sourceRoot, job.report.assets.find(a => /(?:^|\/)Managed\/Assembly-CSharp\.dll$/i.test(a.path))?.path ?? '')];
45
+ await exec(bin, args, { cwd: e.output, timeout: 20 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 });
46
+ job.report.warnings.push(`${e.tool} metadata/code exported locally to ${e.tool}-output. It is not a mesh extraction and is never uploaded to the Library.`);
47
+ }
48
+ e.completed = true;
49
+ await agent.checkpoint();
50
+ }
51
+ if (e.tool === 'assetripper') {
52
+ const engine = job.report.engine, source = job.report.source;
53
+ job.root = await realpath(e.output);
54
+ job.phase = 'analyze';
55
+ const report = await new GameImportAgent(job).analyze();
56
+ report.source = source;
57
+ report.engine = engine;
58
+ report.toolchain = agent.select_toolchain(engine);
59
+ report.warnings.push('Inventory is from AssetRipper export; native Unity formats still require their own converter.');
60
+ await saveImportJob(job);
61
+ }
62
+ else {
63
+ job.phase = 'analyze';
64
+ job.status = 'awaiting_selection';
65
+ await saveImportJob(job);
66
+ }
67
+ }
@@ -0,0 +1,72 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { mkdir, readFile, writeFile, stat } from 'node:fs/promises';
4
+ import { basename, join, isAbsolute, dirname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createHash } from 'node:crypto';
7
+ import { GameImportAgent, confinedFile, inspectAsset, jobDirectory, saveImportJob } from './game-import-agent.js';
8
+ const exec = promisify(execFile), hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
9
+ export function normalizedPath(job, id, format) { return join(jobDirectory(job.id), 'normalized', id, 'output.' + format); }
10
+ /** Local durable conversions. Completed files survive cancellation/crashes; original sources are never modified. */
11
+ export async function normalizeGame(job) {
12
+ if (!job.report || !job.selected?.length || !job.rights)
13
+ throw Error('Select sources and provide a rights basis');
14
+ const bin = process.env.GRIPFORGE_BLENDER_BIN;
15
+ if (!bin || !isAbsolute(bin))
16
+ throw Error('Configure GRIPFORGE_BLENDER_BIN with an installed Blender executable');
17
+ const agent = new GameImportAgent(job);
18
+ job.total = job.selected.length;
19
+ job.completed = 0;
20
+ for (const id of job.selected) {
21
+ await agent.checkpoint();
22
+ const asset = job.report.assets.find(a => a.id === id);
23
+ if (!asset || !['fbx', 'obj', 'dds', 'tga'].includes(asset.format))
24
+ throw Error('Blender normalization supports FBX/OBJ/DDS/TGA only');
25
+ const source = await readFile(await confinedFile(job.root, asset.path));
26
+ if (hash(source) !== asset.sha256)
27
+ throw Error('Source changed after analysis; analyze again');
28
+ const format = ['dds', 'tga'].includes(asset.format) ? 'png' : 'glb', output = normalizedPath(job, id, format), dir = dirname(output);
29
+ await mkdir(join(dir, 'source'), { recursive: true, mode: 0o700 });
30
+ try {
31
+ let bytes;
32
+ if (asset.details.normalization) {
33
+ const cached = await readFile(output).catch(() => undefined);
34
+ if (cached && hash(cached) === asset.details.normalization.sha256)
35
+ bytes = cached;
36
+ }
37
+ if (!bytes) {
38
+ const input = join(dir, 'source', basename(asset.path));
39
+ await writeFile(input, source, { mode: 0o600 });
40
+ // Staging excludes source scripts and arbitrary external references. Embedded FBX materials are retained.
41
+ // OBJ material sidecars need explicit portable export: never follow unvalidated MTL references in Blender.
42
+ if (asset.format === 'obj' && /^\s*mtllib\s/m.test(source.toString()))
43
+ throw Error('OBJ references an MTL sidecar; export a self-contained GLB or embedded FBX to preserve PBR');
44
+ const plan = join(dir, 'plan.json'), receipt = join(dir, 'receipt.json');
45
+ await writeFile(plan, JSON.stringify({ input, output, receipt }), { mode: 0o600 });
46
+ await exec(bin, ['--background', '--factory-startup', '--disable-autoexec', '--python-exit-code', '1', '--python', fileURLToPath(new URL('../runtime/game-import-normalize.py', import.meta.url)), '--', plan], { cwd: dir, timeout: 20 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 });
47
+ await agent.checkpoint();
48
+ if ((await stat(output)).size > 256 * 1024 * 1024)
49
+ throw Error('Normalized output exceeds 256 MiB');
50
+ bytes = await readFile(output);
51
+ const inspected = inspectAsset('output.' + format, bytes);
52
+ if (inspected.status !== 'ready')
53
+ throw Error('Converter output is not a portable asset: ' + inspected.reason);
54
+ const version = String(JSON.parse(await readFile(receipt, 'utf8')).version).slice(0, 100);
55
+ asset.details = { ...asset.details, ...inspected.details, normalization: { format, sha256: hash(bytes), sourceSha256: asset.sha256, tool: 'blender', version } };
56
+ asset.evidence.push({ source: asset.path, reason: 'Blender conversion preserving available materials/rig/clips; visual and anatomy review required', confidence: 1 });
57
+ }
58
+ if (format === 'png')
59
+ asset.kind = 'texture';
60
+ asset.status = 'ready';
61
+ delete asset.reason;
62
+ delete job.errors[id];
63
+ job.completed++;
64
+ }
65
+ catch (e) {
66
+ job.errors[id] = e instanceof Error ? e.message : String(e);
67
+ }
68
+ await saveImportJob(job);
69
+ }
70
+ job.status = job.selected.some(id => job.errors[id]) ? 'failed' : 'awaiting_selection';
71
+ await saveImportJob(job);
72
+ }
@@ -0,0 +1,122 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import { z } from 'zod/v4';
4
+ import { readFile, writeFile } from 'node:fs/promises';
5
+ import { basename, join } from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import { confinedFile, jobDirectory, loadImportJob } from './game-import-agent.js';
8
+ /** Detect containers locally before recommending native code investigation. No binaries are executed. */
9
+ export function detectFormat(bytes) {
10
+ if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 69, 76, 70])))
11
+ return { format: 'ELF', native: true };
12
+ if (bytes.toString('ascii', 0, 2) === 'MZ')
13
+ return { format: 'PE', native: true };
14
+ if (bytes.length >= 4 && [0xfeedface, 0xfeedfacf, 0xcafebabe, 0xcefaedfe, 0xcffaedfe].includes(bytes.readUInt32BE()))
15
+ return { format: 'Mach-O', native: true };
16
+ for (const [magic, format] of [['glTF', 'GLB'], ['UnityFS', 'Unity bundle'], ['DDS ', 'DDS'], ['PK\u0003\u0004', 'ZIP']])
17
+ if (bytes.toString('latin1', 0, magic.length) === magic)
18
+ return { format, native: false };
19
+ return { format: 'unknown', native: false };
20
+ }
21
+ function endpoint() {
22
+ const url = new URL(process.env.GRIPFORGE_GHIDRA_MCP_URL ?? '');
23
+ if (url.protocol !== 'http:' || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password)
24
+ throw Error('Configure a dedicated loopback GRIPFORGE_GHIDRA_MCP_URL (for example http://127.0.0.1:8081/mcp)');
25
+ return url;
26
+ }
27
+ async function bridge(run) {
28
+ const client = new Client({ name: 'gripforge-game-import', version: '1.0' });
29
+ try {
30
+ await client.connect(new StreamableHTTPClientTransport(endpoint()));
31
+ return await run(client);
32
+ }
33
+ finally {
34
+ await client.close().catch(() => { });
35
+ }
36
+ }
37
+ async function call(client, name, args) {
38
+ const tool = (await client.listTools()).tools.find(t => t.name === name);
39
+ if (!tool)
40
+ throw Error('Compatible Ghidra tool unavailable: ' + name);
41
+ for (const key of tool.inputSchema.required ?? [])
42
+ if (!(key in args))
43
+ throw Error(`Installed Ghidra requires ${key} for ${name}; incompatible adapter contract`);
44
+ const response = await client.callTool({ name, arguments: args }, undefined, { timeout: 120000 });
45
+ if (response.isError)
46
+ throw Error(JSON.stringify(response.content).slice(0, 4000));
47
+ return response;
48
+ }
49
+ export function registerReverseTools(register) {
50
+ const jobId = z.string().regex(/^gi_[a-f0-9]{24}$/), path = z.string().min(1).max(4096);
51
+ const wrap = (run) => async (a) => { try {
52
+ return { content: [{ type: 'text', text: JSON.stringify(await run(a), null, 2) }] };
53
+ }
54
+ catch (e) {
55
+ return { isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] };
56
+ } };
57
+ const common = { job_id: jobId };
58
+ register('gripforge_reverse_detect_format', { title: 'Inspect file magic before reverse engineering', description: 'Local signatures, source hash and known-format-first recommendation. Unknown remains unknown. Does not execute or alter the source.', inputSchema: { ...common, path }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } }, wrap(async (a) => {
59
+ const job = await loadImportJob(a.job_id), bytes = await readFile(await confinedFile(job.root, a.path)), found = detectFormat(bytes);
60
+ return { ...found, sha256: createHash('sha256').update(bytes).digest('hex'), next: found.native ? 'gripforge_reverse_open' : 'Known extractor/converter first; unknown data needs a format-specific investigation, not automatic native decompilation' };
61
+ }));
62
+ register('gripforge_reverse_open', { title: 'Open an authorized native binary in Ghidra', description: 'Imports a copy into a dedicated installed Ghidra analysis project, without running the binary. Requires a finished local import job, native file signature and rights basis. Records hash and explicit program selector, so later reads never depend on the shared current program. No extraction or visual approval is implied.', inputSchema: { ...common, path, rights: z.object({ basis: z.enum(['owned', 'authorized', 'compatible-license']), note: z.string().min(3).max(1000) }).strict() }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false } }, wrap(async (a) => {
63
+ const job = await loadImportJob(a.job_id), file = await confinedFile(job.root, a.path), bytes = await readFile(file);
64
+ if (!detectFormat(bytes).native)
65
+ throw Error('Use a known extractor or inspect the data format first; this is not a recognized native binary');
66
+ const response = await bridge(c => call(c, 'import_file', { file_path: file, project_folder: '/' + job.id, auto_analyze: true }));
67
+ const content = response.content;
68
+ let program;
69
+ for (const block of content)
70
+ if (block.text) {
71
+ try {
72
+ const json = JSON.parse(block.text);
73
+ program = json.data?.name ?? json.name;
74
+ }
75
+ catch { }
76
+ }
77
+ if (!program)
78
+ throw Error('Ghidra did not report the imported program name; no ambiguous current-program session saved');
79
+ const session = { path: a.path, sha256: createHash('sha256').update(bytes).digest('hex'), program: '/' + job.id + '/' + basename(program), endpoint: endpoint().href };
80
+ await writeFile(join(jobDirectory(job.id), 'reverse-session.json'), JSON.stringify({ ...session, rights: a.rights }), { mode: 0o600 });
81
+ return { program: session.program, sha256: session.sha256, analysis: response, next: 'gripforge_reverse_scan', source_unchanged: true };
82
+ }));
83
+ // A deliberately small allowlist. Upstream rename/patch/execute tools are never passed through.
84
+ for (const [name, tool, description, query, group] of [
85
+ ['gripforge_reverse_scan', 'find_functions', 'List native functions with an explicit program selector', false, 'listing'],
86
+ ['gripforge_reverse_find_asset_system', 'search_strings', 'Search native strings for asset loading/format clues; results are evidence, not inferred assets', true, 'listing'],
87
+ ['gripforge_reverse_trace_function', 'get_functions', 'Read one native function to investigate an asset loader; never changes executable code', true, 'function'],
88
+ ]) {
89
+ register(name, { title: description, description: 'Configured local Ghidra MCP bridge only. Verifies source hash and installed tool schema. Bounded output and persisted evidence; no unsupported converter is invented. ' + description, inputSchema: { ...common, ...(query ? { query: z.string().min(1).max(256) } : {}), offset: z.number().int().min(0).optional(), limit: z.number().int().min(1).max(100).optional() }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } }, wrap(async (a) => {
90
+ const job = await loadImportJob(a.job_id), session = JSON.parse(await readFile(join(jobDirectory(job.id), 'reverse-session.json'), 'utf8'));
91
+ if (session.endpoint !== endpoint().href)
92
+ throw Error('Ghidra endpoint changed; open the binary again');
93
+ const bytes = await readFile(await confinedFile(job.root, session.path));
94
+ if (createHash('sha256').update(bytes).digest('hex') !== session.sha256)
95
+ throw Error('Binary changed; open it again');
96
+ const response = await bridge(async (c) => {
97
+ if (!(await c.listTools()).tools.some(t => t.name === tool))
98
+ await call(c, 'load_tool_group', { group });
99
+ const schema = (await c.listTools()).tools.find(t => t.name === tool)?.inputSchema;
100
+ if (!schema?.properties?.program)
101
+ throw Error('Installed Ghidra tool lacks explicit program selection; update the bridge');
102
+ const args = { program: session.program };
103
+ if (schema.properties.offset)
104
+ args.offset = a.offset ?? 0;
105
+ if (schema.properties.limit)
106
+ args.limit = a.limit ?? 40;
107
+ if (tool === 'get_functions' && schema.properties.fields)
108
+ args.fields = 'decompiled_code,callers,callees,signature';
109
+ if (query) {
110
+ const key = tool === 'get_functions' ? ['function', 'address', 'function_address', 'name', 'function_name'].find(k => schema.properties?.[k]) : ['query', 'search', 'search_term', 'filter'].find(k => schema.properties?.[k]);
111
+ if (!key)
112
+ throw Error('Installed Ghidra query schema incompatible');
113
+ args[key] = a.query;
114
+ }
115
+ return call(c, tool, args);
116
+ });
117
+ const evidence = { source_sha256: session.sha256, program: session.program, operation: tool, query: a.query, observed_at: new Date().toISOString(), result: JSON.stringify(response).slice(0, 64000) };
118
+ await writeFile(join(jobDirectory(job.id), 'reverse-' + tool + '.json'), JSON.stringify(evidence), { mode: 0o600 });
119
+ return evidence;
120
+ }));
121
+ }
122
+ }
@@ -0,0 +1,138 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { open, writeFile, unlink, readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { z } from 'zod/v4';
6
+ import { jobDirectory, loadImportJob, newImportJob, saveImportJob } from './game-import-agent.js';
7
+ async function launch(job, key) {
8
+ const log = await open(join(jobDirectory(job.id), 'worker.log'), 'a', 0o600);
9
+ try {
10
+ const child = spawn(process.execPath, [fileURLToPath(new URL('./game-import-worker.js', import.meta.url)), job.id], { detached: true, stdio: ['ignore', log.fd, log.fd], env: { ...process.env, ...(key ? { GRIPFORGE_API_KEY: key } : {}) } });
11
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
12
+ child.unref();
13
+ }
14
+ finally {
15
+ await log.close();
16
+ }
17
+ }
18
+ async function assertIdle(id) {
19
+ try {
20
+ const pid = Number(await readFile(join(jobDirectory(id), 'worker.lock'), 'utf8'));
21
+ try {
22
+ process.kill(pid, 0);
23
+ }
24
+ catch (e) {
25
+ if (e.code === 'ESRCH')
26
+ return;
27
+ throw e;
28
+ }
29
+ throw Error('Worker still running; cancel and wait for it to finish');
30
+ }
31
+ catch (e) {
32
+ if (e.code !== 'ENOENT')
33
+ throw e;
34
+ }
35
+ }
36
+ export function registerGameImportTools(register, options) {
37
+ const result = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], structuredContent: data });
38
+ const wrap = (run) => async (a) => { try {
39
+ return result(await run(a));
40
+ }
41
+ catch (e) {
42
+ return { isError: true, content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }] };
43
+ } };
44
+ const id = z.string().regex(/^gi_[a-f0-9]{24}$/);
45
+ const summary = (job) => ({ job_id: job.id, status: job.status, phase: job.phase, progress: { completed: job.completed, total: job.total }, engine: job.report?.engine, counts: job.report?.counts, errors: job.errors, imported: job.receipts, report_path: join(jobDirectory(job.id), 'report.json'), library_url: `${options.apiUrl}/library`, next: job.status === 'awaiting_selection' ? 'Read inventory, select asset ids, then gripforge_game_import_select' : 'gripforge_game_import_read' });
46
+ register('gripforge_game_import', {
47
+ title: 'Analyze an authorized local game or project',
48
+ description: 'LOCAL MCP ONLY. Analyze → discover → classify → normalize → private Library import. Starts a detached durable local job; returns immediately. Known portable formats first, Unity GUID relations carry evidence, unsupported formats stay blocked with an explicit toolchain. Does not launch the game, bypass encryption, guess sockets or claim behavior/visual validation. Read the inventory and select before uploading. Closing the MCP or Studio does not delete progress; read/resume by job_id. Analysis is free and local. Source files remain unchanged.',
49
+ inputSchema: { path: z.string().min(1).max(4096) }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
50
+ }, wrap(async (a) => { const job = await newImportJob(a.path); await launch(job); return summary(job); }));
51
+ register('gripforge_game_import_read', {
52
+ title: 'Read game import progress and inventory', description: 'Read persistent local state with paginated assets, role confidence, extraction blockers and verified relationship edges. The report_path can be opened in Library → Add asset → From Game. Unknown/bundled files are not counted as successfully imported assets.',
53
+ inputSchema: { job_id: id, kind: z.string().max(40).optional(), offset: z.number().int().min(0).optional(), limit: z.number().int().min(1).max(100).optional() }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
54
+ }, wrap(async (a) => { const job = await loadImportJob(a.job_id), all = (job.report?.assets ?? []).filter(x => !a.kind || x.kind === a.kind); return { ...summary(job), total: all.length, assets: all.slice(a.offset ?? 0, (a.offset ?? 0) + (a.limit ?? 40)), toolchain: job.report?.toolchain, normalization: job.report?.normalization, warnings: job.report?.warnings, relationships: job.report?.relationships }; }));
55
+ register('gripforge_game_import_extract', {
56
+ title: 'Run the selected known Unity extractor', description: 'LOCAL ONLY. Dedicated AssetRipper loopback HTTP adapter for assets; installed Cpp2IL/ILSpy adapters for local code/metadata only. The agent chooses AssetRipper first unless tool is explicit. Uses durable output/checkpoints and rescans exported assets; never executes game code. Requires installed tools configured in the MCP environment, compatible OpenAPI routes and an authorization basis. No Ghidra fallback is silently invoked. Tools/version and errors remain visible. Cancellation is checked between external operations, each bounded to 20 minutes.',
57
+ inputSchema: { job_id: id, tool: z.enum(['assetripper', 'cpp2il', 'ilspy']).optional(), rights: z.object({ basis: z.enum(['owned', 'authorized', 'compatible-license']), note: z.string().min(3).max(1000) }).strict() }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
58
+ }, wrap(async (a) => { await assertIdle(a.job_id); const job = await loadImportJob(a.job_id); if (!job.report || job.report.engine.name !== 'unity')
59
+ throw Error('Finish Unity analysis first'); if (Object.keys(job.receipts).length)
60
+ throw Error('Start a new import before extracting a different inventory'); const tool = a.tool ?? 'assetripper'; if (tool === 'cpp2il' && job.report.engine.runtime !== 'IL2CPP')
61
+ throw Error('Cpp2IL requires IL2CPP evidence'); if (tool === 'ilspy' && job.report.engine.runtime !== 'Mono')
62
+ throw Error('ILSpy requires Mono evidence'); if (job.extraction && job.extraction.tool !== tool)
63
+ throw Error('Start a separate analysis job for another extractor'); job.extraction ??= { tool, sourceRoot: job.root, output: join(jobDirectory(job.id), tool + '-output'), ...(tool === 'assetripper' ? { endpoint: process.env.GRIPFORGE_ASSETRIPPER_URL } : {}) }; job.rights = a.rights; job.phase = 'extract'; job.status = 'queued'; await unlink(join(jobDirectory(job.id), 'cancel')).catch(() => { }); await saveImportJob(job); await launch(job); return summary(job); }));
64
+ register('gripforge_game_import_select', {
65
+ title: 'Import selected game assets into a private workspace', description: 'Requires an analyzed local job, explicit source ids, workspace id and rights basis. Self-contained GLB and standard images/audio import directly; glTF packs its local resources into GLB. Engine-native formats require extraction first. Durable per-file checkpoints and server idempotency preserve completed imports across retries. Saved as work revisions, never published/promoted. Storage quotas apply; no paid AI call. Relations remain a knowledge graph and Game Kit suggestions require actual gameplay integration.',
66
+ inputSchema: { job_id: id, asset_ids: z.array(z.string().regex(/^src_[a-f0-9]{24}$/)).min(1).max(500), workspace_id: z.string().regex(/^ws_[A-Za-z0-9_-]+$/), rights: z.object({ basis: z.enum(['owned', 'authorized', 'compatible-license']), note: z.string().min(3).max(1000) }).strict() }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
67
+ }, wrap(async (a) => {
68
+ const key = options.getApiKey();
69
+ if (!key)
70
+ throw Error('GripForge API key required');
71
+ await assertIdle(a.job_id);
72
+ const job = await loadImportJob(a.job_id);
73
+ if (!job.report || job.completed < job.total && job.phase === 'analyze')
74
+ throw Error('Finish analysis before selecting assets');
75
+ if (job.workspace && job.workspace !== a.workspace_id)
76
+ throw Error('Start a new import to target another workspace');
77
+ const selected = [...new Set(a.asset_ids)];
78
+ for (const id of selected) {
79
+ const asset = job.report.assets.find(x => x.id === id);
80
+ if (!asset || asset.status === 'blocked')
81
+ throw Error(`Asset unavailable for import: ${id}`);
82
+ }
83
+ job.phase = 'import';
84
+ job.selected = selected;
85
+ job.rights = a.rights;
86
+ job.workspace = a.workspace_id;
87
+ job.origin = options.apiUrl.replace(/\/$/, '');
88
+ job.status = 'queued';
89
+ delete job.errors.worker;
90
+ await unlink(join(jobDirectory(job.id), 'cancel')).catch(() => { });
91
+ await saveImportJob(job);
92
+ await launch(job, key);
93
+ return summary(job);
94
+ }));
95
+ register('gripforge_game_import_normalize', {
96
+ title: 'Convert selected extracted assets with Blender',
97
+ description: 'LOCAL durable worker. Installed GRIPFORGE_BLENDER_BIN converts FBX/OBJ to GLB and DDS/TGA to PNG. Keeps available PBR, rig and animations; does not fabricate missing materials or validate appearance. Sources remain unchanged; checksummed outputs persist for resume. OBJ with external MTL and missing FBX textures fail explicitly; export embedded materials first. Run before registration/import. Source engine scenes still require their known exporter.',
98
+ inputSchema: { job_id: id, asset_ids: z.array(z.string().regex(/^src_[a-f0-9]{24}$/)).min(1).max(500), rights: z.object({ basis: z.enum(['owned', 'authorized', 'compatible-license']), note: z.string().min(3).max(1000) }).strict() },
99
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
100
+ }, wrap(async (a) => {
101
+ await assertIdle(a.job_id);
102
+ const job = await loadImportJob(a.job_id);
103
+ if (!job.report || Object.keys(job.receipts).length || job.workspace)
104
+ throw Error('Normalize an analyzed inventory before registering/importing it');
105
+ for (const assetId of a.asset_ids)
106
+ if (!job.report.assets.some(x => x.id === assetId && ['fbx', 'obj', 'dds', 'tga'].includes(x.format)))
107
+ throw Error('Select FBX/OBJ/DDS/TGA inventory sources');
108
+ job.phase = 'normalize';
109
+ job.selected = [...new Set(a.asset_ids)];
110
+ job.rights = a.rights;
111
+ job.status = 'queued';
112
+ await unlink(join(jobDirectory(job.id), 'cancel')).catch(() => { });
113
+ await saveImportJob(job);
114
+ await launch(job);
115
+ return summary(job);
116
+ }));
117
+ register('gripforge_game_import_control', {
118
+ title: 'Cancel or resume a local game import', description: 'Cancel between files; an already committed upload remains imported. Resume preserves successful receipts and checkpoints. After a worker crash, resume with the same job_id. Credentials are supplied by the current MCP session, never saved in the job report.',
119
+ inputSchema: { job_id: id, action: z.enum(['cancel', 'resume']) }, annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
120
+ }, wrap(async (a) => {
121
+ const job = await loadImportJob(a.job_id);
122
+ if (a.action === 'cancel') {
123
+ await writeFile(join(jobDirectory(job.id), 'cancel'), 'cancel', { mode: 0o600 });
124
+ await assertIdle(job.id).then(async () => { job.status = 'cancelled'; await saveImportJob(job); }).catch(() => { });
125
+ }
126
+ else {
127
+ await assertIdle(job.id);
128
+ if (job.phase === 'import' && !options.getApiKey())
129
+ throw Error('API key required to resume import');
130
+ await unlink(join(jobDirectory(job.id), 'cancel')).catch(() => { });
131
+ job.status = 'queued';
132
+ delete job.errors.worker;
133
+ await saveImportJob(job);
134
+ await launch(job, options.getApiKey() ?? undefined);
135
+ }
136
+ return summary(job);
137
+ }));
138
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { GameImportAgent, claimImportJob, loadImportJob, saveImportJob } from './game-import-agent.js';
2
+ import { normalizeGame } from './game-import-normalize.js';
3
+ import { extractGame } from './game-import-extract.js';
4
+ const id = process.argv[2];
5
+ const release = await claimImportJob(id);
6
+ const job = await loadImportJob(id);
7
+ try {
8
+ job.status = 'running';
9
+ job.workerPid = process.pid;
10
+ await saveImportJob(job);
11
+ const agent = new GameImportAgent(job);
12
+ if (job.phase === 'normalize')
13
+ await normalizeGame(job);
14
+ else if (job.phase === 'extract')
15
+ await extractGame(job);
16
+ else if (job.phase === 'analyze')
17
+ await agent.analyze();
18
+ else {
19
+ const key = process.env.GRIPFORGE_API_KEY;
20
+ if (!key)
21
+ throw Error('API key required to resume import');
22
+ await agent.import_to_gripforge(key);
23
+ }
24
+ }
25
+ catch (e) {
26
+ job.status = await new GameImportAgent(job).cancelled() ? 'cancelled' : 'failed';
27
+ job.errors.worker = e instanceof Error ? e.message : String(e);
28
+ await saveImportJob(job);
29
+ }
30
+ finally {
31
+ delete job.workerPid;
32
+ await saveImportJob(job);
33
+ await release();
34
+ }