@adhisang/minecraft-modding-mcp 7.0.0-rc.0 → 7.0.0-rc.2
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 +131 -49
- package/README.md +2 -0
- package/dist/cache-registry.d.ts +10 -0
- package/dist/cache-registry.js +44 -3
- package/dist/entry-tools/batch-class-members-service.js +8 -0
- package/dist/entry-tools/inspect-minecraft/handlers/class-members.js +21 -0
- package/dist/entry-tools/inspect-minecraft/internal.d.ts +4 -0
- package/dist/entry-tools/manage-cache-service.d.ts +2 -2
- package/dist/entry-tools/validate-project/cases/mixin.js +26 -6
- package/dist/entry-tools/validate-project/cases/project-summary.d.ts +7 -7
- package/dist/entry-tools/validate-project-service.d.ts +2 -2
- package/dist/entry-tools/verify-mixin-target-service.js +19 -1
- package/dist/error-mapping.d.ts +118 -0
- package/dist/error-mapping.js +177 -7
- package/dist/index.js +3 -1
- package/dist/json-rpc-framing.d.ts +53 -1
- package/dist/json-rpc-framing.js +190 -25
- package/dist/mapping/loaders/tiny-loom-selection.js +10 -3
- package/dist/mapping/parsers/tiny.js +4 -0
- package/dist/mapping-service.js +24 -3
- package/dist/maven-resolver.d.ts +61 -0
- package/dist/maven-resolver.js +95 -4
- package/dist/maven-token.d.ts +65 -0
- package/dist/maven-token.js +120 -0
- package/dist/mcp-helpers.js +14 -3
- package/dist/minecraft-explorer-service.d.ts +13 -1
- package/dist/minecraft-explorer-service.js +200 -7
- package/dist/repo-downloader.d.ts +260 -0
- package/dist/repo-downloader.js +1083 -14
- package/dist/source/artifact-resolver.d.ts +97 -3
- package/dist/source/artifact-resolver.js +228 -24
- package/dist/source/class-source.d.ts +15 -0
- package/dist/source/class-source.js +163 -18
- package/dist/source/indexer.js +14 -0
- package/dist/source/nested-jars.d.ts +16 -1
- package/dist/source/nested-jars.js +66 -3
- package/dist/source/workspace-target.js +39 -20
- package/dist/source-jar-reader.d.ts +30 -2
- package/dist/source-jar-reader.js +39 -8
- package/dist/source-resolver.d.ts +48 -0
- package/dist/source-resolver.js +735 -81
- package/dist/source-service.d.ts +10 -1
- package/dist/stdio-supervisor.d.ts +75 -1
- package/dist/stdio-supervisor.js +240 -14
- package/dist/storage/artifacts-repo.d.ts +2 -0
- package/dist/storage/artifacts-repo.js +22 -0
- package/dist/storage/db.js +32 -10
- package/dist/tool-guidance.js +17 -7
- package/dist/tool-schemas.d.ts +10 -2
- package/dist/tool-schemas.js +4 -0
- package/dist/v1-parity-schemas.js +16 -0
- package/dist/version-diff-service.js +25 -4
- package/dist/version-service.js +23 -8
- package/dist/warning-details.js +3 -1
- package/dist/workspace-mapping-service.d.ts +8 -1
- package/dist/workspace-mapping-service.js +28 -19
- package/docs/tool-reference.md +31 -7
- package/package.json +6 -4
package/dist/error-mapping.js
CHANGED
|
@@ -32,6 +32,122 @@ export function extractDidYouMean(details) {
|
|
|
32
32
|
}
|
|
33
33
|
return cleaned.slice(0, MAX_DID_YOU_MEAN_ENTRIES);
|
|
34
34
|
}
|
|
35
|
+
// A shell jar bundles a handful of inner jars, not hundreds; the cap only
|
|
36
|
+
// bounds a pathological inventory, it is not an expected truncation point.
|
|
37
|
+
const MAX_NESTED_JAR_ENTRIES = 64;
|
|
38
|
+
// Per-entry length ceiling, in UTF-8 bytes. Real entries are jar-relative paths
|
|
39
|
+
// like "META-INF/jars/fabric-screen-handler-api-v1-2.0.5.jar" (~52 bytes); the
|
|
40
|
+
// zip format itself allows a 65535-byte name, so an entry name is attacker-sized
|
|
41
|
+
// unless bounded here. 256 bytes is ~5x the longest realistic entry and still
|
|
42
|
+
// leaves any genuine name intact.
|
|
43
|
+
const MAX_NESTED_JAR_ENTRY_BYTES = 256;
|
|
44
|
+
// Total ceiling for the published list, in UTF-8 bytes. A full fabric-api-style
|
|
45
|
+
// inventory (64 entries at ~52 bytes) is ~3.3 KiB, so 8 KiB carries every real
|
|
46
|
+
// inventory whole while bounding the worst case this field can add to an error
|
|
47
|
+
// payload — which matters because nothing else in this project bounds an
|
|
48
|
+
// outbound response (MCP_MAX_FRAME_BYTES governs inbound decoding only).
|
|
49
|
+
const MAX_NESTED_JARS_TOTAL_BYTES = 8 * 1024;
|
|
50
|
+
/**
|
|
51
|
+
* Validates and extracts a `nestedJars` inventory from error details.
|
|
52
|
+
* `buildClassSourceNotFoundError` records it whenever the lookup ran against a
|
|
53
|
+
* shell jar, but `context` is primitive-only, so without this the inventory
|
|
54
|
+
* never left the process.
|
|
55
|
+
*
|
|
56
|
+
* Two distinct dispositions, deliberately not conflated:
|
|
57
|
+
* - MALFORMED (a non-string or empty-string element, anywhere in the array):
|
|
58
|
+
* the whole array is dropped, matching {@link extractDidYouMean}. The scan
|
|
59
|
+
* deliberately continues past the caps so a malformed element beyond them is
|
|
60
|
+
* still found — the upstream array is a real jar's own entry list, bounded by
|
|
61
|
+
* the archive, so the full scan is not an exposure.
|
|
62
|
+
* - OVERSIZED (an entry longer than {@link MAX_NESTED_JAR_ENTRY_BYTES}, an
|
|
63
|
+
* entry past the count cap, or one that would push the list past the total
|
|
64
|
+
* byte budget): that ENTRY alone is excluded and `nestedJarsTruncated` is
|
|
65
|
+
* set. An implausible name is not evidence that the rest of the inventory is
|
|
66
|
+
* untrustworthy, so it must not drop the array.
|
|
67
|
+
*
|
|
68
|
+
* An empty inventory is dropped as before: the producing site omits the key
|
|
69
|
+
* entirely in that case, so an empty array carries no information a caller could
|
|
70
|
+
* act on. If the caps leave nothing publishable, both keys are omitted rather
|
|
71
|
+
* than publishing a truncation flag with no list beside it.
|
|
72
|
+
*/
|
|
73
|
+
export function extractNestedJarsField(details) {
|
|
74
|
+
const raw = details?.nestedJars;
|
|
75
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
const cleaned = [];
|
|
79
|
+
let truncated = false;
|
|
80
|
+
let totalBytes = 0;
|
|
81
|
+
let budgetExhausted = false;
|
|
82
|
+
for (const entry of raw) {
|
|
83
|
+
if (typeof entry !== "string" || !entry) {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
if (budgetExhausted || cleaned.length >= MAX_NESTED_JAR_ENTRIES) {
|
|
87
|
+
truncated = true;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const entryBytes = Buffer.byteLength(entry, "utf8");
|
|
91
|
+
if (entryBytes > MAX_NESTED_JAR_ENTRY_BYTES) {
|
|
92
|
+
truncated = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (totalBytes + entryBytes > MAX_NESTED_JARS_TOTAL_BYTES) {
|
|
96
|
+
// Keep the published list a prefix of the surviving entries: once the
|
|
97
|
+
// budget is spent, stop admitting rather than cherry-picking short names
|
|
98
|
+
// from the tail.
|
|
99
|
+
truncated = true;
|
|
100
|
+
budgetExhausted = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
totalBytes += entryBytes;
|
|
104
|
+
cleaned.push(entry);
|
|
105
|
+
}
|
|
106
|
+
if (cleaned.length === 0) {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
return { nestedJars: cleaned, ...(truncated ? { nestedJarsTruncated: true } : {}) };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Inventory-only view of {@link extractNestedJarsField}.
|
|
113
|
+
*
|
|
114
|
+
* NOT for an emission site. All three - the tool envelope, the batch entry, the
|
|
115
|
+
* error resource - publish the field pair, because dropping the flag makes a
|
|
116
|
+
* shortened inventory indistinguishable from a complete one. This remains for
|
|
117
|
+
* callers that want the list alone (tests pinning the validation and capping
|
|
118
|
+
* rules), and a new publisher should reach for the field-returning form.
|
|
119
|
+
*/
|
|
120
|
+
export function extractNestedJars(details) {
|
|
121
|
+
return extractNestedJarsField(details).nestedJars;
|
|
122
|
+
}
|
|
123
|
+
const ISSUE_ORIGIN_VALUES = new Set([
|
|
124
|
+
"code_issue",
|
|
125
|
+
"tool_issue",
|
|
126
|
+
"environment"
|
|
127
|
+
]);
|
|
128
|
+
/**
|
|
129
|
+
* Per-throw-site {@link IssueOrigin} override carried on an AppError's
|
|
130
|
+
* `details.issueOrigin`.
|
|
131
|
+
*
|
|
132
|
+
* The default classification is keyed purely on the error CODE, which is right
|
|
133
|
+
* for codes whose every throw site shares one origin. A few codes do not:
|
|
134
|
+
* `ERR_CONTEXT_UNRESOLVED` covers both a caller naming a version no artifact
|
|
135
|
+
* carries (genuinely `code_issue`) and the tool resolving an artifact with no
|
|
136
|
+
* binary jar behind the caller's back (`tool_issue`). Publishing the latter as
|
|
137
|
+
* caller-fixable sends an agent into a retry loop over input it cannot repair.
|
|
138
|
+
*
|
|
139
|
+
* This override is deliberately opt-in and per-error: `issueOriginForErrorCode`
|
|
140
|
+
* and its default sets stay untouched, so the exhaustive classification map in
|
|
141
|
+
* tests/runtime/error-mapping.test.ts keeps forcing a conscious decision for
|
|
142
|
+
* every new code. The key is not in `CONTEXT_ALLOWLIST`, so it cannot leak into
|
|
143
|
+
* the public `context` blob.
|
|
144
|
+
*/
|
|
145
|
+
export function extractIssueOriginOverride(details) {
|
|
146
|
+
const raw = details?.issueOrigin;
|
|
147
|
+
return typeof raw === "string" && ISSUE_ORIGIN_VALUES.has(raw)
|
|
148
|
+
? raw
|
|
149
|
+
: undefined;
|
|
150
|
+
}
|
|
35
151
|
export function statusForErrorCode(code) {
|
|
36
152
|
if (code === ERROR_CODES.BATCH_ABORTED) {
|
|
37
153
|
return 412;
|
|
@@ -200,6 +316,51 @@ export function issueOriginForErrorCode(code) {
|
|
|
200
316
|
}
|
|
201
317
|
return "tool_issue";
|
|
202
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* The single source of truth for a ProblemDetails classification pair, and the
|
|
321
|
+
* ONLY supported way to compute one for an error the server caught. Every site
|
|
322
|
+
* that turns a caught error into a published envelope spreads this result — the
|
|
323
|
+
* tool envelope (`mapErrorToProblem`), batch entries, the batch-aborted
|
|
324
|
+
* sentinel, and the `mc://` resource read — so one failure always publishes the
|
|
325
|
+
* same `{ retryClass, issueOrigin }` no matter which access path the caller
|
|
326
|
+
* used.
|
|
327
|
+
*
|
|
328
|
+
* KNOWN EXCEPTION — the synthetic supervisor replies in `src/stdio-supervisor.ts`
|
|
329
|
+
* (`buildSupervisorQueueLimitReply`, `buildValidateProjectTimeoutReply`) write
|
|
330
|
+
* the pair as literals and never import this builder. They are not emission
|
|
331
|
+
* sites for a caught error at all: the supervisor answers a request the worker
|
|
332
|
+
* never got to run, so there is no source `AppError` whose `details` could carry
|
|
333
|
+
* an override and nothing to pass as the required `details` argument. Their
|
|
334
|
+
* literals are `transient` / `tool_issue`, which is exactly what this function
|
|
335
|
+
* returns for `ERR_LIMIT_EXCEEDED` and `ERR_TOOL_TIMEOUT` today — but that is a
|
|
336
|
+
* duplicated value held in agreement by hand, not a second call into this
|
|
337
|
+
* classifier. Reclassifying either code here must be mirrored there.
|
|
338
|
+
*
|
|
339
|
+
* It exists because that pair used to be assembled by hand at each site. The
|
|
340
|
+
* `issueOrigin` half has a per-throw-site override (see
|
|
341
|
+
* {@link extractIssueOriginOverride}), and honouring it was copy-pasted into
|
|
342
|
+
* some sites and forgotten in others — which is exactly how one identical
|
|
343
|
+
* AppError came to publish `tool_issue` through a tool call and `code_issue`
|
|
344
|
+
* through a resource read. Composing the pair here removes the opportunity:
|
|
345
|
+
* emission modules import this function and no longer reach the component
|
|
346
|
+
* classifiers at all.
|
|
347
|
+
*
|
|
348
|
+
* `details` is REQUIRED, deliberately without a default. A site that genuinely
|
|
349
|
+
* has no AppError behind it (a ZodError, a sanitized non-AppError, the
|
|
350
|
+
* batch-aborted sentinel) must pass `undefined` explicitly, so skipping the
|
|
351
|
+
* override can only ever be a written-down decision rather than an omission.
|
|
352
|
+
*
|
|
353
|
+
* Only `issueOrigin` is overridable per throw site. `retryClass` stays purely
|
|
354
|
+
* code-derived: it is a documented wire contract, and changing the value an
|
|
355
|
+
* existing code publishes is a Breaking change, so a `retryClass` override seam
|
|
356
|
+
* was deliberately deferred rather than added alongside this one.
|
|
357
|
+
*/
|
|
358
|
+
export function problemClassification(code, details) {
|
|
359
|
+
return {
|
|
360
|
+
retryClass: retryClassForErrorCode(code),
|
|
361
|
+
issueOrigin: extractIssueOriginOverride(details) ?? issueOriginForErrorCode(code)
|
|
362
|
+
};
|
|
363
|
+
}
|
|
203
364
|
// Non-sensitive AppError.details fields that are safe to echo to callers as
|
|
204
365
|
// machine-readable repair context. Filesystem paths and free-form text are
|
|
205
366
|
// intentionally excluded.
|
|
@@ -220,7 +381,14 @@ const CONTEXT_ALLOWLIST = new Set([
|
|
|
220
381
|
"maxMembers",
|
|
221
382
|
"candidateCount",
|
|
222
383
|
"candidatesSeen",
|
|
223
|
-
"ambiguous"
|
|
384
|
+
"ambiguous",
|
|
385
|
+
// Why a repository cascade gave up, as the failing leg's own error code (e.g.
|
|
386
|
+
// "ERR_LIMIT_EXCEEDED"). The terminal ERR_REPO_FETCH_FAILED says only that
|
|
387
|
+
// repositories were unstable, which is wrong for a configuration-driven
|
|
388
|
+
// refusal; the underlying code is the machine-readable half of that
|
|
389
|
+
// correction, beside the human-readable `nextAction` hint. A bare code string
|
|
390
|
+
// - no url, no path, no message - which is why it can travel here at all.
|
|
391
|
+
"repoFailureCode"
|
|
224
392
|
]);
|
|
225
393
|
/**
|
|
226
394
|
* Pick the allowlisted, primitive-valued fields out of an AppError's `details`
|
|
@@ -282,6 +450,7 @@ export function errorToBatchEntryProblem(caughtError, instance, options) {
|
|
|
282
450
|
const fieldErrors = extractFieldErrors(caughtError.details);
|
|
283
451
|
const context = extractAllowlistedContext(caughtError.details);
|
|
284
452
|
const didYouMean = extractDidYouMean(caughtError.details);
|
|
453
|
+
const nestedJarsField = extractNestedJarsField(caughtError.details);
|
|
285
454
|
return {
|
|
286
455
|
type: `https://minecraft-modding-mcp.dev/problems/${caughtError.code.toLowerCase()}`,
|
|
287
456
|
title: "Tool execution error",
|
|
@@ -289,12 +458,12 @@ export function errorToBatchEntryProblem(caughtError, instance, options) {
|
|
|
289
458
|
status: statusForErrorCode(caughtError.code),
|
|
290
459
|
code: caughtError.code,
|
|
291
460
|
instance,
|
|
292
|
-
|
|
293
|
-
issueOrigin: issueOriginForErrorCode(caughtError.code),
|
|
461
|
+
...problemClassification(caughtError.code, caughtError.details),
|
|
294
462
|
...(fieldErrors ? { fieldErrors } : {}),
|
|
295
463
|
...(baseHints ? { hints: baseHints } : {}),
|
|
296
464
|
...(options?.suggestedCall ? { suggestedCall: options.suggestedCall } : {}),
|
|
297
465
|
...(didYouMean ? { didYouMean } : {}),
|
|
466
|
+
...nestedJarsField,
|
|
298
467
|
...(context ? { context } : {})
|
|
299
468
|
};
|
|
300
469
|
}
|
|
@@ -314,8 +483,9 @@ export function errorToBatchEntryProblem(caughtError, instance, options) {
|
|
|
314
483
|
status: 500,
|
|
315
484
|
code: ERROR_CODES.INTERNAL,
|
|
316
485
|
instance,
|
|
317
|
-
|
|
318
|
-
|
|
486
|
+
// No AppError behind this envelope: the throw was sanitized away, so there
|
|
487
|
+
// is no per-site override to honour.
|
|
488
|
+
...problemClassification(ERROR_CODES.INTERNAL, undefined),
|
|
319
489
|
...(options?.suggestedCall ? { suggestedCall: options.suggestedCall } : {})
|
|
320
490
|
};
|
|
321
491
|
}
|
|
@@ -327,8 +497,8 @@ export function buildBatchAbortedProblem(instance) {
|
|
|
327
497
|
status: 412,
|
|
328
498
|
code: ERROR_CODES.BATCH_ABORTED,
|
|
329
499
|
instance,
|
|
330
|
-
|
|
331
|
-
|
|
500
|
+
// Synthesized sentinel, not a caught AppError: no per-site override exists.
|
|
501
|
+
...problemClassification(ERROR_CODES.BATCH_ABORTED, undefined)
|
|
332
502
|
};
|
|
333
503
|
}
|
|
334
504
|
//# sourceMappingURL=error-mapping.js.map
|
package/dist/index.js
CHANGED
|
@@ -175,7 +175,9 @@ const validateProjectService = new ValidateProjectService({
|
|
|
175
175
|
const manageCacheService = new ManageCacheService({
|
|
176
176
|
registry: createCacheRegistry({
|
|
177
177
|
cacheDir: config.cacheDir,
|
|
178
|
-
sqlitePath: config.sqlitePath
|
|
178
|
+
sqlitePath: config.sqlitePath,
|
|
179
|
+
sqliteCacheKb: config.sqliteCacheKb,
|
|
180
|
+
sqliteMmapSize: config.sqliteMmapSize
|
|
179
181
|
})
|
|
180
182
|
});
|
|
181
183
|
const verifyMixinTargetService = new VerifyMixinTargetService({
|
|
@@ -5,6 +5,14 @@ export type ParsedJsonRpcFrame = {
|
|
|
5
5
|
message: JSONRPCMessage;
|
|
6
6
|
mode: ConcreteFramingMode;
|
|
7
7
|
};
|
|
8
|
+
/**
|
|
9
|
+
* The opaque handle a {@link JsonRpcFrameReader}'s idle-budget timer is
|
|
10
|
+
* identified by. Only the reader's own scheduler/clearer pair interprets it,
|
|
11
|
+
* so a test can substitute a plain object for a real timer.
|
|
12
|
+
*/
|
|
13
|
+
export type FrameIdleTimerHandle = unknown;
|
|
14
|
+
export type FrameIdleTimerScheduler = (callback: () => void, delayMs: number) => FrameIdleTimerHandle;
|
|
15
|
+
export type FrameIdleTimerClearer = (handle: FrameIdleTimerHandle) => void;
|
|
8
16
|
/**
|
|
9
17
|
* A framing violation the reader cannot provably recover from.
|
|
10
18
|
*
|
|
@@ -32,10 +40,15 @@ export type ParsedJsonRpcFrame = {
|
|
|
32
40
|
* - an oversized Content-Length whose declared body has NOT fully arrived
|
|
33
41
|
* (waiting on it is what let a single unanswerable header wedge the
|
|
34
42
|
* transport for the process lifetime),
|
|
43
|
+
* - an UNDER-limit Content-Length whose declared body stops arriving for the
|
|
44
|
+
* incomplete-frame idle budget — the same wedge, below the size check,
|
|
35
45
|
* - a Content-Length body that is not valid JSON — under-declaration,
|
|
36
46
|
* over-declaration and an honestly-framed bad body are indistinguishable,
|
|
37
47
|
* and the first two have already desynchronized the stream,
|
|
38
|
-
* -
|
|
48
|
+
* - a header block that declared a body length and then contradicted it
|
|
49
|
+
* (duplicate Content-Length headers, a non-numeric value alongside a
|
|
50
|
+
* numeric one, junk after a good declaration) — bytes the peer counted as
|
|
51
|
+
* body would otherwise be re-read as frames,
|
|
39
52
|
* - a Content-Length header block that never terminates within the header
|
|
40
53
|
* limit — there is no delimiter left to resynchronize on.
|
|
41
54
|
*/
|
|
@@ -54,14 +67,34 @@ export declare function loadMaxFrameBytes(value?: string | undefined): number;
|
|
|
54
67
|
export declare function encodeJsonRpcMessage(message: JSONRPCMessage, mode: ConcreteFramingMode): Buffer;
|
|
55
68
|
export declare class JsonRpcFrameReader {
|
|
56
69
|
private readonly maxFrameBytes;
|
|
70
|
+
private readonly incompleteFrameIdleMs;
|
|
71
|
+
private readonly scheduleTimer;
|
|
72
|
+
private readonly clearTimer;
|
|
57
73
|
private mode;
|
|
58
74
|
private buffer;
|
|
59
75
|
private pendingChunks;
|
|
60
76
|
private pendingBytes;
|
|
61
77
|
private awaitedFrameEnd;
|
|
78
|
+
private awaitedBodyStart;
|
|
79
|
+
private idleTimer;
|
|
62
80
|
private fatal;
|
|
81
|
+
/**
|
|
82
|
+
* @param options.maxFrameBytes Largest accepted frame; defaults to
|
|
83
|
+
* {@link loadMaxFrameBytes}.
|
|
84
|
+
* @param options.incompleteFrameIdleMs How long a declared Content-Length
|
|
85
|
+
* body may stop arriving before the session is terminated. This is IDLE
|
|
86
|
+
* time, not total time: every arriving byte clears and re-arms it, so a
|
|
87
|
+
* legitimately slow or very large body is never cut off. Defaults to
|
|
88
|
+
* 30 000 ms; a non-positive or non-finite value disables the budget.
|
|
89
|
+
* @param options.timerScheduler Schedules the idle budget; defaults to an
|
|
90
|
+
* `unref()`'d `setTimeout`. Injectable so tests can drive it directly.
|
|
91
|
+
* @param options.timerClearer Cancels a handle from `timerScheduler`.
|
|
92
|
+
*/
|
|
63
93
|
constructor(options?: {
|
|
64
94
|
maxFrameBytes?: number;
|
|
95
|
+
incompleteFrameIdleMs?: number;
|
|
96
|
+
timerScheduler?: FrameIdleTimerScheduler;
|
|
97
|
+
timerClearer?: FrameIdleTimerClearer;
|
|
65
98
|
});
|
|
66
99
|
get currentMode(): FramingMode;
|
|
67
100
|
/**
|
|
@@ -76,6 +109,25 @@ export declare class JsonRpcFrameReader {
|
|
|
76
109
|
onFrame: (frame: ParsedJsonRpcFrame) => void;
|
|
77
110
|
onError: (error: Error) => void;
|
|
78
111
|
}): void;
|
|
112
|
+
private drainChunk;
|
|
113
|
+
private clearIdleTimer;
|
|
114
|
+
/**
|
|
115
|
+
* Puts an incomplete declared body on an idle clock.
|
|
116
|
+
*
|
|
117
|
+
* A Content-Length UNDER the frame limit whose body never arrives was the
|
|
118
|
+
* one wedge the size check could not see: `readContentLengthMessage` armed
|
|
119
|
+
* `awaitedFrameEnd`, `canCompleteFrame` then refused to look at anything
|
|
120
|
+
* until that many bytes existed, and every later frame piled up behind a
|
|
121
|
+
* body that was never coming. The budget is idle time — the caller clears it
|
|
122
|
+
* on every arriving chunk and this re-arms it — so only a stream that has
|
|
123
|
+
* gone silent mid-body is terminated.
|
|
124
|
+
*
|
|
125
|
+
* `onError` belongs to a `processChunk` call, so the timer fires through the
|
|
126
|
+
* handlers of the MOST RECENT call rather than the one that armed it. Every
|
|
127
|
+
* caller in this repository passes a stable handler pair on every chunk, and
|
|
128
|
+
* a caller that does not still gets a live pair rather than a stale one.
|
|
129
|
+
*/
|
|
130
|
+
private armIdleTimer;
|
|
79
131
|
private canCompleteFrame;
|
|
80
132
|
private rejectOversizedIncompleteInput;
|
|
81
133
|
/**
|
package/dist/json-rpc-framing.js
CHANGED
|
@@ -1,13 +1,34 @@
|
|
|
1
|
+
import { clearTimeout as clearNodeTimeout, setTimeout as setNodeTimeout } from "node:timers";
|
|
1
2
|
import { parseJSONRPCMessage } from "@modelcontextprotocol/server";
|
|
2
3
|
const DEFAULT_MAX_FRAME_BYTES = 64 * 1024 * 1024;
|
|
3
4
|
const MIN_MAX_FRAME_BYTES = 1024 * 1024;
|
|
4
5
|
const MAX_CONTENT_LENGTH_HEADER_BYTES = 8 * 1024;
|
|
6
|
+
const DEFAULT_INCOMPLETE_FRAME_IDLE_MS = 30_000;
|
|
7
|
+
const scheduleIdleTimer = (callback, delayMs) => {
|
|
8
|
+
const timer = setNodeTimeout(callback, delayMs);
|
|
9
|
+
// The budget must never be the reason a process stays alive: a reader parked
|
|
10
|
+
// on an incomplete body would otherwise hold the event loop open for the
|
|
11
|
+
// whole budget after every other handle had closed.
|
|
12
|
+
timer.unref();
|
|
13
|
+
return timer;
|
|
14
|
+
};
|
|
15
|
+
const clearIdleTimerHandle = (handle) => {
|
|
16
|
+
clearNodeTimeout(handle);
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The FIRST header terminator in the buffer, whichever style it is.
|
|
20
|
+
*
|
|
21
|
+
* Preferring a CRLFCRLF found anywhere over an earlier LFLF mis-framed every
|
|
22
|
+
* LF-framed peer whose JSON body happened to contain a raw `\r\n\r\n` — legal
|
|
23
|
+
* inter-token whitespace — because the header block was then cut at a boundary
|
|
24
|
+
* inside the body, losing that frame and the next.
|
|
25
|
+
*/
|
|
5
26
|
function findHeaderBoundary(buffer) {
|
|
6
27
|
const crlfBoundary = buffer.indexOf("\r\n\r\n");
|
|
7
|
-
|
|
28
|
+
const lfBoundary = buffer.indexOf("\n\n");
|
|
29
|
+
if (crlfBoundary !== -1 && (lfBoundary === -1 || crlfBoundary < lfBoundary)) {
|
|
8
30
|
return { index: crlfBoundary, delimiterBytes: 4 };
|
|
9
31
|
}
|
|
10
|
-
const lfBoundary = buffer.indexOf("\n\n");
|
|
11
32
|
if (lfBoundary !== -1) {
|
|
12
33
|
return { index: lfBoundary, delimiterBytes: 2 };
|
|
13
34
|
}
|
|
@@ -46,10 +67,15 @@ function asError(value) {
|
|
|
46
67
|
* - an oversized Content-Length whose declared body has NOT fully arrived
|
|
47
68
|
* (waiting on it is what let a single unanswerable header wedge the
|
|
48
69
|
* transport for the process lifetime),
|
|
70
|
+
* - an UNDER-limit Content-Length whose declared body stops arriving for the
|
|
71
|
+
* incomplete-frame idle budget — the same wedge, below the size check,
|
|
49
72
|
* - a Content-Length body that is not valid JSON — under-declaration,
|
|
50
73
|
* over-declaration and an honestly-framed bad body are indistinguishable,
|
|
51
74
|
* and the first two have already desynchronized the stream,
|
|
52
|
-
* -
|
|
75
|
+
* - a header block that declared a body length and then contradicted it
|
|
76
|
+
* (duplicate Content-Length headers, a non-numeric value alongside a
|
|
77
|
+
* numeric one, junk after a good declaration) — bytes the peer counted as
|
|
78
|
+
* body would otherwise be re-read as frames,
|
|
53
79
|
* - a Content-Length header block that never terminates within the header
|
|
54
80
|
* limit — there is no delimiter left to resynchronize on.
|
|
55
81
|
*/
|
|
@@ -87,14 +113,34 @@ export function encodeJsonRpcMessage(message, mode) {
|
|
|
87
113
|
}
|
|
88
114
|
export class JsonRpcFrameReader {
|
|
89
115
|
maxFrameBytes;
|
|
116
|
+
incompleteFrameIdleMs;
|
|
117
|
+
scheduleTimer;
|
|
118
|
+
clearTimer;
|
|
90
119
|
mode = "unknown";
|
|
91
120
|
buffer = Buffer.alloc(0);
|
|
92
121
|
pendingChunks = [];
|
|
93
122
|
pendingBytes = 0;
|
|
94
123
|
awaitedFrameEnd = -1;
|
|
124
|
+
awaitedBodyStart = -1;
|
|
125
|
+
idleTimer;
|
|
95
126
|
fatal = false;
|
|
127
|
+
/**
|
|
128
|
+
* @param options.maxFrameBytes Largest accepted frame; defaults to
|
|
129
|
+
* {@link loadMaxFrameBytes}.
|
|
130
|
+
* @param options.incompleteFrameIdleMs How long a declared Content-Length
|
|
131
|
+
* body may stop arriving before the session is terminated. This is IDLE
|
|
132
|
+
* time, not total time: every arriving byte clears and re-arms it, so a
|
|
133
|
+
* legitimately slow or very large body is never cut off. Defaults to
|
|
134
|
+
* 30 000 ms; a non-positive or non-finite value disables the budget.
|
|
135
|
+
* @param options.timerScheduler Schedules the idle budget; defaults to an
|
|
136
|
+
* `unref()`'d `setTimeout`. Injectable so tests can drive it directly.
|
|
137
|
+
* @param options.timerClearer Cancels a handle from `timerScheduler`.
|
|
138
|
+
*/
|
|
96
139
|
constructor(options = {}) {
|
|
97
140
|
this.maxFrameBytes = options.maxFrameBytes ?? loadMaxFrameBytes();
|
|
141
|
+
this.incompleteFrameIdleMs = options.incompleteFrameIdleMs ?? DEFAULT_INCOMPLETE_FRAME_IDLE_MS;
|
|
142
|
+
this.scheduleTimer = options.timerScheduler ?? scheduleIdleTimer;
|
|
143
|
+
this.clearTimer = options.timerClearer ?? clearIdleTimerHandle;
|
|
98
144
|
}
|
|
99
145
|
get currentMode() {
|
|
100
146
|
return this.mode;
|
|
@@ -108,22 +154,37 @@ export class JsonRpcFrameReader {
|
|
|
108
154
|
return this.fatal;
|
|
109
155
|
}
|
|
110
156
|
reset() {
|
|
157
|
+
this.clearIdleTimer();
|
|
111
158
|
this.mode = "unknown";
|
|
112
159
|
this.awaitedFrameEnd = -1;
|
|
160
|
+
this.awaitedBodyStart = -1;
|
|
113
161
|
this.fatal = false;
|
|
114
162
|
}
|
|
115
163
|
clear() {
|
|
164
|
+
this.clearIdleTimer();
|
|
116
165
|
this.mode = "unknown";
|
|
117
166
|
this.buffer = Buffer.alloc(0);
|
|
118
167
|
this.pendingChunks = [];
|
|
119
168
|
this.pendingBytes = 0;
|
|
120
169
|
this.awaitedFrameEnd = -1;
|
|
170
|
+
this.awaitedBodyStart = -1;
|
|
121
171
|
this.fatal = false;
|
|
122
172
|
}
|
|
123
173
|
processChunk(chunk, handlers) {
|
|
124
174
|
if (chunk.length === 0 || this.fatal) {
|
|
125
175
|
return;
|
|
126
176
|
}
|
|
177
|
+
// Bytes arrived, so any pending idle budget is stale; it is re-armed below
|
|
178
|
+
// only if this chunk leaves a declared body still incomplete.
|
|
179
|
+
this.clearIdleTimer();
|
|
180
|
+
try {
|
|
181
|
+
this.drainChunk(chunk, handlers);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
this.armIdleTimer(handlers);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
drainChunk(chunk, handlers) {
|
|
127
188
|
this.pendingChunks.push(chunk);
|
|
128
189
|
this.pendingBytes += chunk.length;
|
|
129
190
|
if (!this.canCompleteFrame(chunk)) {
|
|
@@ -153,15 +214,34 @@ export class JsonRpcFrameReader {
|
|
|
153
214
|
}
|
|
154
215
|
return;
|
|
155
216
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
217
|
+
try {
|
|
218
|
+
handlers.onFrame({
|
|
219
|
+
message,
|
|
220
|
+
mode: this.mode
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
catch (handlerError) {
|
|
224
|
+
// The frame HANDLER threw, not the framer. This frame's bytes are
|
|
225
|
+
// already consumed and the framing state describes the stream
|
|
226
|
+
// correctly, so it must survive untouched: sharing the framing
|
|
227
|
+
// try/catch turned a handler bug into a silent framing change (the
|
|
228
|
+
// compat transport picks its response framing from `currentMode`,
|
|
229
|
+
// so a reset here downgraded later replies to line framing) and left
|
|
230
|
+
// the offending request unanswered with no distinguishing signal.
|
|
231
|
+
// The failure is still surfaced through `onError` — the path every
|
|
232
|
+
// transport already handles — but as a plain Error, so a handler can
|
|
233
|
+
// never forge the framing-fatal signal that tears a session down.
|
|
234
|
+
const handlerFault = asError(handlerError);
|
|
235
|
+
handlers.onError(new Error(`JSON-RPC frame handler failed: ${handlerFault.message}`, {
|
|
236
|
+
cause: handlerFault
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
160
239
|
}
|
|
161
240
|
catch (caughtError) {
|
|
162
241
|
const error = asError(caughtError);
|
|
163
242
|
this.mode = "unknown";
|
|
164
243
|
this.awaitedFrameEnd = -1;
|
|
244
|
+
this.awaitedBodyStart = -1;
|
|
165
245
|
if (error instanceof JsonRpcFramingFatalError) {
|
|
166
246
|
// Terminal: drop everything buffered and refuse all further input so
|
|
167
247
|
// no byte after the violation can be mistaken for a frame. The
|
|
@@ -177,6 +257,63 @@ export class JsonRpcFrameReader {
|
|
|
177
257
|
}
|
|
178
258
|
}
|
|
179
259
|
}
|
|
260
|
+
clearIdleTimer() {
|
|
261
|
+
if (this.idleTimer === undefined) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const handle = this.idleTimer;
|
|
265
|
+
this.idleTimer = undefined;
|
|
266
|
+
this.clearTimer(handle);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Puts an incomplete declared body on an idle clock.
|
|
270
|
+
*
|
|
271
|
+
* A Content-Length UNDER the frame limit whose body never arrives was the
|
|
272
|
+
* one wedge the size check could not see: `readContentLengthMessage` armed
|
|
273
|
+
* `awaitedFrameEnd`, `canCompleteFrame` then refused to look at anything
|
|
274
|
+
* until that many bytes existed, and every later frame piled up behind a
|
|
275
|
+
* body that was never coming. The budget is idle time — the caller clears it
|
|
276
|
+
* on every arriving chunk and this re-arms it — so only a stream that has
|
|
277
|
+
* gone silent mid-body is terminated.
|
|
278
|
+
*
|
|
279
|
+
* `onError` belongs to a `processChunk` call, so the timer fires through the
|
|
280
|
+
* handlers of the MOST RECENT call rather than the one that armed it. Every
|
|
281
|
+
* caller in this repository passes a stable handler pair on every chunk, and
|
|
282
|
+
* a caller that does not still gets a live pair rather than a stale one.
|
|
283
|
+
*/
|
|
284
|
+
armIdleTimer(handlers) {
|
|
285
|
+
if (this.fatal || this.awaitedFrameEnd < 0 || this.awaitedBodyStart < 0) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (!Number.isFinite(this.incompleteFrameIdleMs) || this.incompleteFrameIdleMs <= 0) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
let handle;
|
|
292
|
+
const expire = () => {
|
|
293
|
+
if (this.idleTimer === handle) {
|
|
294
|
+
this.idleTimer = undefined;
|
|
295
|
+
}
|
|
296
|
+
if (this.fatal || this.awaitedFrameEnd < 0 || this.awaitedBodyStart < 0) {
|
|
297
|
+
// Cleared, completed or already terminated between scheduling and now.
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const declaredBytes = this.awaitedFrameEnd - this.awaitedBodyStart;
|
|
301
|
+
const arrivedBytes = Math.max(0, this.buffer.length + this.pendingBytes - this.awaitedBodyStart);
|
|
302
|
+
this.fatal = true;
|
|
303
|
+
this.mode = "unknown";
|
|
304
|
+
this.buffer = Buffer.alloc(0);
|
|
305
|
+
this.pendingChunks = [];
|
|
306
|
+
this.pendingBytes = 0;
|
|
307
|
+
this.awaitedFrameEnd = -1;
|
|
308
|
+
this.awaitedBodyStart = -1;
|
|
309
|
+
handlers.onError(new JsonRpcFramingFatalError(`Content-Length declared ${declaredBytes} body bytes but only ${arrivedBytes} arrived ` +
|
|
310
|
+
`within the ${this.incompleteFrameIdleMs} ms incomplete-frame idle budget; the reader ` +
|
|
311
|
+
"cannot resynchronize without trusting bytes that may never be sent, so the stdio " +
|
|
312
|
+
"session is terminated."));
|
|
313
|
+
};
|
|
314
|
+
handle = this.scheduleTimer(expire, this.incompleteFrameIdleMs);
|
|
315
|
+
this.idleTimer = handle;
|
|
316
|
+
}
|
|
180
317
|
canCompleteFrame(chunk) {
|
|
181
318
|
const bufferedBytes = this.buffer.length + this.pendingBytes;
|
|
182
319
|
if (this.mode === "content-length" && this.awaitedFrameEnd >= 0) {
|
|
@@ -237,6 +374,7 @@ export class JsonRpcFrameReader {
|
|
|
237
374
|
if (BigInt(this.buffer.length) >= frameEnd) {
|
|
238
375
|
this.buffer = this.buffer.subarray(Number(frameEnd));
|
|
239
376
|
this.awaitedFrameEnd = -1;
|
|
377
|
+
this.awaitedBodyStart = -1;
|
|
240
378
|
this.mode = "unknown";
|
|
241
379
|
throw new Error(reason);
|
|
242
380
|
}
|
|
@@ -311,6 +449,7 @@ export class JsonRpcFrameReader {
|
|
|
311
449
|
}
|
|
312
450
|
readContentLengthMessage() {
|
|
313
451
|
this.awaitedFrameEnd = -1;
|
|
452
|
+
this.awaitedBodyStart = -1;
|
|
314
453
|
// Skip blank separator lines between frames so the mid-stream mode check
|
|
315
454
|
// below sees the first byte of the next frame.
|
|
316
455
|
while (this.buffer.length > 0) {
|
|
@@ -356,36 +495,61 @@ export class JsonRpcFrameReader {
|
|
|
356
495
|
.split(/\r?\n/)
|
|
357
496
|
.map((line) => line.trim())
|
|
358
497
|
.filter((line) => line.length > 0);
|
|
498
|
+
// The WHOLE block is inspected before anything is thrown. Throwing on the
|
|
499
|
+
// first bad line consumed only the header block, which left the body bytes
|
|
500
|
+
// an EARLIER Content-Length had already declared sitting in the buffer to
|
|
501
|
+
// be re-read as line frames — so `Content-Length: 5x` followed by
|
|
502
|
+
// `Content-Length: 44`, or a good declaration followed by junk, let the
|
|
503
|
+
// peer decide where the reader thought the frame ended.
|
|
359
504
|
let contentLength;
|
|
505
|
+
let declaredValue;
|
|
506
|
+
let contentLengthLines = 0;
|
|
507
|
+
let headerFault;
|
|
360
508
|
for (const headerLine of headerLines) {
|
|
361
509
|
const separatorIndex = headerLine.indexOf(":");
|
|
362
510
|
if (separatorIndex === -1) {
|
|
363
|
-
|
|
364
|
-
|
|
511
|
+
headerFault ??= `Malformed header line: ${headerLine}`;
|
|
512
|
+
continue;
|
|
365
513
|
}
|
|
366
514
|
const headerName = headerLine.slice(0, separatorIndex).trim().toLowerCase();
|
|
367
515
|
const headerValue = headerLine.slice(separatorIndex + 1).trim();
|
|
368
|
-
if (headerName
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
516
|
+
if (headerName !== "content-length") {
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
contentLengthLines += 1;
|
|
520
|
+
if (contentLengthLines > 1) {
|
|
521
|
+
// Two declarations, no way to tell which delimits the body: the
|
|
522
|
+
// classic frame-smuggling shape. Last-wins would hand an attacker
|
|
523
|
+
// the choice of where the reader thinks this frame ends.
|
|
524
|
+
headerFault ??=
|
|
525
|
+
`Duplicate Content-Length header (${declaredValue ?? ""} then ${headerValue})`;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
declaredValue = headerValue;
|
|
529
|
+
if (!/^[0-9]+$/.test(headerValue)) {
|
|
530
|
+
headerFault ??= `Invalid Content-Length header value: ${headerValue}`;
|
|
531
|
+
continue;
|
|
382
532
|
}
|
|
533
|
+
contentLength = BigInt(headerValue);
|
|
534
|
+
}
|
|
535
|
+
const messageStart = headerBoundary.index + headerBoundary.delimiterBytes;
|
|
536
|
+
if (headerFault !== undefined) {
|
|
537
|
+
this.buffer = this.buffer.subarray(messageStart);
|
|
538
|
+
if (contentLength !== undefined || contentLengthLines > 1) {
|
|
539
|
+
// The block named a body length somewhere and then contradicted it, so
|
|
540
|
+
// bytes the peer counted as body may already be buffered. Consuming
|
|
541
|
+
// only the header block would re-dispatch them as frames.
|
|
542
|
+
throw new JsonRpcFramingFatalError(`${headerFault}: the declared body length is ambiguous, so the reader cannot determine ` +
|
|
543
|
+
"where this frame ends; the stdio session is terminated.");
|
|
544
|
+
}
|
|
545
|
+
// No usable length was ever declared, so the delimited header block is
|
|
546
|
+
// all there is to consume and the recovery is provable.
|
|
547
|
+
throw new Error(headerFault);
|
|
383
548
|
}
|
|
384
549
|
if (contentLength === undefined) {
|
|
385
|
-
this.buffer = this.buffer.subarray(
|
|
550
|
+
this.buffer = this.buffer.subarray(messageStart);
|
|
386
551
|
throw new Error("Missing Content-Length header.");
|
|
387
552
|
}
|
|
388
|
-
const messageStart = headerBoundary.index + headerBoundary.delimiterBytes;
|
|
389
553
|
if (contentLength > BigInt(this.maxFrameBytes)) {
|
|
390
554
|
this.rejectDeclaredBody(messageStart, contentLength, `Content-Length ${contentLength.toString()} exceeds the configured frame limit of ` +
|
|
391
555
|
`${this.maxFrameBytes} bytes.`);
|
|
@@ -397,6 +561,7 @@ export class JsonRpcFrameReader {
|
|
|
397
561
|
const frameEnd = messageStart + Number(contentLength);
|
|
398
562
|
if (this.buffer.length < frameEnd) {
|
|
399
563
|
this.awaitedFrameEnd = frameEnd;
|
|
564
|
+
this.awaitedBodyStart = messageStart;
|
|
400
565
|
return undefined;
|
|
401
566
|
}
|
|
402
567
|
const body = this.buffer.subarray(messageStart, frameEnd).toString("utf8");
|