hoi4-agent-tools 1.2.0 → 2.0.0

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/CHANGELOG.md CHANGED
@@ -1,9 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.0 - 2026-07-15
4
+
5
+ - Removed the workspace inventory tool and made the current mod implicit for local MCP calls.
6
+ - Starting the server inside any mod now works without a config file or per-mod setup command.
7
+ - Updated the published client examples, Registry metadata, and installation verification for the twelve-tool surface.
8
+ - Rendered unresolved scripted localisation values as `[X]` and applied supported HOI4 `§` text colours in offline GUI previews.
9
+
3
10
  ## 1.2.0 - 2026-07-13
4
11
 
5
12
  - Added a read-only Event Chain Viewer for scanning, tracing, explaining, linting, rendering, and comparing large HOI4 event chains.
6
- - Kept the event surface to three tools with seven inspect modes and linked authoritative graph artifacts, bringing the public surface to thirteen tools within the fixed discovery budget.
13
+ - Kept the event surface to three tools with seven inspect modes and linked authoritative graph artifacts, keeping the public surface to twelve tools within the fixed discovery budget. Local calls now resolve the mod containing the MCP working directory.
7
14
  - Added event workflow documentation, package and Registry metadata, and project-owned acceptance coverage for more than 300 event definitions.
8
15
  - Reconfirmed MCP protocol `2025-11-25`, TypeScript SDK 1.29.0, Registry schema `2025-12-11`, and Registry publisher 1.7.9 for this release.
9
16
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # HOI4 Agent Tools
2
2
 
3
- HOI4 Agent Tools is an MCP server for coding agents to analyze Hearts of Iron IV event chains and inspect, create, or clean up focus trees, scripted GUIs, and maps. It works directly with configured mod folders and produces offline renders for review.
3
+ HOI4 Agent Tools is an MCP server for coding agents to analyze Hearts of Iron IV event chains and inspect, create, or clean up focus trees, scripted GUIs, and maps. Start it in a mod folder and it works there immediately, with offline renders for review.
4
4
 
5
5
  ## What it does
6
6
 
@@ -14,11 +14,10 @@ HOI4 Agent Tools is an MCP server for coding agents to analyze Hearts of Iron IV
14
14
  Requires Node.js 22 or 24.
15
15
 
16
16
  ```bash
17
- npm install --global hoi4-agent-tools@1.2.0
18
- hoi4-agent-tools-setup --init
17
+ npm install --global hoi4-agent-tools@2.0.0
19
18
  ```
20
19
 
21
- `--init` discovers the usual HOI4 game and mod locations and writes the default per-user configuration. No `HOI4_AGENT_CONFIG` environment variable is needed. For custom paths, multiple mod roots, or a custom config location, see [Setup](docs/setup.md).
20
+ Start the MCP with its working directory set to the mod you are editing. No config or per-mod registration is required: the server finds that mod automatically. Run `hoi4-agent-tools-setup --init` only when you want persistent discovery for several mod roots, a custom game path, or a remote deployment; see [Setup](docs/setup.md).
22
21
 
23
22
  ## Connect your agent
24
23
 
@@ -32,7 +31,6 @@ Paste the printed Codex or generic global-install entry into your MCP client, th
32
31
 
33
32
  | Tool | Purpose |
34
33
  | -------------------- | --------------------------------------------------------------------------------------- |
35
- | `hoi4.mods` | List the writable mods available to the coding agent. |
36
34
  | `hoi4.focus_inspect` | Read focus trees and report structural or reference problems. |
37
35
  | `hoi4.focus_render` | Render an existing focus tree or continuous palette. |
38
36
  | `hoi4.focus_rewrite` | Create or update a focus tree. |
@@ -4,6 +4,8 @@ import { WORKSPACE_MAX_REGISTRATIONS, workspaceRegistrationSchema, } from './con
4
4
  import { compareCodeUnits, hashCanonical, sha256Bytes } from './canonical.js';
5
5
  import { ServiceError } from './result.js';
6
6
  import { ServerState } from './server-state.js';
7
+ /** Omitted MCP workspace arguments resolve to the mod containing the server cwd. */
8
+ export const CURRENT_WORKSPACE_ID = 'current';
7
9
  async function exists(value, signal) {
8
10
  signal?.throwIfAborted();
9
11
  try {
@@ -185,6 +187,9 @@ export class WorkspaceResolver {
185
187
  }
186
188
  }
187
189
  for (const configured of configuration.workspaces) {
190
+ if (configured.id === CURRENT_WORKSPACE_ID) {
191
+ throw new ServiceError('WORKSPACE_ID_RESERVED', `Workspace ID is reserved for the current working mod: ${CURRENT_WORKSPACE_ID}`);
192
+ }
188
193
  const registration = resolver.withGlobalWorkspaceDefaults(configured);
189
194
  const workspace = await resolver.resolveRegistration(registration);
190
195
  if (resolver.#byId.has(workspace.id)) {
@@ -203,6 +208,28 @@ export class WorkspaceResolver {
203
208
  config() {
204
209
  return this.configuration;
205
210
  }
211
+ /** Resolve the implicit workspace used by local coding-agent calls. */
212
+ resolveCurrentWorkspaceId(principal) {
213
+ const workspaces = this.list(principal).filter(({ registration }) => registration.kind === 'mod');
214
+ if (workspaces.length === 0) {
215
+ throw new ServiceError('WORKSPACE_CONTEXT_NOT_FOUND', 'No writable mod workspace is configured for the MCP server');
216
+ }
217
+ const currentDirectory = path.normalize(process.cwd());
218
+ const containing = workspaces.filter((workspace) => isWithin(workspace.modRoot, currentDirectory));
219
+ if (containing.length === 1)
220
+ return containing[0].id;
221
+ if (containing.length > 1) {
222
+ throw new ServiceError('WORKSPACE_CONTEXT_AMBIGUOUS', 'The MCP working directory is inside more than one writable mod workspace', { currentDirectory });
223
+ }
224
+ if (workspaces.length === 1)
225
+ return workspaces[0].id;
226
+ throw new ServiceError('WORKSPACE_CONTEXT_REQUIRED', 'The MCP working directory does not identify one writable mod workspace', { currentDirectory, workspaceIds: workspaces.map(({ id }) => id) });
227
+ }
228
+ resolveWorkspaceId(workspaceId, principal) {
229
+ return workspaceId === CURRENT_WORKSPACE_ID
230
+ ? this.resolveCurrentWorkspaceId(principal)
231
+ : workspaceId;
232
+ }
206
233
  serverState() {
207
234
  return this.#serverState;
208
235
  }
@@ -276,15 +303,16 @@ export class WorkspaceResolver {
276
303
  .sort((a, b) => compareCodeUnits(a.id, b.id));
277
304
  }
278
305
  get(workspaceId, principal) {
279
- if (principal !== undefined && !this.allowedWorkspaceIds(principal).has(workspaceId)) {
306
+ const resolvedWorkspaceId = this.resolveWorkspaceId(workspaceId, principal);
307
+ if (principal !== undefined && !this.allowedWorkspaceIds(principal).has(resolvedWorkspaceId)) {
280
308
  throw new ServiceError('WORKSPACE_INACCESSIBLE', 'Workspace is unavailable to the authenticated principal');
281
309
  }
282
- const workspace = this.#byId.get(workspaceId);
310
+ const workspace = this.#byId.get(resolvedWorkspaceId);
283
311
  if (workspace === undefined) {
284
312
  if (principal !== undefined) {
285
313
  throw new ServiceError('WORKSPACE_INACCESSIBLE', 'Workspace is unavailable to the authenticated principal');
286
314
  }
287
- throw new ServiceError('WORKSPACE_NOT_REGISTERED', `Workspace is not registered: ${workspaceId}`);
315
+ throw new ServiceError('WORKSPACE_NOT_REGISTERED', `Workspace is not registered: ${resolvedWorkspaceId}`);
288
316
  }
289
317
  return workspace;
290
318
  }
@@ -40,8 +40,55 @@ function scalarString(value) {
40
40
  return String(value);
41
41
  return undefined;
42
42
  }
43
+ const defaultTextColour = '#f5f2e8';
44
+ const hoi4TextColours = {
45
+ B: '#4aa3df',
46
+ C: '#55d6d6',
47
+ G: '#56b870',
48
+ H: '#e7b454',
49
+ L: '#9aa7b3',
50
+ M: '#d16bd4',
51
+ O: '#e58b3c',
52
+ R: '#e05a5a',
53
+ T: '#4bc6b9',
54
+ W: defaultTextColour,
55
+ Y: '#f1c75b',
56
+ b: '#4aa3df',
57
+ g: '#87939e',
58
+ };
43
59
  function visibleHoiText(value) {
44
- return value.replace(/\u00a7[A-Za-z0-9!]/gu, '').replace(/\u00a3[^\u00a3\s]+/gu, '\u25c6');
60
+ const characters = [];
61
+ const colours = [];
62
+ let colour;
63
+ let hasColourMarkup = false;
64
+ for (let index = 0; index < value.length; index += 1) {
65
+ const character = value[index] ?? '';
66
+ const next = value[index + 1];
67
+ if (character === '\u00a7' && next !== undefined && /^[A-Za-z0-9!]$/u.test(next)) {
68
+ hasColourMarkup = true;
69
+ colour = next === '!' ? undefined : hoi4TextColours[next];
70
+ index += 1;
71
+ continue;
72
+ }
73
+ if (character === '\u00a3') {
74
+ let cursor = index + 1;
75
+ while (cursor < value.length && !/[\s\u00a3]/u.test(value[cursor] ?? ''))
76
+ cursor += 1;
77
+ characters.push('\u25c6');
78
+ colours.push(colour);
79
+ index = cursor - 1;
80
+ continue;
81
+ }
82
+ if (character === '\\' && next === 'n') {
83
+ characters.push('\n');
84
+ colours.push(colour);
85
+ index += 1;
86
+ continue;
87
+ }
88
+ characters.push(character);
89
+ colours.push(colour);
90
+ }
91
+ return { text: characters.join(''), colours, hasColourMarkup };
45
92
  }
46
93
  function scalarNumber(value, reference) {
47
94
  if (typeof value === 'number')
@@ -167,13 +214,15 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
167
214
  const key = match[1] ?? '';
168
215
  const replacement = rowValues?.[key] ?? scenario.variables[key] ?? scenario.scriptedGui[key];
169
216
  if (replacement === undefined) {
170
- unresolved.push(`[?${key}]`);
171
- return `[?${key}]`;
217
+ unresolved.push(match[0]);
218
+ return '[X]';
172
219
  }
173
220
  return String(replacement);
174
221
  }, 'GUI variable substitution');
175
222
  text = replaceTextBounded(text, /\[([A-Za-z0-9_.:-]+)\]/gu, (match) => {
176
223
  const key = match[1] ?? '';
224
+ if (key === 'X')
225
+ return match[0];
177
226
  const countryKey = key.replace(/^(?:ROOT|This)\./u, '');
178
227
  const replacement = rowValues?.[countryKey] ??
179
228
  scenario.country?.[countryKey] ??
@@ -181,16 +230,16 @@ function resolveTokenText(value, scenario, localisation, rowValues) {
181
230
  scenario.scriptedGui[key];
182
231
  if (replacement === undefined) {
183
232
  unresolved.push(match[0]);
184
- return match[0];
233
+ return '[X]';
185
234
  }
186
235
  return String(replacement);
187
236
  }, 'GUI scope substitution');
188
237
  return { text, unresolved: [...new Set(unresolved)].sort(), missingLocalisation };
189
238
  }
190
- function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
191
- const paragraphs = text.replaceAll('\\n', '\n').split('\n');
239
+ function wrapText(catalog, fontName, text, colours, fontSize, maximumWidth, work) {
192
240
  const lines = [];
193
241
  const widths = [];
242
+ const colourLines = [];
194
243
  const missingGlyphs = new Set();
195
244
  const retainMissingGlyphs = (values) => {
196
245
  for (const value of values) {
@@ -201,12 +250,44 @@ function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
201
250
  };
202
251
  let lineHeight = fontSize * 1.2;
203
252
  let metricSource = 'approximation';
204
- for (const paragraph of paragraphs) {
253
+ const commitLine = (words) => {
254
+ const characters = [];
255
+ const lineColours = [];
256
+ for (const [index, word] of words.entries()) {
257
+ if (index > 0) {
258
+ characters.push(' ');
259
+ lineColours.push(lineColours.at(-1) ?? word.colours[0]);
260
+ }
261
+ characters.push(word.text);
262
+ lineColours.push(...word.colours);
263
+ }
264
+ const committedText = characters.join('');
265
+ work.spendTextLayout('committed text line measurement');
266
+ const committed = catalog.measureText(fontName, committedText, fontSize);
267
+ lines.push(committedText);
268
+ widths.push(committed.width);
269
+ colourLines.push(lineColours);
270
+ retainMissingGlyphs(committed.missingGlyphs);
271
+ lineHeight = committed.lineHeight;
272
+ metricSource = committed.source;
273
+ };
274
+ let paragraphStart = 0;
275
+ for (let paragraphEnd = 0; paragraphEnd <= text.length; paragraphEnd += 1) {
276
+ if (paragraphEnd < text.length && text[paragraphEnd] !== '\n')
277
+ continue;
205
278
  work.spendTextLayout('text paragraph layout');
206
- const words = paragraph.split(/\s+/u).filter((word) => word.length > 0);
279
+ const paragraph = text.slice(paragraphStart, paragraphEnd);
280
+ const paragraphColours = colours.slice(paragraphStart, paragraphEnd);
281
+ const words = [...paragraph.matchAll(/\S+/gu)].map((match) => {
282
+ const start = match.index;
283
+ const word = match[0];
284
+ return { text: word, colours: paragraphColours.slice(start, start + word.length) };
285
+ });
286
+ paragraphStart = paragraphEnd + 1;
207
287
  if (words.length === 0) {
208
288
  lines.push('');
209
289
  widths.push(0);
290
+ colourLines.push([]);
210
291
  continue;
211
292
  }
212
293
  let currentWords = [];
@@ -215,22 +296,17 @@ function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
215
296
  let previousWordWidth = 0;
216
297
  for (const word of words) {
217
298
  work.spendTextLayout('text word measurement');
218
- const measuredWord = catalog.measureText(fontName, word, fontSize);
299
+ const measuredWord = catalog.measureText(fontName, word.text, fontSize);
219
300
  retainMissingGlyphs(measuredWord.missingGlyphs);
220
301
  let candidateWidth = measuredWord.width;
221
302
  if (previousWord !== undefined) {
222
303
  work.spendTextLayout('text word-boundary measurement');
223
- const boundary = catalog.measureText(fontName, `${previousWord} ${word}`, fontSize);
304
+ const boundary = catalog.measureText(fontName, `${previousWord} ${word.text}`, fontSize);
224
305
  retainMissingGlyphs(boundary.missingGlyphs);
225
306
  candidateWidth = currentWidth + boundary.width - previousWordWidth;
226
307
  }
227
308
  if (maximumWidth > 0 && candidateWidth > maximumWidth && currentWords.length > 0) {
228
- const committedText = currentWords.join(' ');
229
- work.spendTextLayout('committed text line measurement');
230
- const committed = catalog.measureText(fontName, committedText, fontSize);
231
- lines.push(committedText);
232
- widths.push(committed.width);
233
- retainMissingGlyphs(committed.missingGlyphs);
309
+ commitLine(currentWords);
234
310
  currentWords = [word];
235
311
  currentWidth = measuredWord.width;
236
312
  }
@@ -238,26 +314,40 @@ function wrapText(catalog, fontName, text, fontSize, maximumWidth, work) {
238
314
  currentWords.push(word);
239
315
  currentWidth = candidateWidth;
240
316
  }
241
- previousWord = word;
317
+ previousWord = word.text;
242
318
  previousWordWidth = measuredWord.width;
243
319
  }
244
- const committedText = currentWords.join(' ');
245
- work.spendTextLayout('committed text line measurement');
246
- const committed = catalog.measureText(fontName, committedText, fontSize);
247
- lines.push(committedText);
248
- widths.push(committed.width);
249
- retainMissingGlyphs(committed.missingGlyphs);
250
- lineHeight = committed.lineHeight;
251
- metricSource = committed.source;
320
+ commitLine(currentWords);
252
321
  }
253
322
  return {
254
323
  lines,
255
324
  widths,
325
+ colourLines,
256
326
  lineHeight,
257
327
  metricSource,
258
328
  missingGlyphs: [...missingGlyphs].sort((a, b) => a - b),
259
329
  };
260
330
  }
331
+ function colourRunsForLine(catalog, fontName, line, colours, fontSize, work) {
332
+ if (!colours.some((colour) => colour !== undefined) || line.length === 0)
333
+ return [];
334
+ const runs = [];
335
+ let start = 0;
336
+ let offsetX = 0;
337
+ while (start < line.length) {
338
+ const colour = colours[start] ?? defaultTextColour;
339
+ let end = start + 1;
340
+ while (end < line.length && (colours[end] ?? defaultTextColour) === colour)
341
+ end += 1;
342
+ const text = line.slice(start, end);
343
+ work.spendTextLayout('localisation colour-run measurement');
344
+ const width = catalog.measureText(fontName, text, fontSize).width;
345
+ runs.push({ text, colour, offsetX, width });
346
+ offsetX += width;
347
+ start = end;
348
+ }
349
+ return runs;
350
+ }
261
351
  function frameFor(element, sprite, scenario) {
262
352
  const frameCount = Math.max(1, sprite.frameCount);
263
353
  const selected = scenario.selectedFrames[element.name] ?? scenario.selectedFrames[sprite.name];
@@ -439,7 +529,8 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
439
529
  displayText = `${displayText} — ${displayText} — ${displayText}`;
440
530
  if (state === 'missing-localisation')
441
531
  displayText = `\u00a7R${rawText}_MISSING\u00a7!`;
442
- displayText = visibleHoiText(displayText);
532
+ const visibleText = visibleHoiText(displayText);
533
+ displayText = visibleText.text;
443
534
  context.work.admitText(displayText, `GUI text for ${definition.name}`);
444
535
  const fontName = scalarString(property(definition.attributes, 'font', 'buttonFont'));
445
536
  const resolvedFontMetrics = catalog.resolvedFontMetrics(fontName);
@@ -450,10 +541,13 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
450
541
  16;
451
542
  const maxWidth = (scalarNumber(property(definition.attributes, 'maxWidth'), width / scale) ?? width / scale) *
452
543
  scale;
453
- const wrapped = wrapText(catalog, fontName, displayText, fontSize * scale, maxWidth, context.work);
544
+ const wrapped = wrapText(catalog, fontName, displayText, visibleText.colours, fontSize * scale, maxWidth, context.work);
454
545
  const glyphLines = [];
455
546
  for (const line of wrapped.lines)
456
547
  glyphLines.push(await catalog.shapeText(fontName, line, fontSize * scale));
548
+ const colourRuns = visibleText.hasColourMarkup
549
+ ? wrapped.lines.map((line, index) => colourRunsForLine(catalog, fontName, line, wrapped.colourLines[index] ?? [], fontSize * scale, context.work))
550
+ : undefined;
457
551
  const maximumLineWidth = wrapped.widths.reduce((maximum, value) => Math.max(maximum, value), 0);
458
552
  if (width === 0)
459
553
  width = maximumLineWidth;
@@ -476,6 +570,7 @@ async function layoutElement(definition, parentRect, inheritedClip, parentScale,
476
570
  overflowX: width > 0 && measuredWidth > width + 0.01,
477
571
  overflowY: height > 0 && measuredHeight > height + 0.01,
478
572
  unresolvedTokens: resolved.unresolved,
573
+ ...(colourRuns === undefined ? {} : { colourRuns }),
479
574
  };
480
575
  if (wrapped.metricSource === 'approximation')
481
576
  addFidelity(fidelity, 'approximated', 'font_metrics', `No supplied font metrics for ${fontName ?? '<default>'}.`, definition);
@@ -86,6 +86,22 @@ function renderText(element, toolText) {
86
86
  (glyphLine === undefined || glyphLine.source === 'deterministic-fallback'
87
87
  ? text.lineHeight * 0.8
88
88
  : glyphLine.baseline);
89
+ const colourRuns = text.colourRuns?.[index] ?? [];
90
+ if (colourRuns.length > 0) {
91
+ const runWidth = colourRuns.reduce((maximum, run) => Math.max(maximum, run.offsetX + run.width), 0);
92
+ const horizontalScale = runWidth > 0 && width > 0 ? width / runWidth : 1;
93
+ return `<g data-hoi4-colour-runs="true">${colourRuns
94
+ .map((run) => toolText.render(run.text, {
95
+ x: originX + run.offsetX * horizontalScale,
96
+ y: baseline,
97
+ fontSize: text.fontSize,
98
+ fill: run.colour,
99
+ stroke: '#12151a',
100
+ strokeWidth: 0.6,
101
+ ...(run.width > 0 ? { targetWidth: run.width * horizontalScale } : {}),
102
+ }))
103
+ .join('')}</g>`;
104
+ }
89
105
  if (glyphLine?.source === 'fontkit-path') {
90
106
  const horizontalScale = glyphLine.width > 0 && width > 0 ? width / glyphLine.width : 1;
91
107
  return `<g data-font-sha256="${glyphLine.sourceHash}">${glyphLine.glyphs
@@ -1,12 +1,11 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { PACKAGE_NAME, PACKAGE_VERSION } from '../../version.js';
3
- import { registerModsTool } from '../tools/mods.js';
4
3
  import { registerFocusTools } from '../tools/focus.js';
5
4
  import { registerGuiTools } from '../tools/gui.js';
6
5
  import { registerMapTools } from '../tools/map.js';
7
6
  import { registerEventTools } from '../tools/event.js';
8
7
  import { registerMcpResources } from '../resources/register.js';
9
- export const SERVER_INSTRUCTIONS = 'Use hoi4.mods to select a workspace. Use hoi4.event_inspect for scan, roots, trace, explain_path, state_flow, lint, and impact; hoi4.event_render and hoi4.event_compare return linked evidence. JSON resources are authoritative. Event tools are read-only. Only focus, GUI, and map rewrite tools edit configured mods.';
8
+ export const SERVER_INSTRUCTIONS = 'The workspace defaults to the mod containing the MCP working directory; pass workspaceId only when selecting another configured mod. Use hoi4.event_inspect for scan, roots, trace, explain_path, state_flow, lint, and impact; hoi4.event_render and hoi4.event_compare return linked evidence. JSON resources are authoritative. Event tools are read-only. Only focus, GUI, and map rewrite tools edit configured mods.';
10
9
  export function createMcpServer(engine, context = {}) {
11
10
  const server = new McpServer({
12
11
  name: PACKAGE_NAME,
@@ -16,7 +15,6 @@ export function createMcpServer(engine, context = {}) {
16
15
  capabilities: { logging: {} },
17
16
  instructions: SERVER_INSTRUCTIONS,
18
17
  });
19
- registerModsTool(server, engine, context);
20
18
  registerFocusTools(server, engine, context);
21
19
  registerGuiTools(server, engine, context);
22
20
  registerMapTools(server, engine, context);
@@ -306,11 +306,11 @@ export function registerEventTools(server, engine, context) {
306
306
  outputSchema: eventInspectOutputSchema,
307
307
  annotations: readOnlyEventTool,
308
308
  }, async (input, extra) => {
309
- const { workspaceId } = input;
309
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
310
310
  try {
311
311
  const progress = progressReporter(extra);
312
312
  await progress.report(0, 3, 'Analyzing event chain');
313
- const inspected = await viewer.inspect(inspectRequest(input, context, progress.signal));
313
+ const inspected = await viewer.inspect(inspectRequest({ ...input, workspaceId }, context, progress.signal));
314
314
  await progress.report(2, 3, 'Linking complete event analysis');
315
315
  const result = emptyServiceResult(workspaceId, {
316
316
  mode: inspected.mode,
@@ -344,11 +344,11 @@ export function registerEventTools(server, engine, context) {
344
344
  outputSchema: eventRenderOutputSchema,
345
345
  annotations: readOnlyEventTool,
346
346
  }, async (input, extra) => {
347
- const { workspaceId } = input;
347
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
348
348
  try {
349
349
  const progress = progressReporter(extra);
350
350
  await progress.report(0, 3, 'Rendering event-chain view');
351
- const rendered = await viewer.renderAndStore(renderRequest(input, context, progress.signal));
351
+ const rendered = await viewer.renderAndStore(renderRequest({ ...input, workspaceId }, context, progress.signal));
352
352
  await progress.report(2, 3, 'Linking complete event renders');
353
353
  const result = emptyServiceResult(workspaceId, {
354
354
  view: rendered.render.view,
@@ -389,11 +389,11 @@ export function registerEventTools(server, engine, context) {
389
389
  outputSchema: eventCompareOutputSchema,
390
390
  annotations: readOnlyEventTool,
391
391
  }, async (input, extra) => {
392
- const { workspaceId } = input;
392
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
393
393
  try {
394
394
  const progress = progressReporter(extra);
395
395
  await progress.report(0, 3, 'Comparing event-chain graphs');
396
- const compared = await viewer.compareAndStore(compareRequest(input, context, progress.signal));
396
+ const compared = await viewer.compareAndStore(compareRequest({ ...input, workspaceId }, context, progress.signal));
397
397
  await progress.report(2, 3, 'Linking complete event comparison');
398
398
  const { comparison } = compared;
399
399
  const result = emptyServiceResult(workspaceId, {
@@ -451,7 +451,8 @@ export function registerFocusTools(server, engine, context) {
451
451
  outputSchema: focusInspectOutputSchema,
452
452
  annotations: artifactProducing,
453
453
  }, async (input, extra) => {
454
- const { workspaceId, relativePath } = input;
454
+ const { workspaceId: requestedWorkspaceId, relativePath } = input;
455
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
455
456
  try {
456
457
  const progress = progressReporter(extra);
457
458
  await progress.report(0, 3, 'Building shared workspace index');
@@ -667,7 +668,8 @@ export function registerFocusTools(server, engine, context) {
667
668
  outputSchema: focusRenderOutputSchema,
668
669
  annotations: artifactProducing,
669
670
  }, async (input, extra) => {
670
- const { workspaceId, relativePath } = input;
671
+ const { workspaceId: requestedWorkspaceId, relativePath } = input;
672
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
671
673
  try {
672
674
  const progress = progressReporter(extra);
673
675
  await progress.report(0, 3, 'Importing and indexing focus source');
@@ -790,7 +792,8 @@ export function registerFocusTools(server, engine, context) {
790
792
  openWorldHint: false,
791
793
  },
792
794
  }, async (input, extra) => {
793
- const { workspaceId, relativePath, createIfMissing, horizontalSpacing, verticalSpacing, padding, reviewScale, } = input;
795
+ const { workspaceId: requestedWorkspaceId, relativePath, createIfMissing, horizontalSpacing, verticalSpacing, padding, reviewScale, } = input;
796
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
794
797
  try {
795
798
  requireServerScope(context, 'hoi4:write');
796
799
  const progress = progressReporter(extra);
@@ -149,7 +149,8 @@ export function registerGuiTools(server, engine, context) {
149
149
  inputSchema: guiInspectInput,
150
150
  outputSchema: guiScanOutputSchema,
151
151
  annotations: artifactProducing,
152
- }, async ({ workspaceId, windowName, scenario, relatedScenarios }, extra) => {
152
+ }, async ({ workspaceId: requestedWorkspaceId, windowName, scenario, relatedScenarios }, extra) => {
153
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
153
154
  try {
154
155
  const progress = progressReporter(extra);
155
156
  await progress.report(0, 3, 'Building shared index and GUI inspection');
@@ -253,7 +254,8 @@ export function registerGuiTools(server, engine, context) {
253
254
  }
254
255
  });
255
256
  const render = async (input, extra, code) => {
256
- const { workspaceId, windowName } = input;
257
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
258
+ const { windowName } = input;
257
259
  try {
258
260
  const progress = progressReporter(extra);
259
261
  await progress.report(0, 4, 'Building shared index and GUI source graph');
@@ -444,7 +446,8 @@ export function registerGuiTools(server, engine, context) {
444
446
  openWorldHint: false,
445
447
  },
446
448
  }, async (input, extra) => {
447
- const { workspaceId, relativePath, windowName, scenario } = input;
449
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
450
+ const { relativePath, windowName, scenario } = input;
448
451
  try {
449
452
  requireServerScope(context, 'hoi4:write');
450
453
  const progress = progressReporter(extra);
@@ -364,7 +364,8 @@ export function registerMapTools(server, engine, context) {
364
364
  .strict(),
365
365
  outputSchema: mapWorkspaceInspectOutputSchema,
366
366
  annotations: artifactProducing,
367
- }, async ({ workspaceId, provinceIds, stateIds, regionIds, allocationRequests }, extra) => {
367
+ }, async ({ workspaceId: requestedWorkspaceId, provinceIds, stateIds, regionIds, allocationRequests }, extra) => {
368
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
368
369
  try {
369
370
  const progress = progressReporter(extra);
370
371
  await progress.report(0, 3, 'Inspecting and validating the map');
@@ -512,7 +513,8 @@ export function registerMapTools(server, engine, context) {
512
513
  .strict(),
513
514
  outputSchema: mapRenderOutputSchema,
514
515
  annotations: artifactProducing,
515
- }, async ({ workspaceId, layer, overlays, scale }, extra) => {
516
+ }, async ({ workspaceId: requestedWorkspaceId, layer, overlays, scale }, extra) => {
517
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
516
518
  try {
517
519
  const progress = progressReporter(extra);
518
520
  await progress.report(0, 3, 'Rendering offline map layer');
@@ -557,7 +559,8 @@ export function registerMapTools(server, engine, context) {
557
559
  idempotentHint: false,
558
560
  openWorldHint: false,
559
561
  },
560
- }, async ({ workspaceId, operations, diffScale }, extra) => {
562
+ }, async ({ workspaceId: requestedWorkspaceId, operations, diffScale }, extra) => {
563
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
561
564
  try {
562
565
  requireServerScope(context, 'hoi4:write');
563
566
  const progress = progressReporter(extra);
@@ -1,9 +1,11 @@
1
+ import { stat } from 'node:fs/promises';
1
2
  import path from 'node:path';
2
3
  import { homedir } from 'node:os';
3
- import { loadConfiguration } from './core/configuration.js';
4
+ import { loadConfiguration, serverConfigurationSchema, } from './core/configuration.js';
4
5
  import { CoreEngine } from './core/engine.js';
5
6
  import { ServiceError } from './core/result.js';
6
7
  import { WorkspaceResolver } from './core/workspace.js';
8
+ import { CONFIG_VERSION } from './version.js';
7
9
  export function defaultConfigurationPath() {
8
10
  const configRoot = process.env.APPDATA ?? process.env.XDG_CONFIG_HOME ?? path.join(homedir(), '.config');
9
11
  return path.join(configRoot, 'hoi4-agent-tools', 'config.json');
@@ -21,8 +23,116 @@ export function configurationPath(arguments_ = process.argv.slice(2)) {
21
23
  return path.resolve(process.env.HOI4_AGENT_CONFIG);
22
24
  return defaultConfigurationPath();
23
25
  }
26
+ async function isDirectory(candidate) {
27
+ try {
28
+ return (await stat(candidate)).isDirectory();
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ async function isFile(candidate) {
35
+ try {
36
+ return (await stat(candidate)).isFile();
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ async function findCurrentModRoot(start = process.cwd()) {
43
+ for (let cursor = path.resolve(start);;) {
44
+ if ((await isFile(path.join(cursor, 'descriptor.mod'))) ||
45
+ ((await isDirectory(path.join(cursor, 'common'))) &&
46
+ ((await isDirectory(path.join(cursor, 'history'))) ||
47
+ (await isDirectory(path.join(cursor, 'interface'))) ||
48
+ (await isDirectory(path.join(cursor, 'map')))))) {
49
+ return cursor;
50
+ }
51
+ const parent = path.dirname(cursor);
52
+ if (parent === cursor)
53
+ break;
54
+ cursor = parent;
55
+ }
56
+ throw new ServiceError('AUTO_MOD_ROOT_NOT_FOUND', 'Start the MCP from inside a Hearts of Iron IV mod or configure a server workspace', { currentDirectory: path.resolve(start) });
57
+ }
58
+ async function firstExistingDirectory(candidates) {
59
+ for (const candidate of candidates) {
60
+ if (await isDirectory(candidate))
61
+ return path.resolve(candidate);
62
+ }
63
+ return undefined;
64
+ }
65
+ function automaticGameRootCandidates() {
66
+ const home = homedir();
67
+ return [
68
+ ...(process.env.HOI4_GAME_ROOT === undefined ? [] : [process.env.HOI4_GAME_ROOT]),
69
+ path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Steam', 'steamapps', 'common', 'Hearts of Iron IV'),
70
+ path.join(home, '.local', 'share', 'Steam', 'steamapps', 'common', 'Hearts of Iron IV'),
71
+ path.join(home, '.steam', 'steam', 'steamapps', 'common', 'Hearts of Iron IV'),
72
+ path.join(home, 'Library', 'Application Support', 'Steam', 'steamapps', 'common', 'Hearts of Iron IV'),
73
+ ];
74
+ }
75
+ function automaticStorageRoots() {
76
+ const home = homedir();
77
+ const dataRoot = process.env.XDG_DATA_HOME ?? process.env.LOCALAPPDATA ?? path.join(home, '.local', 'share');
78
+ const stateRoot = process.env.XDG_STATE_HOME ?? process.env.LOCALAPPDATA ?? path.join(home, '.local', 'state');
79
+ return {
80
+ serverStateRoot: path.join(stateRoot, 'hoi4-agent-tools', 'state'),
81
+ workspaceStorageRoot: path.join(dataRoot, 'hoi4-agent-tools', 'workspaces'),
82
+ };
83
+ }
84
+ function automaticWorkspaceId(modRoot) {
85
+ const slug = path
86
+ .basename(modRoot)
87
+ .normalize('NFKD')
88
+ .toLowerCase()
89
+ .replace(/[^a-z0-9]+/gu, '_')
90
+ .replace(/^_+|_+$/gu, '')
91
+ .slice(0, 48);
92
+ return `auto_${slug.length === 0 ? 'mod' : slug}`;
93
+ }
94
+ /** Build an in-memory configuration when a local agent starts inside a mod. */
95
+ export async function automaticConfiguration(start = process.cwd()) {
96
+ const modRoot = await findCurrentModRoot(start);
97
+ const gameRoot = await firstExistingDirectory(automaticGameRootCandidates());
98
+ const storage = automaticStorageRoots();
99
+ return serverConfigurationSchema.parse({
100
+ version: CONFIG_VERSION,
101
+ serverStateRoot: storage.serverStateRoot,
102
+ workspaceStorageRoot: storage.workspaceStorageRoot,
103
+ ...(gameRoot === undefined ? {} : { gameRoot }),
104
+ workspaces: [
105
+ {
106
+ id: automaticWorkspaceId(modRoot),
107
+ name: path.basename(modRoot),
108
+ root: modRoot,
109
+ },
110
+ ],
111
+ });
112
+ }
113
+ async function configurationExists(configPath) {
114
+ try {
115
+ return (await stat(configPath)).isFile();
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
24
121
  export async function createEngine(configPath = configurationPath()) {
25
- const configuration = await loadConfiguration(configPath);
122
+ let configuration;
123
+ try {
124
+ configuration = await loadConfiguration(configPath);
125
+ }
126
+ catch (error) {
127
+ const useAutomaticConfiguration = configPath === defaultConfigurationPath() &&
128
+ process.env.HOI4_AGENT_CONFIG === undefined &&
129
+ error instanceof ServiceError &&
130
+ error.code === 'CONFIG_READ_FAILED' &&
131
+ !(await configurationExists(configPath));
132
+ if (!useAutomaticConfiguration)
133
+ throw error;
134
+ configuration = await automaticConfiguration();
135
+ }
26
136
  const resolver = await WorkspaceResolver.create(configuration);
27
137
  const engine = new CoreEngine(resolver);
28
138
  await engine.initialize();
@@ -1,5 +1,9 @@
1
1
  import { z } from 'zod/v4';
2
- export const workspaceIdSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u);
2
+ /** Optional for local agents: omitted means the mod containing the MCP cwd. */
3
+ export const workspaceIdSchema = z
4
+ .string()
5
+ .regex(/^[a-z][a-z0-9_-]{0,63}$/u)
6
+ .default('current');
3
7
  const diagnosticCodeSchema = z.string().min(1).max(256);
4
8
  const diagnosticMessageSchema = z.string().max(4096);
5
9
  const sourceDisplayPathSchema = z.string().min(1).max(4096);
@@ -1,6 +1,6 @@
1
1
  import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
2
2
  export const PACKAGE_NAME = 'hoi4-agent-tools';
3
- export const PACKAGE_VERSION = '1.2.0';
3
+ export const PACKAGE_VERSION = '2.0.0';
4
4
  export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
5
5
  export const CONFIG_VERSION = 1;
6
6
  export const TRANSACTION_VERSION = 1;
package/docs/events.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The Event Chain Viewer analyzes event definitions and call sites across the active mod, configured dependencies, and vanilla sources. It is read-only: it does not rewrite event files or simulate the game.
4
4
 
5
- Start with `hoi4.mods` to obtain the workspace ID, then use the three event tools as needed.
5
+ Use the three event tools directly; omit `workspaceId` when the MCP working directory is inside the target mod.
6
6
 
7
7
  ## Inspect
8
8
 
package/docs/focus.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Focus trees
2
2
 
3
- Use `hoi4.focus_inspect`, `hoi4.focus_render`, and `hoi4.focus_rewrite` for national focus trees and continuous focus palettes. Start with `hoi4.mods` when the mod ID is not known.
3
+ Use `hoi4.focus_inspect`, `hoi4.focus_render`, and `hoi4.focus_rewrite` for national focus trees and continuous focus palettes. Omit `workspaceId` when the MCP working directory is inside the target mod.
4
4
 
5
5
  ## Create a tree
6
6
 
package/docs/gui.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Scripted GUIs
2
2
 
3
- Use `hoi4.gui_inspect`, `hoi4.gui_render`, and `hoi4.gui_rewrite` for `.gui`, `.gfx`, `common/scripted_guis`, localisation, sprites, fonts, and linked decision entry points. Start with `hoi4.mods` when the mod ID is not known.
3
+ Use `hoi4.gui_inspect`, `hoi4.gui_render`, and `hoi4.gui_rewrite` for `.gui`, `.gfx`, `common/scripted_guis`, localisation, sprites, fonts, and linked decision entry points. Omit `workspaceId` when the MCP working directory is inside the target mod.
4
4
 
5
5
  ## Create a GUI
6
6
 
@@ -34,4 +34,6 @@ Inspection checks missing assets and localisation, invalid sizes, overlap, clipp
34
34
 
35
35
  The renderer does not run the game engine. Each render includes a fidelity report that separates fields it models from fields it approximates, ignores, cannot resolve, or does not support. Treat that report as part of the review.
36
36
 
37
+ Unresolved runtime localisation expressions such as `[GetValue]` and `[?variable|format]` render as the compact placeholder `[X]`; provide scenario values when the exact text matters. Supported HOI4 `§` localisation colour controls are applied to preview text and `§!` restores the default colour.
38
+
37
39
  A rewrite stops if malformed or unsupported GUI script makes the requested change ambiguous. Runtime animation, masking, tiling, hardcoded controls, and dynamic values may require exact game precedents even when a useful offline preview is available.
package/docs/map.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Maps
2
2
 
3
- Use `hoi4.map_inspect`, `hoi4.map_render`, and `hoi4.map_rewrite` for provinces, states, strategic regions, adjacency, supply nodes, railways, positions, ownership, cores, claims, resources, buildings, and localisation. Start with `hoi4.mods` when the mod ID is not known.
3
+ Use `hoi4.map_inspect`, `hoi4.map_render`, and `hoi4.map_rewrite` for provinces, states, strategic regions, adjacency, supply nodes, railways, positions, ownership, cores, claims, resources, buildings, and localisation. Omit `workspaceId` when the MCP working directory is inside the target mod.
4
4
 
5
5
  ## Create or reshape map content
6
6
 
package/docs/setup.md CHANGED
@@ -2,18 +2,21 @@
2
2
 
3
3
  HOI4 Agent Tools requires Node.js 22 or 24.
4
4
 
5
- ## Install and initialize
5
+ ## Install
6
6
 
7
7
  ```bash
8
- npm install --global hoi4-agent-tools@1.2.0
9
- hoi4-agent-tools-setup --init
8
+ npm install --global hoi4-agent-tools
10
9
  ```
11
10
 
12
- The setup command discovers standard HOI4 game and mod locations, creates a separate storage folder for generated workspace data, and writes the default per-user config. Each configured mod root is a parent folder whose immediate child directories are individual mods. Every discovered mod is available through the focus, GUI, map, and read-only event tools without per-mod setup. The game root supplies vanilla references.
11
+ Set the MCP process working directory to the mod being edited. The server creates its local workspace in memory, keeps generated artifacts in the per-user data directory, and starts without a config file. The installed game is detected when available for vanilla references; it is not required for source inspection or offline rendering.
13
12
 
14
- After connecting the MCP server, call `hoi4.mods` to confirm which mods are available.
13
+ When the MCP server starts with its working directory inside a mod, every focus, GUI, map, and event call uses that mod automatically. Omit `workspaceId`. Explicit `workspaceId` values remain available for configured multi-mod or remote deployments.
15
14
 
16
- ## Override discovered paths
15
+ ## Optional persistent configuration
16
+
17
+ Use `--init` only when you need several mod roots, a fixed game path, or a shared/remote process:
18
+
19
+ ### Configure explicit paths
17
20
 
18
21
  Use explicit paths when discovery is incomplete or when you want to expose more than one mod root:
19
22
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hoi4-agent-tools",
3
3
  "mcpName": "io.github.klimPaskov/hoi4-agent-tools",
4
- "version": "1.2.0",
4
+ "version": "2.0.0",
5
5
  "description": "Analyze HOI4 event chains; create and clean focus trees, scripted GUIs, and maps with coding agents.",
6
6
  "type": "module",
7
7
  "license": "Apache-2.0",
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.klimPaskov/hoi4-agent-tools",
4
4
  "title": "HOI4 Agent Tools",
5
5
  "description": "Analyze HOI4 event chains; create and clean focus trees, scripted GUIs, and maps with coding agents.",
6
- "version": "1.2.0",
6
+ "version": "2.0.0",
7
7
  "repository": {
8
8
  "url": "https://github.com/klimPaskov/hoi4-agent-tools",
9
9
  "source": "github"
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "hoi4-agent-tools",
16
- "version": "1.2.0",
16
+ "version": "2.0.0",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }
@@ -1,54 +0,0 @@
1
- import { z } from 'zod/v4';
2
- import { compareCodeUnits } from '../../core/canonical.js';
3
- import { emptyServiceResult } from '../../core/result.js';
4
- import { nonNegativeIntegerSchema } from '../server/output-schemas.js';
5
- import { errorResult, strictOperationResultSchema, toolResult } from '../server/result.js';
6
- const modsOutputSchema = strictOperationResultSchema(z
7
- .object({
8
- count: nonNegativeIntegerSchema,
9
- mods: z
10
- .array(z
11
- .object({
12
- id: z.string().min(1).max(64),
13
- name: z.string().min(1).max(256),
14
- writable: z.boolean(),
15
- })
16
- .strict())
17
- .max(1_000),
18
- })
19
- .strict());
20
- export function registerModsTool(server, engine, context) {
21
- server.registerTool('hoi4.mods', {
22
- title: 'List available HOI4 mods',
23
- description: 'List configured HOI4 mod workspaces, optionally selecting one by ID.',
24
- inputSchema: z.object({ workspaceId: z.string().min(1).max(64).optional() }).strict(),
25
- outputSchema: modsOutputSchema,
26
- annotations: {
27
- readOnlyHint: true,
28
- destructiveHint: false,
29
- idempotentHint: true,
30
- openWorldHint: false,
31
- },
32
- }, ({ workspaceId }) => {
33
- try {
34
- const mods = (workspaceId === undefined
35
- ? engine.list(context.principal)
36
- : [engine.status(workspaceId, context.principal)])
37
- .filter(({ kind }) => kind === 'mod')
38
- .sort((left, right) => compareCodeUnits(left.id, right.id));
39
- const result = emptyServiceResult(workspaceId ?? '', {
40
- count: mods.length,
41
- mods: mods.map(({ id, name, writable }) => ({
42
- id,
43
- name,
44
- writable,
45
- })),
46
- });
47
- result.code = 'MODS_LISTED';
48
- return toolResult(result);
49
- }
50
- catch (error) {
51
- return errorResult(error, workspaceId ?? '');
52
- }
53
- });
54
- }