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.
- package/CHANGELOG.md +14 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +21 -1
- package/dist/hoi4_agent_tools/core/configuration.js +14 -28
- package/dist/hoi4_agent_tools/core/engine.js +7 -1
- package/dist/hoi4_agent_tools/core/index.js +25 -0
- package/dist/hoi4_agent_tools/core/request-scheduler.js +79 -0
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +108 -0
- package/dist/hoi4_agent_tools/gui/studio.js +4 -1
- package/dist/hoi4_agent_tools/mcp/server/create.js +3 -1
- package/dist/hoi4_agent_tools/mcp/server/progress.js +29 -8
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +14 -0
- package/dist/hoi4_agent_tools/mcp/transports/bounded-stdio.js +44 -14
- package/dist/hoi4_agent_tools/mcp/transports/http.js +75 -7
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/development.md +2 -0
- package/docs/http.md +5 -1
- package/package.json +3 -2
- package/schemas/configuration.schema.json +151 -133
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- 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
|
-
|
|
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(() =>
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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;
|
package/docs/development.md
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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",
|