hoi4-agent-tools 1.0.0 → 1.2.0

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 (45) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +27 -16
  3. package/dist/hoi4_agent_tools/core/engine.js +12 -0
  4. package/dist/hoi4_agent_tools/event/algorithms.js +208 -0
  5. package/dist/hoi4_agent_tools/event/artifact-validation.js +572 -0
  6. package/dist/hoi4_agent_tools/event/compare.js +413 -0
  7. package/dist/hoi4_agent_tools/event/graph.js +1586 -0
  8. package/dist/hoi4_agent_tools/event/index.js +10 -0
  9. package/dist/hoi4_agent_tools/event/layout.js +218 -0
  10. package/dist/hoi4_agent_tools/event/limits.js +41 -0
  11. package/dist/hoi4_agent_tools/event/model.js +2 -0
  12. package/dist/hoi4_agent_tools/event/queries.js +643 -0
  13. package/dist/hoi4_agent_tools/event/render.js +443 -0
  14. package/dist/hoi4_agent_tools/event/scc.js +72 -0
  15. package/dist/hoi4_agent_tools/event/service.js +1147 -0
  16. package/dist/hoi4_agent_tools/event/source-analysis.js +1458 -0
  17. package/dist/hoi4_agent_tools/focus/compact.js +432 -0
  18. package/dist/hoi4_agent_tools/focus/compiler.js +1 -7
  19. package/dist/hoi4_agent_tools/focus/geometry.js +203 -16
  20. package/dist/hoi4_agent_tools/focus/index.js +1 -0
  21. package/dist/hoi4_agent_tools/focus/layout.js +1256 -92
  22. package/dist/hoi4_agent_tools/focus/limits.js +1 -1
  23. package/dist/hoi4_agent_tools/focus/lint.js +11 -3
  24. package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
  25. package/dist/hoi4_agent_tools/focus/planning.js +6 -1
  26. package/dist/hoi4_agent_tools/focus/render.js +14 -9
  27. package/dist/hoi4_agent_tools/mcp/resources/register.js +1 -1
  28. package/dist/hoi4_agent_tools/mcp/server/context-schemas.js +8 -0
  29. package/dist/hoi4_agent_tools/mcp/server/create.js +4 -1
  30. package/dist/hoi4_agent_tools/mcp/server/result.js +117 -52
  31. package/dist/hoi4_agent_tools/mcp/tools/event.js +459 -0
  32. package/dist/hoi4_agent_tools/mcp/tools/focus.js +93 -7
  33. package/dist/hoi4_agent_tools/mcp/tools/gui.js +12 -9
  34. package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -4
  35. package/dist/hoi4_agent_tools/mcp/tools/mods.js +1 -1
  36. package/dist/hoi4_agent_tools/schemas/event.js +95 -0
  37. package/dist/hoi4_agent_tools/schemas/focus.js +64 -8
  38. package/dist/hoi4_agent_tools/version.js +1 -1
  39. package/docs/README.md +1 -0
  40. package/docs/development.md +10 -0
  41. package/docs/events.md +113 -0
  42. package/docs/focus.md +17 -7
  43. package/docs/setup.md +3 -3
  44. package/package.json +9 -5
  45. package/server.json +3 -3
@@ -2,7 +2,7 @@ export const FOCUS_GRAPH_MAX_DEPTH = 256;
2
2
  export const FOCUS_GRAPH_MAX_NODES = 10_000;
3
3
  export const FOCUS_GRAPH_MAX_EDGES = 50_000;
4
4
  export const FOCUS_PAIR_COMPARISON_MAX = 2_000_000;
5
- export const FOCUS_LAYOUT_WORK_MAX = 500_000;
5
+ export const FOCUS_LAYOUT_WORK_MAX = 8_000_000;
6
6
  export const FOCUS_LINT_WORK_MAX = 2_000_000;
7
7
  export const FOCUS_DIAGNOSTIC_MAX = 2_000;
8
8
  export const FOCUS_CYCLE_SAMPLE_MAX = 128;
@@ -2,7 +2,7 @@ import { compareCodeUnits } from '../core/canonical.js';
2
2
  import { sortDiagnostics } from '../core/diagnostics.js';
3
3
  import { DiagnosticCollector } from '../core/diagnostic-collector.js';
4
4
  import { ServiceError } from '../core/result.js';
5
- import { focusConnectorSegmentsProperlyCross, focusNodesVisiblyOverlap } from './geometry.js';
5
+ import { flattenFocusConnectorCurve, focusConnectorCurve, focusConnectorPolylinesIntersect, focusNodesVisiblyOverlap, } from './geometry.js';
6
6
  import { layoutFocusTree } from './layout.js';
7
7
  import { FOCUS_CYCLE_SAMPLE_MAX, FOCUS_DIAGNOSTIC_MAX, FOCUS_GRAPH_MAX_DEPTH, FOCUS_GRAPH_MAX_EDGES, FOCUS_GRAPH_MAX_NODES, FOCUS_LINT_WORK_MAX, FOCUS_PAIR_COMPARISON_MAX, } from './limits.js';
8
8
  import { layoutNodeMap, } from './model.js';
@@ -641,7 +641,15 @@ export function lintFocusTree(plan, options = {}) {
641
641
  const child = positions.get(focus.id);
642
642
  return parent === undefined || child === undefined
643
643
  ? []
644
- : [{ parentId, childId: focus.id, parent, child }];
644
+ : [
645
+ {
646
+ parentId,
647
+ childId: focus.id,
648
+ parent,
649
+ child,
650
+ points: flattenFocusConnectorCurve(focusConnectorCurve(parent, child)),
651
+ },
652
+ ];
645
653
  }));
646
654
  const connectorPairCount = (connectors.length * (connectors.length - 1)) / 2;
647
655
  if (connectorPairCount > FOCUS_PAIR_COMPARISON_MAX) {
@@ -668,7 +676,7 @@ export function lintFocusTree(plan, options = {}) {
668
676
  first.childId === second.childId) {
669
677
  continue;
670
678
  }
671
- if (!focusConnectorSegmentsProperlyCross(first.parent, first.child, second.parent, second.child))
679
+ if (!focusConnectorPolylinesIntersect(first.points, second.points))
672
680
  continue;
673
681
  const focus = focuses.get(second.childId);
674
682
  if (focus === undefined)
@@ -5,7 +5,7 @@
5
5
  * Source: the official HOI4 1.19.2 effects documentation reviewed 2026-07-10.
6
6
  * Keep this list synchronized during compatibility reviews.
7
7
  */
8
- export const nativeFocusEffectKeys = new Set([
8
+ export const nativeEffectKeys = new Set([
9
9
  'activate_advisor',
10
10
  'activate_decision',
11
11
  'activate_mission',
@@ -560,3 +560,5 @@ export const nativeFocusEffectKeys = new Set([
560
560
  'while_loop_effect',
561
561
  'white_peace',
562
562
  ]);
563
+ /** Backward-compatible focus-domain name for the shared official effect catalog. */
564
+ export const nativeFocusEffectKeys = nativeEffectKeys;
@@ -27,6 +27,7 @@ function planningNode(focus) {
27
27
  aiStrategyIds: [...focus.ai.strategyIds],
28
28
  ...(focus.payoff === undefined ? {} : { payoff: focus.payoff }),
29
29
  ...(focus.terminalKind === undefined ? {} : { terminalKind: focus.terminalKind }),
30
+ ...(focus.position.mode === 'auto' ? { autoPosition: structuredClone(focus.position) } : {}),
30
31
  };
31
32
  }
32
33
  export function createFocusPlanningSidecar(plan, sourceHash = plan.provenance.sourceHash) {
@@ -139,8 +140,12 @@ export function enrichFocusPlanFromSidecar(plan, sidecar) {
139
140
  delete focus.laneId;
140
141
  else
141
142
  focus.laneId = saved.laneId;
142
- if (focus.position.mode !== 'auto')
143
+ if (saved.autoPosition !== undefined) {
144
+ focus.position = structuredClone(saved.autoPosition);
145
+ }
146
+ else if (focus.position.mode !== 'auto') {
143
147
  focus.position.pinned = saved.pinned;
148
+ }
144
149
  focus.visibility = saved.visibility;
145
150
  if (saved.reveal === undefined)
146
151
  delete focus.reveal;
@@ -6,7 +6,7 @@ import { ServiceError } from '../core/result.js';
6
6
  import { DeterministicSvgTextRenderer } from '../core/svg-text.js';
7
7
  import { PACKAGE_VERSION } from '../version.js';
8
8
  import { compileFocusTreeWithSourceMap } from './compiler.js';
9
- import { FOCUS_HORIZONTAL_GRID_PIXELS, FOCUS_NODE_HEIGHT_PIXELS, FOCUS_NODE_WIDTH_PIXELS, FOCUS_VERTICAL_GRID_PIXELS, focusNodeOrigin, } from './geometry.js';
9
+ import { FOCUS_HORIZONTAL_GRID_PIXELS, FOCUS_NODE_HEIGHT_PIXELS, FOCUS_NODE_WIDTH_PIXELS, FOCUS_VERTICAL_GRID_PIXELS, focusConnectorCurve, focusConnectorSvgPath, focusNodeOrigin, } from './geometry.js';
10
10
  import { createFocusPlanningSidecar, serializeFocusPlanningSidecar } from './planning.js';
11
11
  import { FOCUS_GRAPH_MAX_EDGES, FOCUS_GRAPH_MAX_NODES } from './lint.js';
12
12
  import { layoutNodeMap, } from './model.js';
@@ -173,6 +173,7 @@ function svgDocument(plan, layout, diagnostics, options) {
173
173
  node.id,
174
174
  focusNodeOrigin(node, minimumX, minimumY, padding, horizontal, vertical),
175
175
  ]));
176
+ const layoutNodes = new Map(nodes.map((node) => [node.id, node]));
176
177
  const focusMap = new Map(plan.focuses.map((focus) => [focus.id, focus]));
177
178
  const diagnosticMap = new Map();
178
179
  const toolText = new DeterministicSvgTextRenderer();
@@ -214,16 +215,19 @@ function svgDocument(plan, layout, diagnostics, options) {
214
215
  '<g id="prerequisites">',
215
216
  ];
216
217
  for (const edge of prerequisiteEdges) {
217
- const parent = pixel.get(edge.parentId);
218
- const child = pixel.get(edge.childId);
218
+ const parent = layoutNodes.get(edge.parentId);
219
+ const child = layoutNodes.get(edge.childId);
219
220
  if (parent === undefined || child === undefined)
220
221
  continue;
221
- const startX = parent.x + nodeWidth / 2;
222
- const startY = parent.y + nodeHeight;
223
- const endX = child.x + nodeWidth / 2;
224
- const endY = child.y;
225
- const middleY = (startY + endY) / 2;
226
- parts.push(`<path d="M ${startX} ${startY} C ${startX} ${middleY}, ${endX} ${middleY}, ${endX} ${endY}" fill="none" stroke="#8da2bc" stroke-width="2" marker-end="url(#arrow)" data-parent="${escapeXml(edge.parentId)}" data-child="${escapeXml(edge.childId)}" data-prerequisite-group="${edge.groupIndex}"/>`);
222
+ const curve = focusConnectorCurve(parent, child, {
223
+ horizontalSpacing: horizontal,
224
+ verticalSpacing: vertical,
225
+ nodeWidth,
226
+ nodeHeight,
227
+ originX: padding - minimumX * horizontal,
228
+ originY: padding - minimumY * vertical,
229
+ });
230
+ parts.push(`<path d="${focusConnectorSvgPath(curve)}" fill="none" stroke="#8da2bc" stroke-width="2" marker-end="url(#arrow)" data-parent="${escapeXml(edge.parentId)}" data-child="${escapeXml(edge.childId)}" data-prerequisite-group="${edge.groupIndex}"/>`);
227
231
  }
228
232
  parts.push('</g>', '<g id="mutual-exclusions">');
229
233
  for (const [leftId, rightId] of exclusionEdges) {
@@ -309,6 +313,7 @@ function svgDocument(plan, layout, diagnostics, options) {
309
313
  hash: layout.layoutHash,
310
314
  nodes,
311
315
  decisions: layout.decisions,
316
+ metrics: layout.metrics ?? null,
312
317
  },
313
318
  focuses: [...plan.focuses]
314
319
  .sort((left, right) => compareCodeUnits(left.id, right.id))
@@ -6,7 +6,7 @@ const artifactChunkMetadataKey = 'io.github.klimpaskov/hoi4-agent-tools.artifact
6
6
  const fatalUtf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
7
7
  const artifactResourceMetadata = {
8
8
  title: 'HOI4 review artifact',
9
- description: 'Complete plans, diagnostics, layouts, previews, and diffs. Reads accept zero-based byte offset and positive byte length query selectors; each response reports its exact byte range and continuation URI in _meta.',
9
+ description: 'Complete plans, diagnostics, layouts, previews, event graphs, routes, state and scope reports, comparisons, and diffs. Reads accept zero-based byte offset and positive byte length query selectors; each response reports its exact byte range and continuation URI in _meta.',
10
10
  _meta: {
11
11
  [artifactChunkMetadataKey]: {
12
12
  version: 1,
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod/v4';
2
+ /**
3
+ * Keep a large nested payload schema out of tools/list while retaining its complete runtime parser.
4
+ * The MCP SDK publishes the input side of a pipe and executes both sides when a tool is called.
5
+ */
6
+ export function compactValidatedInputSchema(schema, description) {
7
+ return z.unknown().describe(description).pipe(schema);
8
+ }
@@ -4,7 +4,9 @@ import { registerModsTool } from '../tools/mods.js';
4
4
  import { registerFocusTools } from '../tools/focus.js';
5
5
  import { registerGuiTools } from '../tools/gui.js';
6
6
  import { registerMapTools } from '../tools/map.js';
7
+ import { registerEventTools } from '../tools/event.js';
7
8
  import { registerMcpResources } from '../resources/register.js';
9
+ export const SERVER_INSTRUCTIONS = 'Use hoi4.mods to select a workspace. Use hoi4.event_inspect for scan, roots, trace, explain_path, state_flow, lint, and impact; hoi4.event_render and hoi4.event_compare return linked evidence. JSON resources are authoritative. Event tools are read-only. Only focus, GUI, and map rewrite tools edit configured mods.';
8
10
  export function createMcpServer(engine, context = {}) {
9
11
  const server = new McpServer({
10
12
  name: PACKAGE_NAME,
@@ -12,12 +14,13 @@ export function createMcpServer(engine, context = {}) {
12
14
  websiteUrl: 'https://github.com/klimPaskov/hoi4-agent-tools',
13
15
  }, {
14
16
  capabilities: { logging: {} },
15
- instructions: 'Use this server when a configured HOI4 mod needs a large national or continuous focus, scripted GUI, or map created, inspected, rendered, or cleaned up. Start with hoi4.mods, then use the matching inspect, render, and rewrite tools. Each rewrite performs the complete edit in one call. Read linked hoi4-agent:// artifact resources for complete plans, diagnostics, layouts, previews, and diffs; when a resource content item has a non-null continuationUri in its namespaced _meta byte-range record, follow it until null. Generated GUI renders are offline representations, never game screenshots.',
17
+ instructions: SERVER_INSTRUCTIONS,
16
18
  });
17
19
  registerModsTool(server, engine, context);
18
20
  registerFocusTools(server, engine, context);
19
21
  registerGuiTools(server, engine, context);
20
22
  registerMapTools(server, engine, context);
23
+ registerEventTools(server, engine, context);
21
24
  registerMcpResources(server, engine, context);
22
25
  return server;
23
26
  }
@@ -3,15 +3,15 @@ import { canonicalJson } from '../../core/canonical.js';
3
3
  import { ServiceError } from '../../core/result.js';
4
4
  import { diagnosticSchema } from '../../schemas/common.js';
5
5
  import { artifactLinkSchema, validationSummarySchema } from '../../schemas/transaction.js';
6
- export const MAX_INLINE_FILES_SCANNED = 1_000;
7
- export const MAX_INLINE_PROPOSED_FILES = 1_000;
8
- export const MAX_INLINE_CHANGED_FILES = 1_000;
9
- export const MAX_INLINE_DIAGNOSTICS = 100;
10
- export const MAX_INLINE_ARTIFACT_LINKS = 512;
11
- export const MAX_INLINE_BLOCKERS = 100;
6
+ export const MAX_INLINE_FILES_SCANNED = 64;
7
+ export const MAX_INLINE_PROPOSED_FILES = 64;
8
+ export const MAX_INLINE_CHANGED_FILES = 64;
9
+ export const MAX_INLINE_DIAGNOSTICS = 20;
10
+ export const MAX_INLINE_ARTIFACT_LINKS = 32;
11
+ export const MAX_INLINE_BLOCKERS = 20;
12
12
  export const MAX_INLINE_VALIDATION_CHECKS = 5;
13
13
  // Leave headroom for the enclosing JSON-RPC response and transport metadata.
14
- export const MAX_TOOL_RESULT_BYTES = 500_000;
14
+ export const MAX_TOOL_RESULT_BYTES = 32_768;
15
15
  const deferredFilesDiagnostic = Symbol('deferredFilesDiagnostic');
16
16
  const operationResultBaseSchema = z
17
17
  .object({
@@ -39,15 +39,37 @@ export const operationResultSchema = operationResultBaseSchema
39
39
  .extend({ data: z.record(z.string().max(256), z.unknown()) })
40
40
  .strict();
41
41
  const emptyToolDataSchema = z.object({}).strict();
42
+ const compactPublicOperationResultSchema = z
43
+ .object({
44
+ status: z.enum(['ok', 'blocked', 'error']),
45
+ code: z.string(),
46
+ workspaceId: z.string(),
47
+ filesScanned: z.array(z.unknown()).max(MAX_INLINE_FILES_SCANNED),
48
+ proposedFiles: z.array(z.unknown()).max(MAX_INLINE_PROPOSED_FILES),
49
+ changedFiles: z.array(z.unknown()).max(MAX_INLINE_CHANGED_FILES),
50
+ diagnostics: z.array(z.unknown()).max(MAX_INLINE_DIAGNOSTICS),
51
+ artifacts: z.array(z.unknown()).max(MAX_INLINE_ARTIFACT_LINKS),
52
+ validation: z.unknown(),
53
+ blockers: z.array(z.unknown()).max(MAX_INLINE_BLOCKERS),
54
+ data: z.object({}).catchall(z.unknown()),
55
+ })
56
+ .strict();
42
57
  /**
43
- * Advertise an exact success payload for one tool while retaining the shared empty error payload.
44
- * The generic operation-result schema remains available for stored envelopes, but is never used as
45
- * a public tool output contract.
58
+ * Advertise one compact operation envelope while retaining exact per-tool runtime validation.
59
+ * The generic operation-result schema remains available for stored envelopes.
46
60
  */
47
61
  export function strictOperationResultSchema(successDataSchema) {
48
- return operationResultBaseSchema
62
+ const exactSchema = operationResultBaseSchema
49
63
  .extend({ data: z.union([emptyToolDataSchema, successDataSchema]) })
50
64
  .strict();
65
+ return compactPublicOperationResultSchema.superRefine((value, context) => {
66
+ const parsed = exactSchema.safeParse(value);
67
+ if (parsed.success)
68
+ return;
69
+ for (const issue of parsed.error.issues) {
70
+ context.addIssue({ code: 'custom', path: issue.path, message: issue.message });
71
+ }
72
+ });
51
73
  }
52
74
  /** Keep source inventories compact; complete inventories belong in linked resources. */
53
75
  export function setInlineFilesScanned(result, files) {
@@ -82,11 +104,14 @@ function preserveDeferredFilesDiagnostic(result) {
82
104
  };
83
105
  }
84
106
  function boundedResult(result, actualBytes) {
107
+ const hasLinkedEvidence = result.artifacts.length > 0;
85
108
  const truncationDiagnostic = {
86
109
  code: 'MCP_RESPONSE_TRUNCATED',
87
110
  severity: 'warning',
88
111
  category: 'configuration',
89
- message: 'Tool output exceeded the wire budget; complete details remain available through linked resources',
112
+ message: hasLinkedEvidence
113
+ ? 'Tool output exceeded the wire budget; inspect the linked evidence for complete details'
114
+ : 'Tool output exceeded the wire budget; nonessential inline details were omitted',
90
115
  details: { actualBytes, maxBytes: MAX_TOOL_RESULT_BYTES },
91
116
  };
92
117
  return {
@@ -106,7 +131,9 @@ function boundedResult(result, actualBytes) {
106
131
  : [
107
132
  {
108
133
  code: 'MCP_RESPONSE_TRUNCATED',
109
- message: 'Tool output exceeded the wire budget; inspect the linked resource for complete details',
134
+ message: hasLinkedEvidence
135
+ ? 'Tool output exceeded the wire budget; inspect the linked evidence for complete details'
136
+ : 'Tool output exceeded the wire budget; nonessential inline details were omitted',
110
137
  details: {
111
138
  actualBytes,
112
139
  maxBytes: MAX_TOOL_RESULT_BYTES,
@@ -196,32 +223,60 @@ function jsonBytesWithin(value, budget) {
196
223
  return visit(value);
197
224
  }
198
225
  function compactDetails(details) {
199
- return jsonBytesWithin(details, 4_096) ? details : { truncated: true };
226
+ return jsonBytesWithin(details, 2_048) ? details : { truncated: true };
200
227
  }
201
228
  function compactDescriptions(result) {
202
229
  const compactLocation = (location) => location === undefined
203
230
  ? undefined
204
231
  : {
205
232
  ...location,
206
- path: location.path.slice(0, 4_096),
207
- ...(location.symbol === undefined ? {} : { symbol: location.symbol.slice(0, 1_024) }),
233
+ path: location.path.slice(0, 1_024),
234
+ ...(location.symbol === undefined ? {} : { symbol: location.symbol.slice(0, 256) }),
208
235
  };
209
- const validationTruncation = result.validation.checks.length > MAX_INLINE_VALIDATION_CHECKS
210
- ? {
211
- code: 'MCP_INLINE_VALIDATION_TRUNCATED',
212
- severity: 'info',
213
- category: 'configuration',
214
- message: `Inline validation is limited to ${MAX_INLINE_VALIDATION_CHECKS} checks; use the linked artifact for the complete validation record`,
215
- details: {
216
- total: result.validation.checks.length,
217
- returned: MAX_INLINE_VALIDATION_CHECKS,
218
- },
219
- }
220
- : undefined;
221
- const compactDiagnostics = result.diagnostics.map((diagnostic) => ({
236
+ const collectionTotals = {
237
+ filesScanned: result.filesScanned.length,
238
+ proposedFiles: result.proposedFiles.length,
239
+ changedFiles: result.changedFiles.length,
240
+ diagnostics: result.diagnostics.length,
241
+ artifacts: result.artifacts.length,
242
+ validationChecks: result.validation.checks.length,
243
+ blockers: result.blockers.length,
244
+ };
245
+ const collectionLimits = {
246
+ filesScanned: MAX_INLINE_FILES_SCANNED,
247
+ proposedFiles: MAX_INLINE_PROPOSED_FILES,
248
+ changedFiles: MAX_INLINE_CHANGED_FILES,
249
+ diagnostics: MAX_INLINE_DIAGNOSTICS,
250
+ artifacts: MAX_INLINE_ARTIFACT_LINKS,
251
+ validationChecks: MAX_INLINE_VALIDATION_CHECKS,
252
+ blockers: MAX_INLINE_BLOCKERS,
253
+ };
254
+ const truncatedCollections = Object.fromEntries(Object.entries(collectionTotals).flatMap(([name, total]) => {
255
+ const limit = collectionLimits[name];
256
+ return total > limit ? [[name, { total, limit }]] : [];
257
+ }));
258
+ if (Object.keys(truncatedCollections).length > 0 &&
259
+ result.diagnostics.length > MAX_INLINE_DIAGNOSTICS - 1) {
260
+ truncatedCollections.diagnostics = {
261
+ total: result.diagnostics.length,
262
+ limit: MAX_INLINE_DIAGNOSTICS - 1,
263
+ };
264
+ }
265
+ const needsTruncationDiagnostic = Object.keys(truncatedCollections).length > 0;
266
+ const diagnosticSlots = Math.max(0, MAX_INLINE_DIAGNOSTICS - (needsTruncationDiagnostic ? 1 : 0));
267
+ const hardDiagnostics = result.diagnostics.filter(({ severity }) => severity === 'error' || severity === 'blocker');
268
+ const priorityDiagnostics = result.diagnostics.filter(({ code, severity }) => severity === 'error' || severity === 'blocker' || code === 'MCP_INLINE_FILES_TRUNCATED');
269
+ const otherDiagnostics = result.diagnostics.filter((diagnostic) => !priorityDiagnostics.includes(diagnostic));
270
+ const retainedPriorityDiagnostics = priorityDiagnostics.slice(0, diagnosticSlots);
271
+ const retainedDiagnostics = [
272
+ ...retainedPriorityDiagnostics,
273
+ ...otherDiagnostics.slice(0, diagnosticSlots - retainedPriorityDiagnostics.length),
274
+ ];
275
+ const omittedHardDiagnostics = hardDiagnostics.filter((diagnostic) => !retainedDiagnostics.includes(diagnostic)).length;
276
+ const compactDiagnostics = retainedDiagnostics.map((diagnostic) => ({
222
277
  ...diagnostic,
223
278
  code: diagnostic.code.slice(0, 256),
224
- message: diagnostic.message.slice(0, 4_096),
279
+ message: diagnostic.message.slice(0, 1_024),
225
280
  ...(diagnostic.operationId === undefined
226
281
  ? {}
227
282
  : { operationId: diagnostic.operationId.slice(0, 256) }),
@@ -231,35 +286,53 @@ function compactDescriptions(result) {
231
286
  ...(diagnostic.related === undefined
232
287
  ? {}
233
288
  : {
234
- related: diagnostic.related.slice(0, 5).map((location) => compactLocation(location)),
289
+ related: diagnostic.related.slice(0, 3).map((location) => compactLocation(location)),
235
290
  }),
236
291
  ...(diagnostic.details === undefined ? {} : { details: compactDetails(diagnostic.details) }),
237
292
  }));
293
+ const truncationDiagnostic = needsTruncationDiagnostic
294
+ ? {
295
+ code: 'MCP_INLINE_COLLECTIONS_TRUNCATED',
296
+ severity: omittedHardDiagnostics > 0 ? 'blocker' : 'info',
297
+ category: 'configuration',
298
+ message: result.artifacts.length > 0
299
+ ? 'Large collections were bounded inline; linked resources retain bulk evidence'
300
+ : 'Large collections were bounded inline',
301
+ details: {
302
+ collections: truncatedCollections,
303
+ diagnosticsReturned: retainedDiagnostics.length,
304
+ omittedHardDiagnostics,
305
+ },
306
+ }
307
+ : undefined;
238
308
  return {
239
309
  ...result,
240
- diagnostics: validationTruncation === undefined
310
+ filesScanned: result.filesScanned.slice(0, MAX_INLINE_FILES_SCANNED),
311
+ proposedFiles: result.proposedFiles.slice(0, MAX_INLINE_PROPOSED_FILES),
312
+ changedFiles: result.changedFiles.slice(0, MAX_INLINE_CHANGED_FILES),
313
+ diagnostics: truncationDiagnostic === undefined
241
314
  ? compactDiagnostics
242
- : [validationTruncation, ...compactDiagnostics].slice(0, MAX_INLINE_DIAGNOSTICS),
315
+ : [truncationDiagnostic, ...compactDiagnostics],
316
+ artifacts: result.artifacts.slice(0, MAX_INLINE_ARTIFACT_LINKS).map((artifact) => ({
317
+ ...artifact,
318
+ ...(artifact.description === undefined
319
+ ? {}
320
+ : { description: artifact.description.slice(0, 256) }),
321
+ })),
243
322
  validation: {
244
323
  ...result.validation,
245
324
  checks: result.validation.checks.slice(0, MAX_INLINE_VALIDATION_CHECKS).map((check) => ({
246
325
  ...check,
247
326
  id: check.id.slice(0, 256),
248
- message: check.message.slice(0, 4_096),
327
+ message: check.message.slice(0, 1_024),
249
328
  })),
250
329
  },
251
- blockers: result.blockers.map((blocker) => ({
330
+ blockers: result.blockers.slice(0, MAX_INLINE_BLOCKERS).map((blocker) => ({
252
331
  ...blocker,
253
332
  code: blocker.code.slice(0, 256),
254
- message: blocker.message.slice(0, 4_096),
333
+ message: blocker.message.slice(0, 1_024),
255
334
  ...(blocker.details === undefined ? {} : { details: compactDetails(blocker.details) }),
256
335
  })),
257
- artifacts: result.artifacts.map((artifact) => ({
258
- ...artifact,
259
- ...(artifact.description === undefined
260
- ? {}
261
- : { description: artifact.description.slice(0, 1_024) }),
262
- })),
263
336
  };
264
337
  }
265
338
  function buildToolResult(selected) {
@@ -286,23 +359,15 @@ function buildToolResult(selected) {
286
359
  }
287
360
  export function toolResult(result) {
288
361
  const completeResult = preserveDeferredFilesDiagnostic(result);
289
- const exceedsArrayBudget = completeResult.filesScanned.length > MAX_INLINE_FILES_SCANNED ||
290
- completeResult.proposedFiles.length > MAX_INLINE_PROPOSED_FILES ||
291
- completeResult.changedFiles.length > MAX_INLINE_CHANGED_FILES ||
292
- completeResult.diagnostics.length > MAX_INLINE_DIAGNOSTICS ||
293
- completeResult.artifacts.length > MAX_INLINE_ARTIFACT_LINKS ||
294
- completeResult.blockers.length > MAX_INLINE_BLOCKERS;
295
362
  const compact = compactDescriptions(completeResult);
296
363
  const estimatedBytes = jsonBytesWithin(compact, MAX_TOOL_RESULT_BYTES)
297
364
  ? Buffer.byteLength(canonicalJson(compact), 'utf8')
298
365
  : MAX_TOOL_RESULT_BYTES + 1;
299
- const selected = estimatedBytes > MAX_TOOL_RESULT_BYTES || exceedsArrayBudget
300
- ? boundedResult(completeResult, estimatedBytes)
301
- : compact;
366
+ const selected = estimatedBytes > MAX_TOOL_RESULT_BYTES ? boundedResult(compact, estimatedBytes) : compact;
302
367
  let output = buildToolResult(selected);
303
368
  const wireBytes = Buffer.byteLength(canonicalJson(output), 'utf8');
304
369
  if (wireBytes > MAX_TOOL_RESULT_BYTES) {
305
- output = buildToolResult(boundedResult(completeResult, wireBytes));
370
+ output = buildToolResult(boundedResult(compact, wireBytes));
306
371
  }
307
372
  const finalBytes = Buffer.byteLength(canonicalJson(output), 'utf8');
308
373
  if (finalBytes > MAX_TOOL_RESULT_BYTES) {