hoi4-agent-tools 3.0.7 → 3.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/hoi4_agent_tools/core/artifacts.js +21 -1
  3. package/dist/hoi4_agent_tools/core/configuration.js +14 -28
  4. package/dist/hoi4_agent_tools/core/engine.js +7 -1
  5. package/dist/hoi4_agent_tools/core/index.js +25 -0
  6. package/dist/hoi4_agent_tools/core/request-scheduler.js +79 -0
  7. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +108 -0
  8. package/dist/hoi4_agent_tools/gui/studio.js +4 -1
  9. package/dist/hoi4_agent_tools/mcp/server/create.js +3 -1
  10. package/dist/hoi4_agent_tools/mcp/server/progress.js +29 -8
  11. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +14 -0
  12. package/dist/hoi4_agent_tools/mcp/transports/bounded-stdio.js +44 -14
  13. package/dist/hoi4_agent_tools/mcp/transports/http.js +75 -7
  14. package/dist/hoi4_agent_tools/version.js +1 -1
  15. package/docs/development.md +2 -0
  16. package/docs/http.md +5 -1
  17. package/package.json +3 -2
  18. package/schemas/configuration.schema.json +151 -133
  19. package/schemas/continuous-focus-palette.schema.json +1 -1
  20. package/schemas/custom-weighted-pool.schema.json +1 -1
  21. package/schemas/event-feature-manifest.schema.json +1 -1
  22. package/schemas/focus-plan.schema.json +1 -1
  23. package/schemas/focus-planning-sidecar.schema.json +1 -1
  24. package/schemas/gui-animation-source.schema.json +1 -1
  25. package/schemas/gui-helper.schema.json +1 -1
  26. package/schemas/gui-scenario.schema.json +1 -1
  27. package/schemas/map-operation.schema.json +1 -1
  28. package/schemas/operation-result.schema.json +1 -1
  29. package/schemas/probability-analysis-result.schema.json +1 -1
  30. package/schemas/probability-compare-input.schema.json +1 -1
  31. package/schemas/probability-evaluate-input.schema.json +1 -1
  32. package/schemas/probability-inspect-input.schema.json +1 -1
  33. package/schemas/probability-render-input.schema.json +1 -1
  34. package/schemas/probability-scenario-set.schema.json +1 -1
  35. package/schemas/probability-sequence-input.schema.json +1 -1
  36. package/schemas/probability-simulate-input.schema.json +1 -1
  37. package/schemas/probability-sweep-input.schema.json +1 -1
  38. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@
4
4
 
5
5
  No unreleased changes.
6
6
 
7
+ ## 3.0.8 - 2026-09-05
8
+
9
+ - Queue simultaneous tool calls fairly across HTTP sessions and coordinate expensive operations across local task processes sharing private server state.
10
+ - Rotate across every waiting session and canonicalize configured storage aliases, including Windows short-name paths, before acquiring shared execution capacity.
11
+ - Keep every tool call responsive with strictly increasing MCP progress notifications during execution and queue waits; allow cancellation and discovery while tool capacity is occupied.
12
+ - Separate HTTP request admission and body-byte reservations from expensive execution capacity, replacing the two-request ceiling with defaults suitable for parallel agents.
13
+ - Keep active HTTP sessions alive, yield during shared source indexing, and serialize backpressured stdio output without leaking listeners or leaving sends pending after a disconnect.
14
+ - Share cached GUI source graphs across sessions using the same engine instead of retaining a separate graph per client.
15
+ - Reverify artifacts after metadata changes caused by concurrent publication-link cleanup, avoiding false hash collisions while still rejecting altered bytes.
16
+ - Serialize artifact admission, publication, and failed-batch cleanup across processes so one writer cannot remove evidence committed by another.
17
+ - Add concurrent production workflows spanning focus, GUI, map, event, technology, and probability tools over both transports, including shared artifacts, session expiry, cancelled queue entries, and abandoned process recovery.
18
+ - Qualify each map layer separately from semantic and pixel diffs so failures and deadlines identify the affected operation rather than an aggregate rendering loop.
19
+ - Update `fast-uri` and `qs` to patched releases identified by the dependency audit.
20
+
7
21
  ## 3.0.7 - 2026-09-01
8
22
 
9
23
  - Reclaim detached parser, graph, and render memory after the idle cache window so parallel coding-agent tasks do not retain large V8 heaps and trigger `Transport closed` through host memory exhaustion. Idle cleanup is coordinated across every MCP domain and never compacts during an active tool call.
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { z } from 'zod/v4';
5
5
  import { canonicalJson, compareCodeUnits, sha256Bytes } from './canonical.js';
6
6
  import { ServiceError } from './result.js';
7
+ import { SharedRequestCapacity } from './shared-request-capacity.js';
7
8
  import { containedGeneratedPath, isPortablePathSegment } from './workspace.js';
8
9
  const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u);
9
10
  const artifactProvenanceSchema = z
@@ -329,7 +330,9 @@ async function withArtifactQueue(root, action, signal) {
329
330
  artifactQueues.set(root, tail);
330
331
  try {
331
332
  await waitForArtifactTurn(previous, signal);
332
- return await action();
333
+ // Admission, publication, and rollback must be one critical section across
334
+ // processes too: another writer may reuse the same immutable address.
335
+ return await new SharedRequestCapacity(root, 1).run(signal ?? new AbortController().signal, action);
333
336
  }
334
337
  finally {
335
338
  release();
@@ -607,6 +610,23 @@ async function publishExclusiveFile(target, bytes, signal) {
607
610
  }
608
611
  }
609
612
  async function readVerifiedArtifactRange(filePath, expectedSha256, range, verifiedIdentity, signal) {
613
+ for (let attempt = 0;; attempt += 1) {
614
+ try {
615
+ return await readArtifactRangeOnce(filePath, expectedSha256, range, attempt === 0 ? verifiedIdentity : undefined, signal);
616
+ }
617
+ catch (error) {
618
+ if (attempt >= 2 ||
619
+ !(error instanceof ServiceError) ||
620
+ error.code !== 'ARTIFACT_READ_INCOMPLETE')
621
+ throw error;
622
+ // Removing the temporary hard link after atomic publication changes ctime
623
+ // without changing content. Retry from a fresh handle and hash all bytes;
624
+ // never reuse cached verification after an identity change.
625
+ await new Promise((resolve) => setImmediate(resolve));
626
+ }
627
+ }
628
+ }
629
+ async function readArtifactRangeOnce(filePath, expectedSha256, range, verifiedIdentity, signal) {
610
630
  signal?.throwIfAborted();
611
631
  if (range !== undefined &&
612
632
  (!Number.isSafeInteger(range.offset) ||
@@ -3,7 +3,8 @@ import path from 'node:path';
3
3
  import { z } from 'zod/v4';
4
4
  import { CONFIG_VERSION } from '../version.js';
5
5
  import { ServiceError } from './result.js';
6
- export const HTTP_MAX_SAFE_CONCURRENT_REQUESTS = 2;
6
+ export const HTTP_MAX_SAFE_CONCURRENT_REQUESTS = 1024;
7
+ export const HTTP_DEFAULT_CONCURRENT_REQUESTS = 128;
7
8
  export const HTTP_MAX_BODY_BYTES = 67_108_864;
8
9
  export const HTTP_MAX_AGGREGATE_BODY_BYTES = 134_217_728;
9
10
  export const HTTP_DEFAULT_SESSION_EVENT_BYTES = 2_097_152;
@@ -132,6 +133,8 @@ export const serverConfigurationSchema = z
132
133
  })
133
134
  .optional(),
134
135
  scanMaxFiles: z.number().int().min(1).max(1_000_000).default(DEFAULT_SCAN_MAX_FILES),
136
+ maxConcurrentTools: z.number().int().min(1).max(32).default(2),
137
+ maxSharedTools: z.number().int().min(1).max(128).default(4),
135
138
  scanMaxBytes: z
136
139
  .number()
137
140
  .int()
@@ -193,18 +196,18 @@ export const serverConfigurationSchema = z
193
196
  headersTimeoutMs: z.number().int().min(1_000).max(120_000).default(10_000),
194
197
  requestTimeoutMs: z.number().int().min(1_000).max(300_000).default(30_000),
195
198
  keepAliveTimeoutMs: z.number().int().min(1_000).max(120_000).default(5_000),
196
- maxConnections: z.number().int().min(1).max(100_000).default(64),
199
+ maxConnections: z.number().int().min(1).max(100_000).default(512),
197
200
  maxRequestsPerSocket: z.number().int().min(1).max(100_000).default(100),
198
201
  maxConcurrentRequests: z
199
202
  .number()
200
203
  .int()
201
204
  .min(1)
202
205
  .max(HTTP_MAX_SAFE_CONCURRENT_REQUESTS)
203
- .default(HTTP_MAX_SAFE_CONCURRENT_REQUESTS),
206
+ .default(HTTP_DEFAULT_CONCURRENT_REQUESTS),
204
207
  maxSessions: z.number().int().min(1).max(10_000).default(128),
205
208
  maxSessionsPerPrincipal: z.number().int().min(1).max(10_000).default(32),
206
- maxEventStreams: z.number().int().min(1).max(10_000).default(32),
207
- maxEventStreamsPerPrincipal: z.number().int().min(1).max(10_000).default(4),
209
+ maxEventStreams: z.number().int().min(1).max(10_000).default(128),
210
+ maxEventStreamsPerPrincipal: z.number().int().min(1).max(10_000).default(32),
208
211
  maxSessionEventBytes: z
209
212
  .number()
210
213
  .int()
@@ -212,7 +215,7 @@ export const serverConfigurationSchema = z
212
215
  .max(67_108_864)
213
216
  .default(HTTP_DEFAULT_SESSION_EVENT_BYTES),
214
217
  maxEventStoreBytes: z.number().int().min(65_536).max(268_435_456).default(16_777_216),
215
- requestsPerMinute: z.number().int().min(1).max(100_000).default(120),
218
+ requestsPerMinute: z.number().int().min(1).max(100_000).default(6_000),
216
219
  sessionTtlSeconds: z.number().int().min(60).max(86_400).default(3600),
217
220
  })
218
221
  .strict()
@@ -274,16 +277,16 @@ export const serverConfigurationSchema = z
274
277
  headersTimeoutMs: 10_000,
275
278
  requestTimeoutMs: 30_000,
276
279
  keepAliveTimeoutMs: 5_000,
277
- maxConnections: 64,
280
+ maxConnections: 512,
278
281
  maxRequestsPerSocket: 100,
279
- maxConcurrentRequests: HTTP_MAX_SAFE_CONCURRENT_REQUESTS,
282
+ maxConcurrentRequests: HTTP_DEFAULT_CONCURRENT_REQUESTS,
280
283
  maxSessions: 128,
281
284
  maxSessionsPerPrincipal: 32,
282
- maxEventStreams: 32,
283
- maxEventStreamsPerPrincipal: 4,
285
+ maxEventStreams: 128,
286
+ maxEventStreamsPerPrincipal: 32,
284
287
  maxSessionEventBytes: HTTP_DEFAULT_SESSION_EVENT_BYTES,
285
288
  maxEventStoreBytes: 16_777_216,
286
- requestsPerMinute: 120,
289
+ requestsPerMinute: 6_000,
287
290
  sessionTtlSeconds: 3600,
288
291
  }),
289
292
  })
@@ -332,23 +335,6 @@ export const serverConfigurationSchema = z
332
335
  message: 'HTTP keep-alive timeout must be shorter than the header timeout',
333
336
  });
334
337
  }
335
- const concurrentScanBytes = value.scanMaxBytes * value.http.maxConcurrentRequests;
336
- if (!Number.isSafeInteger(concurrentScanBytes) || concurrentScanBytes > 536_870_912) {
337
- context.addIssue({
338
- code: 'custom',
339
- path: ['scanMaxBytes'],
340
- message: 'Scan-byte ceiling multiplied by HTTP concurrency must not exceed 512 MiB',
341
- });
342
- }
343
- const concurrentBodyBytes = value.http.maxBodyBytes * value.http.maxConcurrentRequests;
344
- if (!Number.isSafeInteger(concurrentBodyBytes) ||
345
- concurrentBodyBytes > HTTP_MAX_AGGREGATE_BODY_BYTES) {
346
- context.addIssue({
347
- code: 'custom',
348
- path: ['http', 'maxBodyBytes'],
349
- message: 'HTTP body-byte ceiling multiplied by concurrency must not exceed 128 MiB',
350
- });
351
- }
352
338
  });
353
339
  export async function loadConfiguration(filePath) {
354
340
  const absolute = path.resolve(filePath);
@@ -3,6 +3,8 @@ import { SymbolIndex } from './index.js';
3
3
  import { WorkspaceScanner } from './scanner.js';
4
4
  import { ArtifactStore } from './artifacts.js';
5
5
  import { TransactionManager } from './transactions.js';
6
+ import { RequestScheduler } from './request-scheduler.js';
7
+ import { SharedRequestCapacity } from './shared-request-capacity.js';
6
8
  const RECOVERY_TTL_SECONDS = 3_600;
7
9
  const RECOVERY_MAX_BYTES = 536_870_912;
8
10
  const RECOVERY_MAX_RECORDS = 128;
@@ -74,6 +76,8 @@ function defaultPatterns(workspace) {
74
76
  }
75
77
  export class CoreEngine {
76
78
  resolver;
79
+ requests;
80
+ sharedRequests;
77
81
  scanner;
78
82
  artifacts;
79
83
  transactions;
@@ -84,6 +88,8 @@ export class CoreEngine {
84
88
  #activeScanAdmissions = new Set();
85
89
  constructor(resolver, services = {}) {
86
90
  this.resolver = resolver;
91
+ this.requests = new RequestScheduler(resolver.config().maxConcurrentTools);
92
+ this.sharedRequests = new SharedRequestCapacity(resolver.serverState()?.root, resolver.config().maxSharedTools);
87
93
  this.scanner =
88
94
  services.scanner ??
89
95
  new WorkspaceScanner(resolver.config().scanMaxFiles, resolver.config().scanMaxBytes, resolver.config().scanMaxFileBytes);
@@ -159,7 +165,7 @@ export class CoreEngine {
159
165
  const cached = this.#scanCache.get(cacheKey);
160
166
  if (cached !== undefined)
161
167
  return cached;
162
- const index = SymbolIndex.build(files);
168
+ const index = await SymbolIndex.buildAsync(files, controller.signal);
163
169
  const snapshot = {
164
170
  workspaceId,
165
171
  revision,
@@ -284,6 +284,31 @@ export class SymbolIndex {
284
284
  index.finalize();
285
285
  return index;
286
286
  }
287
+ /** Yield between source files so protocol cancellation and progress remain responsive. */
288
+ static async buildAsync(files, signal) {
289
+ signal?.throwIfAborted();
290
+ const index = new SymbolIndex();
291
+ const definitionSelection = selectedDefinitionFiles(files);
292
+ for (const displayPath of definitionSelection.selected)
293
+ index.#definitionFiles.add(displayPath);
294
+ for (const skipped of definitionSelection.skipped) {
295
+ index.recordSkippedSource(skipped.file, skipped.diagnostics);
296
+ }
297
+ let yieldedAt = performance.now();
298
+ for (const file of [...files].sort((a, b) => a.loadOrder - b.loadOrder || compareCodeUnits(a.displayPath, b.displayPath))) {
299
+ signal?.throwIfAborted();
300
+ index.files.set(file.displayPath, file);
301
+ index.indexFile(file);
302
+ if (performance.now() - yieldedAt >= 20) {
303
+ await new Promise((resolve) => setImmediate(resolve));
304
+ signal?.throwIfAborted();
305
+ yieldedAt = performance.now();
306
+ }
307
+ }
308
+ index.finalize();
309
+ signal?.throwIfAborted();
310
+ return index;
311
+ }
287
312
  get complete() {
288
313
  return this.#complete;
289
314
  }
@@ -0,0 +1,79 @@
1
+ import { ServiceError } from './result.js';
2
+ /** Shares execution capacity across all sessions using an engine, with fair, cancellable waits. */
3
+ export class RequestScheduler {
4
+ capacity;
5
+ maxPending;
6
+ maxPendingBytes;
7
+ active = 0;
8
+ pendingBytes = 0;
9
+ pending = [];
10
+ lastOwner;
11
+ constructor(capacity = 2, maxPending = 1024, maxPendingBytes = 134_217_728) {
12
+ this.capacity = capacity;
13
+ this.maxPending = maxPending;
14
+ this.maxPendingBytes = maxPendingBytes;
15
+ if (!Number.isSafeInteger(capacity) || capacity < 1) {
16
+ throw new RangeError('Execution capacity must be a positive integer');
17
+ }
18
+ }
19
+ async run(owner, bytes, signal, action) {
20
+ signal.throwIfAborted();
21
+ await new Promise((resolve, reject) => {
22
+ if (this.pending.length >= this.maxPending ||
23
+ bytes > this.maxPendingBytes - this.pendingBytes) {
24
+ reject(new ServiceError('REQUEST_QUEUE_FULL', 'The server request queue is full; retry after current work completes'));
25
+ return;
26
+ }
27
+ const entry = {
28
+ owner,
29
+ bytes,
30
+ start: () => {
31
+ signal.removeEventListener('abort', entry.cancel);
32
+ this.active += 1;
33
+ this.lastOwner = owner;
34
+ resolve();
35
+ },
36
+ cancel: () => {
37
+ const index = this.pending.indexOf(entry);
38
+ if (index < 0)
39
+ return;
40
+ this.pending.splice(index, 1);
41
+ this.pendingBytes -= bytes;
42
+ signal.removeEventListener('abort', entry.cancel);
43
+ reject(signal.reason instanceof Error
44
+ ? signal.reason
45
+ : new DOMException('Request cancelled', 'AbortError'));
46
+ this.drain();
47
+ },
48
+ };
49
+ signal.addEventListener('abort', entry.cancel, { once: true });
50
+ this.pending.push(entry);
51
+ this.pendingBytes += bytes;
52
+ this.drain();
53
+ });
54
+ try {
55
+ signal.throwIfAborted();
56
+ return await action();
57
+ }
58
+ finally {
59
+ this.active -= 1;
60
+ this.drain();
61
+ }
62
+ }
63
+ drain() {
64
+ while (this.active < this.capacity && this.pending.length > 0) {
65
+ const other = this.pending.findIndex(({ owner }) => owner !== this.lastOwner);
66
+ const entry = this.pending.splice(Math.max(0, other), 1)[0];
67
+ // Move this session's remaining work behind every other waiting session.
68
+ // Merely avoiding the last owner can alternate two busy sessions forever.
69
+ const sameOwner = [];
70
+ const otherOwners = [];
71
+ for (const pending of this.pending) {
72
+ (pending.owner === entry.owner ? sameOwner : otherOwners).push(pending);
73
+ }
74
+ this.pending.splice(0, this.pending.length, ...otherOwners, ...sameOwner);
75
+ this.pendingBytes -= entry.bytes;
76
+ entry.start();
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,108 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { hostname } from 'node:os';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { sha256Bytes } from './canonical.js';
6
+ import { canonicalPath, containedGeneratedPath } from './workspace.js';
7
+ function processAlive(pid) {
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ }
12
+ catch (error) {
13
+ return error.code !== 'ESRCH';
14
+ }
15
+ }
16
+ /** Bounds heavy work across task processes sharing the same private server state. */
17
+ export class SharedRequestCapacity {
18
+ stateRoot;
19
+ capacity;
20
+ constructor(stateRoot, capacity = 4) {
21
+ this.stateRoot = stateRoot;
22
+ this.capacity = capacity;
23
+ }
24
+ async run(signal, action) {
25
+ signal.throwIfAborted();
26
+ if (this.stateRoot === undefined)
27
+ return action();
28
+ const root = await containedGeneratedPath(await canonicalPath(this.stateRoot, signal), 'request-capacity', sha256Bytes(hostname().toLowerCase()).slice(0, 16));
29
+ await mkdir(root, { recursive: true });
30
+ const lease = `${process.pid}-${randomUUID()}.lease`;
31
+ for (;;) {
32
+ signal.throwIfAborted();
33
+ for (let index = 0; index < this.capacity; index += 1) {
34
+ let slot;
35
+ try {
36
+ slot = await containedGeneratedPath(root, String(index));
37
+ }
38
+ catch (error) {
39
+ // Another process can finish a slot while canonical containment is checked.
40
+ if (error.code === 'ENOENT')
41
+ continue;
42
+ throw error;
43
+ }
44
+ try {
45
+ await mkdir(slot);
46
+ }
47
+ catch (error) {
48
+ if (error.code !== 'EEXIST')
49
+ throw error;
50
+ await this.reap(slot);
51
+ continue;
52
+ }
53
+ let owner;
54
+ try {
55
+ owner = await containedGeneratedPath(slot, lease);
56
+ await writeFile(owner, '', { flag: 'wx', mode: 0o600 });
57
+ }
58
+ catch (error) {
59
+ // A competing stale-owner cleanup can remove an empty slot before our
60
+ // owner file is published. No work starts until the owner exists.
61
+ if (error.code === 'ENOENT')
62
+ continue;
63
+ await rmdir(slot).catch(() => undefined);
64
+ throw error;
65
+ }
66
+ try {
67
+ signal.throwIfAborted();
68
+ return await action();
69
+ }
70
+ finally {
71
+ await unlink(owner);
72
+ await rmdir(slot).catch((error) => {
73
+ const code = error.code;
74
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY')
75
+ throw error;
76
+ });
77
+ }
78
+ }
79
+ await delay(100, undefined, { signal });
80
+ }
81
+ }
82
+ async reap(slot) {
83
+ try {
84
+ const owners = await readdir(slot);
85
+ if (owners.length === 0) {
86
+ if (Date.now() - (await stat(slot)).mtimeMs < 30_000)
87
+ return;
88
+ }
89
+ else {
90
+ for (const name of owners) {
91
+ const match = /^([1-9][0-9]*)-[0-9a-f-]{36}\.lease$/u.exec(name);
92
+ if (match === null || processAlive(Number(match[1])))
93
+ return;
94
+ // Unique owner names prevent a concurrent reaper from deleting a new lease.
95
+ await unlink(await containedGeneratedPath(slot, name)).catch((error) => {
96
+ if (error.code !== 'ENOENT')
97
+ throw error;
98
+ });
99
+ }
100
+ }
101
+ await rmdir(slot);
102
+ }
103
+ catch (error) {
104
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code ?? ''))
105
+ throw error;
106
+ }
107
+ }
108
+ }
@@ -672,13 +672,14 @@ export function guiArtifactProvenance(graph, kind, primaryScene, scenarioScenes
672
672
  },
673
673
  };
674
674
  }
675
+ const sharedGuiGraphs = new WeakMap();
675
676
  export class ScriptedGuiStudio {
676
677
  engine;
677
678
  resolver;
678
679
  transactions;
679
680
  scanner;
680
681
  artifacts;
681
- #graphCache = new Map();
682
+ #graphCache;
682
683
  constructor(engineOrResolver, transactions, scanner = new WorkspaceScanner(), artifacts = new ArtifactStore()) {
683
684
  this.engine =
684
685
  engineOrResolver instanceof CoreEngine
@@ -689,6 +690,8 @@ export class ScriptedGuiStudio {
689
690
  ...(transactions === undefined ? {} : { transactions }),
690
691
  });
691
692
  this.resolver = this.engine.resolver;
693
+ this.#graphCache = sharedGuiGraphs.get(this.engine) ?? new Map();
694
+ sharedGuiGraphs.set(this.engine, this.#graphCache);
692
695
  this.transactions = this.engine.transactions;
693
696
  this.scanner = this.engine.scanner;
694
697
  this.artifacts = this.engine.artifacts;
@@ -8,7 +8,8 @@ import { registerEventTools } from '../tools/event.js';
8
8
  import { registerTechnologyTools } from '../tools/technology.js';
9
9
  import { registerProbabilityTools } from '../tools/probability.js';
10
10
  import { registerMcpResources } from '../resources/register.js';
11
- export const SERVER_INSTRUCTIONS = 'Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Long GUI calls emit periodic progress heartbeats when the client requests progress. Large evidence is linked as resources.';
11
+ import { installRequestLifecycle } from './request-lifecycle.js';
12
+ export const SERVER_INSTRUCTIONS = 'Use focus tools for focus trees, GUI tools for interfaces, and map tools for map data. Start unfamiliar event chains with hoi4.event_inspect. Event tools are read-only. Start technology and doctrine work with hoi4.tech_inspect. Technology tools are read-only. Start weighted AI, MTTH, random, and declared-pool analysis with hoi4.probability_inspect. Probability tools are read-only. Concurrent calls queue automatically and emit periodic progress when the client requests it. Large evidence is linked as resources.';
12
13
  export function createMcpServer(engine, context = {}) {
13
14
  const server = new McpServer({
14
15
  name: PACKAGE_NAME,
@@ -18,6 +19,7 @@ export function createMcpServer(engine, context = {}) {
18
19
  capabilities: { logging: {} },
19
20
  instructions: SERVER_INSTRUCTIONS,
20
21
  });
22
+ installRequestLifecycle(server, engine);
21
23
  const serverContext = {
22
24
  ...context,
23
25
  resolveCurrentWorkspaceId: async (signal) => {
@@ -3,11 +3,16 @@
3
3
  * common MCP clients, while still keeping notification traffic negligible for ordinary calls.
4
4
  */
5
5
  export const PROGRESS_HEARTBEAT_INTERVAL_MS = 10_000;
6
+ const reporters = new WeakMap();
7
+ const heartbeatOwners = new WeakSet();
6
8
  export function progressReporter(extra) {
9
+ const existing = reporters.get(extra);
10
+ if (existing !== undefined)
11
+ return existing;
7
12
  let latest = Number.NEGATIVE_INFINITY;
8
13
  let latestTotal = 1;
9
- let latestMessage = '';
10
- return {
14
+ let latestMessage = 'Waiting for server execution capacity';
15
+ const reporter = {
11
16
  signal: extra.signal,
12
17
  async report(progress, total, message) {
13
18
  extra.signal.throwIfAborted();
@@ -20,27 +25,38 @@ export function progressReporter(extra) {
20
25
  if (normalized <= latest)
21
26
  return;
22
27
  latest = normalized;
23
- await extra.sendNotification({
28
+ await extra
29
+ .sendNotification({
24
30
  method: 'notifications/progress',
25
31
  params: { progressToken, progress: normalized, total, message },
26
- });
32
+ })
33
+ .catch(() => undefined);
27
34
  },
28
35
  async pulse(message = latestMessage) {
29
36
  extra.signal.throwIfAborted();
30
37
  const progressToken = extra._meta?.progressToken;
31
38
  if (progressToken === undefined)
32
39
  return;
33
- await extra.sendNotification({
40
+ // Use the next representably larger value rather than repeating a value, which
41
+ // violates MCP progress ordering. These pulses do not invent completed work.
42
+ latest = Number.isFinite(latest)
43
+ ? latest + Math.max(1, Math.abs(latest)) * Number.EPSILON * 2
44
+ : 0;
45
+ await extra
46
+ .sendNotification({
34
47
  method: 'notifications/progress',
35
48
  params: {
36
49
  progressToken,
37
- progress: Number.isFinite(latest) ? latest : 0,
38
- total: latestTotal,
50
+ progress: latest,
51
+ ...(latest <= latestTotal ? { total: latestTotal } : {}),
39
52
  message,
40
53
  },
41
- });
54
+ })
55
+ .catch(() => undefined);
42
56
  },
43
57
  };
58
+ reporters.set(extra, reporter);
59
+ return reporter;
44
60
  }
45
61
  /**
46
62
  * Keep an MCP request alive while a domain service is in a long, opaque asynchronous stage.
@@ -52,6 +68,10 @@ export async function withProgressHeartbeat(operation, reporter, message, interv
52
68
  throw new RangeError('Progress heartbeat interval must be a positive finite number');
53
69
  }
54
70
  reporter.signal.throwIfAborted();
71
+ if (heartbeatOwners.has(reporter)) {
72
+ return typeof operation === 'function' ? operation() : operation;
73
+ }
74
+ heartbeatOwners.add(reporter);
55
75
  let active = true;
56
76
  let inFlight = false;
57
77
  const tick = () => {
@@ -76,5 +96,6 @@ export async function withProgressHeartbeat(operation, reporter, message, interv
76
96
  finally {
77
97
  active = false;
78
98
  clearInterval(timer);
99
+ heartbeatOwners.delete(reporter);
79
100
  }
80
101
  }
@@ -0,0 +1,14 @@
1
+ import { progressReporter, withProgressHeartbeat } from './progress.js';
2
+ /** Wrap the public SDK handler registration so optional tools receive the same lifecycle. */
3
+ export function installRequestLifecycle(server, engine) {
4
+ const register = server.server.setRequestHandler.bind(server.server);
5
+ const owner = {};
6
+ server.server.setRequestHandler = (schema, handler) => {
7
+ register(schema, (request, extra) => {
8
+ if (request.method !== 'tools/call')
9
+ return handler(request, extra);
10
+ const progress = progressReporter(extra);
11
+ return withProgressHeartbeat(() => engine.requests.run(owner, Buffer.byteLength(JSON.stringify(request)), extra.signal, () => engine.sharedRequests.run(extra.signal, async () => handler(request, extra))), progress);
12
+ });
13
+ };
14
+ }
@@ -49,6 +49,9 @@ export class BoundedStdioServerTransport {
49
49
  frameBytes = 0;
50
50
  started = false;
51
51
  closed = false;
52
+ writing = false;
53
+ queuedBytes = 0;
54
+ writes = [];
52
55
  constructor(options = {}) {
53
56
  this.input = options.stdin ?? process.stdin;
54
57
  this.output = options.stdout ?? process.stdout;
@@ -149,8 +152,13 @@ export class BoundedStdioServerTransport {
149
152
  this.input.off('error', this.handleInputError);
150
153
  this.input.off('end', this.handleInputEnd);
151
154
  this.input.off('close', this.handleInputClose);
152
- this.output.off('error', this.handleOutputError);
155
+ if (!this.writing)
156
+ this.output.off('error', this.handleOutputError);
153
157
  this.output.off('close', this.handleOutputClose);
158
+ const error = new Error('Stdio transport is closed');
159
+ for (const write of this.writes.splice(0))
160
+ write.reject(error);
161
+ this.queuedBytes = 0;
154
162
  if (destroyInput)
155
163
  this.input.destroy();
156
164
  else if (this.input.listenerCount('data') === 0)
@@ -179,22 +187,44 @@ export class BoundedStdioServerTransport {
179
187
  send(message) {
180
188
  if (this.closed)
181
189
  return Promise.reject(new Error('Stdio transport is closed'));
190
+ const data = serializeMessage(message);
191
+ const bytes = Buffer.byteLength(data);
192
+ if (bytes > 134_217_728 - this.queuedBytes) {
193
+ return Promise.reject(new Error('Stdio output queue is full'));
194
+ }
182
195
  return new Promise((resolve, reject) => {
183
- const onError = (error) => {
184
- this.output.off('error', onError);
185
- reject(error);
186
- };
187
- this.output.once('error', onError);
188
- const written = this.output.write(serializeMessage(message));
189
- if (written) {
190
- this.output.off('error', onError);
191
- resolve();
196
+ this.queuedBytes += bytes;
197
+ this.writes.push({ data, bytes, resolve, reject });
198
+ this.flushWrites();
199
+ });
200
+ }
201
+ flushWrites() {
202
+ if (this.writing || this.closed)
203
+ return;
204
+ const write = this.writes[0];
205
+ if (write === undefined)
206
+ return;
207
+ this.writing = true;
208
+ this.output.write(write.data, (error) => {
209
+ this.writing = false;
210
+ if (this.closed) {
211
+ // A stream error is emitted after its write callback. Keep the listener through
212
+ // that turn so closing an output with buffered writes cannot crash the process.
213
+ setImmediate(() => this.output.off('error', this.handleOutputError));
214
+ return;
215
+ }
216
+ this.writes.shift();
217
+ this.queuedBytes -= write.bytes;
218
+ if (error != null) {
219
+ write.reject(error);
220
+ this.writing = true;
221
+ this.finish(true);
222
+ this.writing = false;
223
+ setImmediate(() => this.output.off('error', this.handleOutputError));
192
224
  }
193
225
  else {
194
- this.output.once('drain', () => {
195
- this.output.off('error', onError);
196
- resolve();
197
- });
226
+ write.resolve();
227
+ this.flushWrites();
198
228
  }
199
229
  });
200
230
  }