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.
- package/CHANGELOG.md +26 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +21 -1
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +399 -0
- package/dist/hoi4_agent_tools/core/condition-model.js +1 -0
- package/dist/hoi4_agent_tools/core/condition-schema.js +42 -0
- 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/scenario-state.js +97 -0
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +125 -0
- package/dist/hoi4_agent_tools/gui/scenario-conditions.js +119 -0
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +59 -73
- package/dist/hoi4_agent_tools/gui/scenario.js +3 -0
- package/dist/hoi4_agent_tools/gui/studio.js +9 -3
- 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/probability/scenario-state.js +2 -95
- package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +1 -287
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/development.md +2 -0
- package/docs/gui.md +3 -1
- 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 +189 -33
- 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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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,98 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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) {
|