hoi4-agent-tools 3.0.7 → 3.0.9

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 (48) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/hoi4_agent_tools/core/artifacts.js +21 -1
  3. package/dist/hoi4_agent_tools/core/condition-evaluator.js +399 -0
  4. package/dist/hoi4_agent_tools/core/condition-model.js +1 -0
  5. package/dist/hoi4_agent_tools/core/condition-schema.js +42 -0
  6. package/dist/hoi4_agent_tools/core/configuration.js +14 -28
  7. package/dist/hoi4_agent_tools/core/engine.js +7 -1
  8. package/dist/hoi4_agent_tools/core/index.js +25 -0
  9. package/dist/hoi4_agent_tools/core/request-scheduler.js +79 -0
  10. package/dist/hoi4_agent_tools/core/scenario-state.js +97 -0
  11. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +125 -0
  12. package/dist/hoi4_agent_tools/gui/scenario-conditions.js +119 -0
  13. package/dist/hoi4_agent_tools/gui/scenario-generator.js +59 -73
  14. package/dist/hoi4_agent_tools/gui/scenario.js +3 -0
  15. package/dist/hoi4_agent_tools/gui/studio.js +9 -3
  16. package/dist/hoi4_agent_tools/mcp/server/create.js +3 -1
  17. package/dist/hoi4_agent_tools/mcp/server/progress.js +29 -8
  18. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +14 -0
  19. package/dist/hoi4_agent_tools/mcp/transports/bounded-stdio.js +44 -14
  20. package/dist/hoi4_agent_tools/mcp/transports/http.js +75 -7
  21. package/dist/hoi4_agent_tools/probability/scenario-state.js +2 -95
  22. package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +1 -287
  23. package/dist/hoi4_agent_tools/version.js +1 -1
  24. package/docs/development.md +2 -0
  25. package/docs/gui.md +3 -1
  26. package/docs/http.md +5 -1
  27. package/package.json +3 -2
  28. package/schemas/configuration.schema.json +151 -133
  29. package/schemas/continuous-focus-palette.schema.json +1 -1
  30. package/schemas/custom-weighted-pool.schema.json +1 -1
  31. package/schemas/event-feature-manifest.schema.json +1 -1
  32. package/schemas/focus-plan.schema.json +1 -1
  33. package/schemas/focus-planning-sidecar.schema.json +1 -1
  34. package/schemas/gui-animation-source.schema.json +1 -1
  35. package/schemas/gui-helper.schema.json +1 -1
  36. package/schemas/gui-scenario.schema.json +189 -33
  37. package/schemas/map-operation.schema.json +1 -1
  38. package/schemas/operation-result.schema.json +1 -1
  39. package/schemas/probability-analysis-result.schema.json +1 -1
  40. package/schemas/probability-compare-input.schema.json +1 -1
  41. package/schemas/probability-evaluate-input.schema.json +1 -1
  42. package/schemas/probability-inspect-input.schema.json +1 -1
  43. package/schemas/probability-render-input.schema.json +1 -1
  44. package/schemas/probability-scenario-set.schema.json +1 -1
  45. package/schemas/probability-sequence-input.schema.json +1 -1
  46. package/schemas/probability-simulate-input.schema.json +1 -1
  47. package/schemas/probability-sweep-input.schema.json +1 -1
  48. package/server.json +2 -2
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { ConditionScopeBindingSchema, ConditionResultSchema } from '../core/condition-schema.js';
2
3
  import { renderDimensionViolation, RENDER_MAX_DIMENSION } from '../core/render-budget.js';
3
4
  import { ServiceError } from '../core/result.js';
4
5
  import { GUI_SCENARIO_MAX_KEYS, GUI_SCENARIO_MAX_LIST_KEYS, GUI_SCENARIO_MAX_ROWS, GUI_SCENARIO_MAX_STRING_CHARACTERS, } from './limits.js';
@@ -120,6 +121,8 @@ const GuiPreviewScenarioBodySchema = z
120
121
  visibleTimeSeconds: z.number().min(0).max(86_400).optional(),
121
122
  country: objectSchema.optional(),
122
123
  stateValues: objectSchema.optional(),
124
+ scopes: z.record(z.string().max(256), ConditionScopeBindingSchema).optional(),
125
+ conditionResults: z.array(ConditionResultSchema).max(10_000).optional(),
123
126
  variables: z.record(z.string(), z.number()).default({}),
124
127
  flags: z.record(z.string(), z.boolean()).default({}),
125
128
  lists: z.record(z.string(), z.array(objectSchema).max(10_000)).default({}),
@@ -6,6 +6,7 @@ import { compareCodeUnits, canonicalJson, deterministicId, hashCanonical, sha256
6
6
  import { DiagnosticCollector } from '../core/diagnostic-collector.js';
7
7
  import { sortDiagnostics } from '../core/diagnostics.js';
8
8
  import { CoreEngine } from '../core/engine.js';
9
+ import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.js';
9
10
  import { assertRenderDimensions, RenderBudget, RENDER_MAX_PIXELS } from '../core/render-budget.js';
10
11
  import { ServiceError } from '../core/result.js';
11
12
  import { WorkspaceScanner } from '../core/scanner.js';
@@ -25,6 +26,8 @@ import { buildGuiSourceGraph } from './source-graph.js';
25
26
  import { assertGuiSourcePatchesSafe } from './source-patch.js';
26
27
  import { validateGuiScene, validateResolutionDrift, validateStateMatrix } from './validators.js';
27
28
  const staticGuiDefinitionPatterns = [
29
+ 'common/scripted_triggers/**/*.txt',
30
+ 'common/script_constants/**/*.txt',
28
31
  'common/scripted_localisation/**/*.txt',
29
32
  'common/decisions/**/*.txt',
30
33
  'common/decision_categories/**/*.txt',
@@ -672,13 +675,14 @@ export function guiArtifactProvenance(graph, kind, primaryScene, scenarioScenes
672
675
  },
673
676
  };
674
677
  }
678
+ const sharedGuiGraphs = new WeakMap();
675
679
  export class ScriptedGuiStudio {
676
680
  engine;
677
681
  resolver;
678
682
  transactions;
679
683
  scanner;
680
684
  artifacts;
681
- #graphCache = new Map();
685
+ #graphCache;
682
686
  constructor(engineOrResolver, transactions, scanner = new WorkspaceScanner(), artifacts = new ArtifactStore()) {
683
687
  this.engine =
684
688
  engineOrResolver instanceof CoreEngine
@@ -689,6 +693,8 @@ export class ScriptedGuiStudio {
689
693
  ...(transactions === undefined ? {} : { transactions }),
690
694
  });
691
695
  this.resolver = this.engine.resolver;
696
+ this.#graphCache = sharedGuiGraphs.get(this.engine) ?? new Map();
697
+ sharedGuiGraphs.set(this.engine, this.#graphCache);
692
698
  this.transactions = this.engine.transactions;
693
699
  this.scanner = this.engine.scanner;
694
700
  this.artifacts = this.engine.artifacts;
@@ -811,7 +817,7 @@ export class ScriptedGuiStudio {
811
817
  const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples));
812
818
  const generatedScenarios = generatedOptions === undefined
813
819
  ? []
814
- : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions);
820
+ : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, ClausewitzEvaluationDefinitions.build(scanned));
815
821
  const scenario = generatedScenarios[0] ?? placeholderScenario;
816
822
  const relatedScenarios = [
817
823
  ...(generatedOptions?.preservePlaceholder === true && generatedScenarios.length > 0
@@ -890,7 +896,7 @@ export class ScriptedGuiStudio {
890
896
  ], generatedOptions?.textSamples));
891
897
  const generatedScenarios = generatedOptions === undefined
892
898
  ? []
893
- : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions);
899
+ : generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, ClausewitzEvaluationDefinitions.build(scanned));
894
900
  const scenario = generatedScenarios[0] ?? placeholderScenario;
895
901
  const relatedScenarios = [
896
902
  ...(generatedOptions?.preservePlaceholder === true && generatedScenarios.length > 0
@@ -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
  }
@@ -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,98 +1,5 @@
1
- function caseInsensitiveEntry(record, key) {
2
- if (record === undefined)
3
- return undefined;
4
- if (Object.hasOwn(record, key))
5
- return record[key];
6
- const normalized = key.toLowerCase();
7
- const found = Object.entries(record).find(([candidate]) => candidate.toLowerCase() === normalized);
8
- return found?.[1];
9
- }
10
- export function rootScopeContext(scenario) {
11
- return {
12
- expression: 'ROOT',
13
- binding: {
14
- id: scenario.actor ?? 'ROOT',
15
- ...(scenario.actor === undefined ? {} : { actor: scenario.actor }),
16
- state: scenario.state,
17
- ...(scenario.flags === undefined ? {} : { flags: scenario.flags }),
18
- ...(scenario.eventTargets === undefined ? {} : { eventTargets: scenario.eventTargets }),
19
- },
20
- };
21
- }
22
- function explicitScope(scenario, expression, parent) {
23
- const binding = caseInsensitiveEntry(scenario.scopes, expression);
24
- return binding === undefined ? undefined : { expression, binding, parent };
25
- }
26
- export function resolveScopeContext(scenario, expression, current) {
27
- const upper = expression.toUpperCase();
28
- if (upper === 'ROOT')
29
- return rootScopeContext(scenario);
30
- if (upper === 'THIS')
31
- return current;
32
- if (upper === 'PREV')
33
- return current.parent ?? rootScopeContext(scenario);
34
- const chained = current.expression === 'ROOT' || current.expression === 'THIS'
35
- ? expression
36
- : `${current.expression}.${expression}`;
37
- const chainedBinding = explicitScope(scenario, chained, current);
38
- if (chainedBinding !== undefined)
39
- return chainedBinding;
40
- const direct = explicitScope(scenario, expression, current);
41
- if (direct !== undefined)
42
- return direct;
43
- const targetMatch = /^(?:event_target|scope):(.+)$/iu.exec(expression);
44
- const targetId = targetMatch === null
45
- ? undefined
46
- : (caseInsensitiveEntry(current.binding.eventTargets, targetMatch[1]) ??
47
- caseInsensitiveEntry(scenario.eventTargets, targetMatch[1]));
48
- if (targetId !== undefined) {
49
- const declaredBinding = Object.values(scenario.scopes ?? {}).find((binding) => binding.id === targetId || binding.actor === targetId);
50
- return {
51
- expression,
52
- binding: declaredBinding ?? { id: targetId, actor: targetId, state: {} },
53
- parent: current,
54
- };
55
- }
56
- return undefined;
57
- }
58
- export function scopeStateValue(scenario, key, current) {
59
- if (Object.hasOwn(current.binding.state, key))
60
- return current.binding.state[key];
61
- if (Object.hasOwn(current.binding.state, `trigger.${key}`))
62
- return current.binding.state[`trigger.${key}`];
63
- if (current.expression !== 'ROOT')
64
- return undefined;
65
- if (Object.hasOwn(scenario.state, key))
66
- return scenario.state[key];
67
- if (Object.hasOwn(scenario.state, `trigger.${key}`))
68
- return scenario.state[`trigger.${key}`];
69
- return undefined;
70
- }
71
- export function rootStateValue(scenario, key) {
72
- if (Object.hasOwn(scenario.state, key))
73
- return scenario.state[key];
74
- if (Object.hasOwn(scenario.state, `trigger.${key}`))
75
- return scenario.state[`trigger.${key}`];
76
- return undefined;
77
- }
78
- export function scopeIdentity(current) {
79
- return current.binding.actor ?? current.binding.id;
80
- }
81
- export function scopedExpressionValue(scenario, expression) {
82
- const scopes = Object.entries(scenario.scopes ?? {}).sort(([left], [right]) => right.length - left.length);
83
- for (const [scope, binding] of scopes) {
84
- for (const prefix of [`${scope}.`, `scope.${scope}.`, `scopes.${scope}.`]) {
85
- if (!expression.toLowerCase().startsWith(prefix.toLowerCase()))
86
- continue;
87
- const key = expression.slice(prefix.length).replace(/^state\./u, '');
88
- if (Object.hasOwn(binding.state, key))
89
- return binding.state[key];
90
- if (Object.hasOwn(binding.state, `variable.${key}`))
91
- return binding.state[`variable.${key}`];
92
- }
93
- }
94
- return undefined;
95
- }
1
+ import { rootStateValue, scopedExpressionValue } from '../core/scenario-state.js';
2
+ export { rootScopeContext, resolveScopeContext, scopeStateValue, rootStateValue, scopeIdentity, scopedExpressionValue, } from '../core/scenario-state.js';
96
3
  function updateScopeState(scenario, path, value) {
97
4
  const scopes = Object.entries(scenario.scopes ?? {}).sort(([left], [right]) => right.length - left.length);
98
5
  for (const [scope, binding] of scopes) {