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
@@ -5,6 +5,7 @@ import express, {} from 'express';
5
5
  import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js';
6
6
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
7
  import { isInitializeRequest, SUPPORTED_PROTOCOL_VERSIONS, } from '@modelcontextprotocol/sdk/types.js';
8
+ import { HTTP_MAX_AGGREGATE_BODY_BYTES } from '../../core/configuration.js';
8
9
  import { canonicalJson } from '../../core/canonical.js';
9
10
  import { authenticate, bearerChallenge, HttpAuthError, protectedResourceMetadata, validateHttpDeployment, } from '../security/auth.js';
10
11
  import { BoundedEventStore, SharedEventStoreBudget } from './event-store.js';
@@ -346,18 +347,39 @@ export async function startHttpServer(engine, configuration, serverFactory) {
346
347
  const rates = new Map();
347
348
  let lastRatePruneMinute = -1;
348
349
  let concurrent = 0;
350
+ let reservedBodyBytes = 0;
349
351
  const admissionMiddleware = (request, response, next) => {
350
- if (concurrent >= http.maxConcurrentRequests) {
352
+ // Reserve a few small envelopes for cancellation, roots replies, and discovery,
353
+ // even when all long request slots are occupied.
354
+ const declaredLength = request.headers['content-length'];
355
+ const bodyBytes = request.method === 'POST'
356
+ ? declaredLength === undefined
357
+ ? http.maxBodyBytes
358
+ : Number(declaredLength)
359
+ : 0;
360
+ if (!Number.isSafeInteger(bodyBytes) || bodyBytes < 0 || bodyBytes > http.maxBodyBytes) {
361
+ jsonRpcError(response, 413, 'Payload too large');
362
+ return;
363
+ }
364
+ const overflow = concurrent >= http.maxConcurrentRequests;
365
+ if (overflow && (concurrent >= http.maxConcurrentRequests + 16 || bodyBytes > 65_536)) {
351
366
  response.status(429).setHeader('Retry-After', '1').json({ error: 'concurrency_limit' });
352
367
  return;
353
368
  }
369
+ if (bodyBytes > HTTP_MAX_AGGREGATE_BODY_BYTES - reservedBodyBytes) {
370
+ response.status(429).setHeader('Retry-After', '1').json({ error: 'body_capacity_limit' });
371
+ return;
372
+ }
354
373
  concurrent += 1;
374
+ reservedBodyBytes += bodyBytes;
375
+ response.locals.admissionOverflow = overflow;
355
376
  let released = false;
356
377
  const release = () => {
357
378
  if (released)
358
379
  return;
359
380
  released = true;
360
381
  concurrent = Math.max(0, concurrent - 1);
382
+ reservedBodyBytes -= bodyBytes;
361
383
  };
362
384
  response.once('finish', release);
363
385
  response.once('close', release);
@@ -387,6 +409,8 @@ export async function startHttpServer(engine, configuration, serverFactory) {
387
409
  }
388
410
  next();
389
411
  };
412
+ app.post('/mcp', requireMcpJsonContentType);
413
+ app.post('/mcp', requireIdentityContentEncoding);
390
414
  app.use(admissionMiddleware);
391
415
  const authenticateMiddleware = async (request, response, next) => {
392
416
  try {
@@ -427,8 +451,6 @@ export async function startHttpServer(engine, configuration, serverFactory) {
427
451
  // Supplying a defined parsed body to handleRequest prevents the SDK from falling back to
428
452
  // an unbounded Request.json() parse for alternate media types or empty established-session
429
453
  // requests. GET and DELETE deliberately remain bodyless.
430
- app.post('/mcp', requireMcpJsonContentType);
431
- app.post('/mcp', requireIdentityContentEncoding);
432
454
  app.post('/mcp', express.json({
433
455
  limit: http.maxBodyBytes,
434
456
  strict: true,
@@ -437,6 +459,24 @@ export async function startHttpServer(engine, configuration, serverFactory) {
437
459
  verify: verifyMcpUtf8,
438
460
  }));
439
461
  app.post('/mcp', requireParsedMcpJsonBody);
462
+ app.post('/mcp', (request, response, next) => {
463
+ const method = isRecord(request.body) ? request.body.method : undefined;
464
+ const control = method === undefined ||
465
+ (typeof method === 'string' &&
466
+ (method.startsWith('notifications/') ||
467
+ [
468
+ 'ping',
469
+ 'tools/list',
470
+ 'resources/list',
471
+ 'resources/templates/list',
472
+ 'prompts/list',
473
+ ].includes(method)));
474
+ if (response.locals.admissionOverflow === true && !control) {
475
+ response.status(429).setHeader('Retry-After', '1').json({ error: 'concurrency_limit' });
476
+ return;
477
+ }
478
+ next();
479
+ });
440
480
  if (http.oauth !== undefined && http.publicUrl !== undefined) {
441
481
  const metadata = protectedResourceMetadata(configuration);
442
482
  app.get('/.well-known/oauth-protected-resource', (_request, response) => response.json(metadata));
@@ -449,6 +489,8 @@ export async function startHttpServer(engine, configuration, serverFactory) {
449
489
  let eventStreams = 0;
450
490
  const eventStreamsByPrincipal = new Map();
451
491
  const sessionTtlMs = http.sessionTtlSeconds * 1000;
492
+ const sessionExpired = (session) => session.credentialExpiresAt <= Date.now() ||
493
+ (session.activeRequests === 0 && session.expiresAt <= Date.now());
452
494
  const removeSession = async (sessionId) => {
453
495
  const session = sessions.get(sessionId);
454
496
  if (session === undefined)
@@ -463,13 +505,21 @@ export async function startHttpServer(engine, configuration, serverFactory) {
463
505
  const scheduleSessionExpiry = (sessionId, session) => {
464
506
  if (session.expiryTimer !== undefined)
465
507
  clearTimeout(session.expiryTimer);
466
- session.expiryTimer = setTimeout(() => void removeSession(sessionId), Math.max(0, session.expiresAt - Date.now()));
508
+ session.expiryTimer = setTimeout(() => {
509
+ if (sessionExpired(session))
510
+ void removeSession(sessionId);
511
+ else {
512
+ session.expiresAt = Math.min(Date.now() + sessionTtlMs, session.credentialExpiresAt);
513
+ scheduleSessionExpiry(sessionId, session);
514
+ }
515
+ }, Math.max(0, session.expiresAt - Date.now()));
467
516
  session.expiryTimer.unref();
468
517
  };
469
518
  const pruneExpiredSessions = async () => {
470
519
  const now = Date.now();
471
520
  await Promise.all([...sessions]
472
- .filter(([, session]) => session.expiresAt <= now)
521
+ .filter(([, session]) => session.credentialExpiresAt <= now ||
522
+ (session.activeRequests === 0 && session.expiresAt <= now))
473
523
  .map(([sessionId]) => removeSession(sessionId)));
474
524
  };
475
525
  const findSession = async (request, response) => {
@@ -479,7 +529,7 @@ export async function startHttpServer(engine, configuration, serverFactory) {
479
529
  return undefined;
480
530
  }
481
531
  const session = sessions.get(id);
482
- if (session === undefined || session.expiresAt <= Date.now()) {
532
+ if (session === undefined || sessionExpired(session)) {
483
533
  if (session !== undefined)
484
534
  await removeSession(id);
485
535
  jsonRpcError(response, 404, 'Unknown or expired MCP session');
@@ -504,6 +554,22 @@ export async function startHttpServer(engine, configuration, serverFactory) {
504
554
  session.scopes = expandedScopes;
505
555
  session.authorizationContext.scopes = expandedScopes;
506
556
  session.expiresAt = boundedSessionExpiration(principal, Date.now(), sessionTtlMs);
557
+ session.credentialExpiresAt =
558
+ principal.expiresAt === undefined ? Infinity : principal.expiresAt * 1000;
559
+ session.activeRequests += 1;
560
+ let requestFinished = false;
561
+ const finishRequest = () => {
562
+ if (requestFinished)
563
+ return;
564
+ requestFinished = true;
565
+ session.activeRequests -= 1;
566
+ if (sessions.get(id) !== session)
567
+ return;
568
+ session.expiresAt = Math.min(Date.now() + sessionTtlMs, session.credentialExpiresAt);
569
+ scheduleSessionExpiry(id, session);
570
+ };
571
+ response.once('finish', finishRequest);
572
+ response.once('close', finishRequest);
507
573
  scheduleSessionExpiry(id, session);
508
574
  return session;
509
575
  };
@@ -569,6 +635,8 @@ export async function startHttpServer(engine, configuration, serverFactory) {
569
635
  credentialId: principal.credentialId,
570
636
  scopes: [...principal.scopes],
571
637
  authorizationContext,
638
+ activeRequests: 0,
639
+ credentialExpiresAt: principal.expiresAt === undefined ? Infinity : principal.expiresAt * 1000,
572
640
  expiresAt: Math.min(initialExpiration, boundedSessionExpiration(principal, Date.now(), sessionTtlMs)),
573
641
  };
574
642
  sessions.set(newId, session);
@@ -718,7 +786,7 @@ export async function startHttpServer(engine, configuration, serverFactory) {
718
786
  process.stderr.write(`${canonicalJson({ level: 'info', event: 'http_listening', url })}\n`);
719
787
  const cleanup = setInterval(() => {
720
788
  for (const [sessionId, session] of sessions) {
721
- if (session.expiresAt <= Date.now())
789
+ if (sessionExpired(session))
722
790
  void removeSession(sessionId);
723
791
  }
724
792
  }, Math.min(60_000, sessionTtlMs));
@@ -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 = '3.0.7';
3
+ export const PACKAGE_VERSION = '3.0.8';
4
4
  export const MCP_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
5
5
  export const CONFIG_VERSION = 1;
6
6
  export const TRANSACTION_VERSION = 1;
@@ -23,6 +23,8 @@ The event-chain acceptance fixture contains more than 300 project-owned event de
23
23
 
24
24
  The technology acceptance fixture contains 1,040 project-owned technologies across 13 folders and exercises classic and current doctrines, prerequisites, exclusive branches, multiple placements, unlocks, bonuses, grants, assets, unresolved references, rendering, and comparison.
25
25
 
26
+ `npx vitest run tests/integration/mcp-concurrent-workloads.test.ts` tests simultaneous production calls across all six domains over six HTTP sessions and four independent stdio processes, including artifact retrieval and queued cancellation. Execution limits and connection behavior are described in [HTTP](http.md) and the [concurrency decision](https://github.com/klimPaskov/hoi4-agent-tools/blob/main/docs/adr/0026-concurrent-request-execution.md).
27
+
26
28
  The probability fixture contains more than 150 weighted blocks, 250 scenarios, and a declared stateful pool. Run `npm run benchmark` after analyzer changes. On the July 22, 2026 reference run, cold indexing took 28 ms, a cached block evaluation 29 ms, the 250-row matrix 85 ms, one million draws 9 ms, 3/12/25-candidate sequence cases 20/2,079/356 ms, and retrieval of a 1.84 MB result 3 ms. Review budgets are 250 ms, 150 ms, 750 ms, 100 ms, 200/10,000/3,000 ms, and 100 ms respectively; accuracy, method metadata, and omitted probability must not be weakened to meet them.
27
29
 
28
30
  Local opt-in tests can also read an installed game and an external mod without copying their sources:
package/docs/http.md CHANGED
@@ -4,7 +4,11 @@ Local MCP clients should use the default stdio registration. Use `hoi4-agent-too
4
4
 
5
5
  The HTTP server exposes the MCP endpoint at `/mcp`.
6
6
 
7
- JSON request bodies default to and are capped at 64 MiB. This leaves transport-envelope headroom for the bounded 16 MiB GUI text package and a maximum canonical map mask while the two-request concurrency ceiling keeps aggregate body bytes at or below 128 MiB.
7
+ The server accepts up to 128 simultaneous HTTP requests by default and queues expensive tool work automatically. Cancellation, discovery, and protocol replies retain separate admission headroom. Active requests keep their sessions alive; credential expiry still applies.
8
+
9
+ JSON request bodies default to and are capped at 64 MiB, with a shared 128 MiB budget based on declared request sizes. Chunked bodies reserve their configured maximum. These byte reservations are independent of the number of accepted requests.
10
+
11
+ `maxConcurrentTools` controls expensive operations per server engine (default 2), and `maxSharedTools` controls operations across local processes sharing the same `serverStateRoot` (default 4). Calls waiting for capacity remain cancellable and emit progress when requested. Increase execution capacity only when the host has enough memory for the corresponding scans and renders. HTTP admission, connection, session, and rate limits remain separately configurable.
8
12
 
9
13
  Resumable event history defaults to 2 MiB per session within the 16 MiB global store, enough to retain one framed 1 MiB artifact-resource chunk.
10
14
 
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": "3.0.7",
4
+ "version": "3.0.8",
5
5
  "description": "Analyze HOI4 events, tech trees, AI weights, and MTTH; build focus trees, GUIs, and maps.",
6
6
  "type": "module",
7
7
  "license": "Apache-2.0",
@@ -130,7 +130,8 @@
130
130
  "overrides": {
131
131
  "@hono/node-server": "2.1.0",
132
132
  "brace-expansion": "5.0.9",
133
- "fast-uri": "3.1.5",
133
+ "fast-uri": "3.1.7",
134
+ "qs": "6.16.0",
134
135
  "hono": "4.13.1",
135
136
  "nanoid": "3.3.18",
136
137
  "ip-address": "10.4.0",