@pascal-app/mcp 1.0.4 → 1.0.6
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 +76 -0
- package/README.md +166 -62
- package/dist/bin/pascal-mcp.js +1 -1
- package/dist/bridge/agent-session.d.ts +18 -0
- package/dist/bridge/agent-session.d.ts.map +1 -0
- package/dist/bridge/agent-session.js +15 -0
- package/dist/bridge/index.d.ts +1 -1
- package/dist/bridge/index.d.ts.map +1 -1
- package/dist/bridge/scene-bridge.d.ts +3 -0
- package/dist/bridge/scene-bridge.d.ts.map +1 -1
- package/dist/bridge/scene-bridge.js +7 -0
- package/dist/lib/safe-fetch.d.ts +22 -4
- package/dist/lib/safe-fetch.d.ts.map +1 -1
- package/dist/lib/safe-fetch.js +233 -134
- package/dist/operations/scene-operations.d.ts +18 -0
- package/dist/operations/scene-operations.d.ts.map +1 -1
- package/dist/operations/scene-operations.js +11 -0
- package/dist/prompts/from-brief.d.ts +1 -1
- package/dist/prompts/from-brief.d.ts.map +1 -1
- package/dist/prompts/from-brief.js +3 -3
- package/dist/prompts/index.d.ts +1 -1
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/iterate-on-feedback.d.ts +1 -1
- package/dist/prompts/iterate-on-feedback.d.ts.map +1 -1
- package/dist/prompts/iterate-on-feedback.js +2 -2
- package/dist/prompts/renovation-from-photos.d.ts +1 -1
- package/dist/prompts/renovation-from-photos.d.ts.map +1 -1
- package/dist/prompts/renovation-from-photos.js +2 -2
- package/dist/prompts/scene-guidance.d.ts.map +1 -1
- package/dist/prompts/scene-guidance.js +10 -3
- package/dist/resources/agent-guide.d.ts +1 -1
- package/dist/resources/agent-guide.d.ts.map +1 -1
- package/dist/resources/agent-guide.js +52 -44
- package/dist/resources/catalog-items.d.ts +1 -1
- package/dist/resources/catalog-items.d.ts.map +1 -1
- package/dist/resources/constraints.d.ts +1 -1
- package/dist/resources/constraints.d.ts.map +1 -1
- package/dist/resources/constraints.js +1 -1
- package/dist/resources/design-schema.d.ts +1 -1
- package/dist/resources/design-schema.d.ts.map +1 -1
- package/dist/resources/index.d.ts +1 -1
- package/dist/resources/index.d.ts.map +1 -1
- package/dist/resources/scene-current.d.ts +1 -1
- package/dist/resources/scene-current.d.ts.map +1 -1
- package/dist/resources/scene-summary.d.ts +1 -1
- package/dist/resources/scene-summary.d.ts.map +1 -1
- package/dist/server.d.ts +7 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +59 -21
- package/dist/storage/disk-artifact-store.d.ts.map +1 -1
- package/dist/storage/sqlite-scene-store.d.ts +1 -1
- package/dist/storage/sqlite-scene-store.d.ts.map +1 -1
- package/dist/storage/types.d.ts +4 -4
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/templates/empty-studio.d.ts +3 -3
- package/dist/templates/empty-studio.d.ts.map +1 -1
- package/dist/templates/garden-house.d.ts +3 -3
- package/dist/templates/garden-house.d.ts.map +1 -1
- package/dist/templates/index.d.ts.map +1 -1
- package/dist/templates/two-bedroom.d.ts +3 -3
- package/dist/templates/two-bedroom.d.ts.map +1 -1
- package/dist/tools/add-column.d.ts +1 -1
- package/dist/tools/add-column.d.ts.map +1 -1
- package/dist/tools/add-column.js +3 -2
- package/dist/tools/add-object.d.ts +19 -3
- package/dist/tools/add-object.d.ts.map +1 -1
- package/dist/tools/add-object.js +66 -35
- package/dist/tools/annotations.d.ts.map +1 -1
- package/dist/tools/apply-patch.d.ts +6 -2
- package/dist/tools/apply-patch.d.ts.map +1 -1
- package/dist/tools/apply-patch.js +13 -8
- package/dist/tools/asset-catalog.d.ts.map +1 -1
- package/dist/tools/asset-catalog.js +23 -0
- package/dist/tools/author-scene.d.ts +10 -0
- package/dist/tools/author-scene.d.ts.map +1 -0
- package/dist/tools/author-scene.js +140 -0
- package/dist/tools/check-collisions.d.ts +6 -6
- package/dist/tools/check-collisions.d.ts.map +1 -1
- package/dist/tools/check-collisions.js +6 -6
- package/dist/tools/clear-scene.d.ts +1 -1
- package/dist/tools/clear-scene.d.ts.map +1 -1
- package/dist/tools/clear-scene.js +3 -3
- package/dist/tools/collections.d.ts +1 -1
- package/dist/tools/collections.d.ts.map +1 -1
- package/dist/tools/collections.js +3 -2
- package/dist/tools/construction-tools.d.ts +6 -49
- package/dist/tools/construction-tools.d.ts.map +1 -1
- package/dist/tools/construction-tools.js +9 -158
- package/dist/tools/create-unit.d.ts +3 -3
- package/dist/tools/create-unit.d.ts.map +1 -1
- package/dist/tools/create-unit.js +2 -2
- package/dist/tools/errors.d.ts +5 -5
- package/dist/tools/errors.d.ts.map +1 -1
- package/dist/tools/errors.js +4 -4
- package/dist/tools/export-glb.d.ts +1 -1
- package/dist/tools/export-glb.d.ts.map +1 -1
- package/dist/tools/export-glb.js +2 -2
- package/dist/tools/export-json.d.ts +1 -1
- package/dist/tools/export-json.d.ts.map +1 -1
- package/dist/tools/export-json.js +2 -2
- package/dist/tools/get-scene.d.ts +1 -1
- package/dist/tools/get-scene.d.ts.map +1 -1
- package/dist/tools/get-scene.js +2 -2
- package/dist/tools/hosted-services.d.ts +1 -1
- package/dist/tools/hosted-services.d.ts.map +1 -1
- package/dist/tools/hosted-services.js +13 -3
- package/dist/tools/index.d.ts +4 -3
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +8 -13
- package/dist/tools/level-height-guard.d.ts +2 -0
- package/dist/tools/level-height-guard.d.ts.map +1 -0
- package/dist/tools/level-height-guard.js +11 -0
- package/dist/tools/list-units.d.ts +2 -2
- package/dist/tools/list-units.d.ts.map +1 -1
- package/dist/tools/list-units.js +2 -2
- package/dist/tools/live-sync.d.ts +1 -1
- package/dist/tools/live-sync.d.ts.map +1 -1
- package/dist/tools/live-sync.js +4 -4
- package/dist/tools/measure.d.ts +1 -1
- package/dist/tools/measure.d.ts.map +1 -1
- package/dist/tools/measure.js +7 -7
- package/dist/tools/node-world-point.d.ts +1 -1
- package/dist/tools/node-world-point.d.ts.map +1 -1
- package/dist/tools/normalize-schema-dialect.d.ts +1 -1
- package/dist/tools/normalize-schema-dialect.d.ts.map +1 -1
- package/dist/tools/normalize-schema-dialect.js +2 -3
- package/dist/tools/patch-guards.d.ts +6 -4
- package/dist/tools/patch-guards.d.ts.map +1 -1
- package/dist/tools/patch-guards.js +5 -2
- package/dist/tools/photo-to-scene/index.d.ts +1 -1
- package/dist/tools/photo-to-scene/index.d.ts.map +1 -1
- package/dist/tools/photo-to-scene/photo-to-scene.d.ts +3 -3
- package/dist/tools/photo-to-scene/photo-to-scene.d.ts.map +1 -1
- package/dist/tools/photo-to-scene/photo-to-scene.js +7 -7
- package/dist/tools/project-files.d.ts +4 -0
- package/dist/tools/project-files.d.ts.map +1 -0
- package/dist/tools/project-files.js +30 -0
- package/dist/tools/redo.d.ts +2 -2
- package/dist/tools/redo.d.ts.map +1 -1
- package/dist/tools/redo.js +2 -2
- package/dist/tools/retired-tools.d.ts +10 -0
- package/dist/tools/retired-tools.d.ts.map +1 -0
- package/dist/tools/retired-tools.js +22 -0
- package/dist/tools/roof-level-guard.d.ts +2 -0
- package/dist/tools/roof-level-guard.d.ts.map +1 -0
- package/dist/tools/roof-level-guard.js +22 -0
- package/dist/tools/room-tools.d.ts +47 -46
- package/dist/tools/room-tools.d.ts.map +1 -1
- package/dist/tools/room-tools.js +6 -5
- package/dist/tools/scene-lifecycle/create-project.d.ts +1 -1
- package/dist/tools/scene-lifecycle/create-project.d.ts.map +1 -1
- package/dist/tools/scene-lifecycle/create-project.js +5 -5
- package/dist/tools/scene-lifecycle/delete-scene.d.ts +1 -1
- package/dist/tools/scene-lifecycle/delete-scene.d.ts.map +1 -1
- package/dist/tools/scene-lifecycle/delete-scene.js +6 -6
- package/dist/tools/scene-lifecycle/get-project-status.d.ts +1 -1
- package/dist/tools/scene-lifecycle/get-project-status.d.ts.map +1 -1
- package/dist/tools/scene-lifecycle/get-project-status.js +6 -6
- package/dist/tools/scene-lifecycle/index.d.ts +1 -1
- package/dist/tools/scene-lifecycle/index.d.ts.map +1 -1
- package/dist/tools/scene-lifecycle/list-scenes.d.ts +1 -1
- package/dist/tools/scene-lifecycle/list-scenes.d.ts.map +1 -1
- package/dist/tools/scene-lifecycle/list-scenes.js +4 -4
- package/dist/tools/scene-lifecycle/load-scene.d.ts +2 -2
- package/dist/tools/scene-lifecycle/load-scene.d.ts.map +1 -1
- package/dist/tools/scene-lifecycle/load-scene.js +5 -5
- package/dist/tools/scene-lifecycle/metadata.d.ts +1 -1
- package/dist/tools/scene-lifecycle/rename-scene.d.ts +1 -1
- package/dist/tools/scene-lifecycle/rename-scene.d.ts.map +1 -1
- package/dist/tools/scene-lifecycle/rename-scene.js +6 -6
- package/dist/tools/scene-lifecycle/save-scene.d.ts +5 -3
- package/dist/tools/scene-lifecycle/save-scene.d.ts.map +1 -1
- package/dist/tools/scene-lifecycle/save-scene.js +80 -12
- package/dist/tools/scene-lifecycle/test-utils.d.ts +1 -1
- package/dist/tools/scene-lifecycle/test-utils.d.ts.map +1 -1
- package/dist/tools/set-unit-members.d.ts +2 -2
- package/dist/tools/set-unit-members.d.ts.map +1 -1
- package/dist/tools/set-unit-members.js +6 -6
- package/dist/tools/set-zone.d.ts +2 -2
- package/dist/tools/set-zone.d.ts.map +1 -1
- package/dist/tools/set-zone.js +30 -18
- package/dist/tools/shared-tools.d.ts +15 -5
- package/dist/tools/shared-tools.d.ts.map +1 -1
- package/dist/tools/shared-tools.js +242 -29
- package/dist/tools/structure-tools.d.ts +3 -3
- package/dist/tools/templates/create-from-template.d.ts +2 -2
- package/dist/tools/templates/create-from-template.d.ts.map +1 -1
- package/dist/tools/templates/create-from-template.js +6 -6
- package/dist/tools/templates/create-house-from-brief.d.ts +1 -1
- package/dist/tools/templates/create-house-from-brief.d.ts.map +1 -1
- package/dist/tools/templates/create-house-from-brief.js +6 -6
- package/dist/tools/templates/index.d.ts +1 -1
- package/dist/tools/templates/index.d.ts.map +1 -1
- package/dist/tools/templates/list-templates.d.ts +1 -1
- package/dist/tools/templates/list-templates.d.ts.map +1 -1
- package/dist/tools/templates/list-templates.js +2 -2
- package/dist/tools/undo.d.ts +2 -2
- package/dist/tools/undo.d.ts.map +1 -1
- package/dist/tools/undo.js +2 -2
- package/dist/tools/unit-members.d.ts.map +1 -1
- package/dist/tools/unit-members.js +6 -6
- package/dist/tools/variants/generate-variants.d.ts +6 -6
- package/dist/tools/variants/generate-variants.d.ts.map +1 -1
- package/dist/tools/variants/generate-variants.js +6 -6
- package/dist/tools/variants/index.d.ts +1 -1
- package/dist/tools/variants/index.d.ts.map +1 -1
- package/dist/tools/view-scene.d.ts +8 -2
- package/dist/tools/view-scene.d.ts.map +1 -1
- package/dist/tools/view-scene.js +17 -5
- package/dist/tools/vision/analyze-floorplan-image.d.ts +1 -1
- package/dist/tools/vision/analyze-floorplan-image.d.ts.map +1 -1
- package/dist/tools/vision/analyze-floorplan-image.js +7 -7
- package/dist/tools/vision/analyze-room-photo.d.ts +1 -1
- package/dist/tools/vision/analyze-room-photo.d.ts.map +1 -1
- package/dist/tools/vision/analyze-room-photo.js +7 -7
- package/dist/tools/vision/index.d.ts +1 -1
- package/dist/tools/vision/index.d.ts.map +1 -1
- package/dist/transports/http.d.ts +8 -2
- package/dist/transports/http.d.ts.map +1 -1
- package/dist/transports/http.js +9 -3
- package/dist/transports/stdio.d.ts +15 -9
- package/dist/transports/stdio.d.ts.map +1 -1
- package/dist/transports/stdio.js +18 -11
- package/examples/coordinate-conventions-demo.md +12 -13
- package/examples/embed-in-agent.ts +5 -6
- package/examples/generate-apartment.md +4 -4
- package/examples/photo-to-scene.md +2 -3
- package/examples/renovate-from-photos.md +3 -5
- package/package.json +13 -9
- package/dist/tools/describe-node.d.ts +0 -17
- package/dist/tools/describe-node.d.ts.map +0 -1
- package/dist/tools/describe-node.js +0 -99
- package/dist/tools/find-nodes.d.ts +0 -16
- package/dist/tools/find-nodes.d.ts.map +0 -1
- package/dist/tools/find-nodes.js +0 -204
- package/dist/tools/place-design.d.ts +0 -34
- package/dist/tools/place-design.d.ts.map +0 -1
- package/dist/tools/place-design.js +0 -91
- package/dist/tools/validate-design.d.ts +0 -136
- package/dist/tools/validate-design.d.ts.map +0 -1
- package/dist/tools/validate-design.js +0 -28
- package/dist/tools/validate-scene.d.ts +0 -19
- package/dist/tools/validate-scene.d.ts.map +0 -1
- package/dist/tools/validate-scene.js +0 -28
package/dist/bin/pascal-mcp.js
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type SessionKeep, type SessionRecord } from '@pascal-app/core/agent-operations';
|
|
2
|
+
/**
|
|
3
|
+
* Where a host keeps what the agent's session should remember: the moments it named and what it
|
|
4
|
+
* made. Never scene data. A host with sessions that outlive the process keeps it beside them.
|
|
5
|
+
*/
|
|
6
|
+
export type AgentSession = {
|
|
7
|
+
read(): SessionRecord;
|
|
8
|
+
keep(keep: SessionKeep): void;
|
|
9
|
+
};
|
|
10
|
+
/** The standalone server's: in memory, for as long as the bridge holds a scene. */
|
|
11
|
+
export declare class InMemoryAgentSession implements AgentSession {
|
|
12
|
+
#private;
|
|
13
|
+
read(): SessionRecord;
|
|
14
|
+
keep(keep: SessionKeep): void;
|
|
15
|
+
/** Binding another scene starts another session. */
|
|
16
|
+
replace(record?: SessionRecord): void;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=agent-session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agent-session.d.ts","sourceRoot":"","sources":["../../src/bridge/agent-session.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,aAAa,EACnB,MAAM,mCAAmC,CAAA;AAE1C;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,IAAI,IAAI,aAAa,CAAA;IACrB,IAAI,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CAAA;CAC9B,CAAA;AAED,mFAAmF;AACnF,qBAAa,oBAAqB,YAAW,YAAY;;IAGvD,IAAI,IAAI,aAAa,CAEpB;IAED,IAAI,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,CAE5B;IAED,oDAAoD;IACpD,OAAO,CAAC,MAAM,GAAE,aAA6B,GAAG,IAAI,CAEnD;CACF"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { EMPTY_SESSION, keepInSession, } from '@pascal-app/core/agent-operations';
|
|
2
|
+
/** The standalone server's: in memory, for as long as the bridge holds a scene. */
|
|
3
|
+
export class InMemoryAgentSession {
|
|
4
|
+
#record = EMPTY_SESSION;
|
|
5
|
+
read() {
|
|
6
|
+
return this.#record;
|
|
7
|
+
}
|
|
8
|
+
keep(keep) {
|
|
9
|
+
this.#record = keepInSession(this.#record, keep);
|
|
10
|
+
}
|
|
11
|
+
/** Binding another scene starts another session. */
|
|
12
|
+
replace(record = EMPTY_SESSION) {
|
|
13
|
+
this.#record = record;
|
|
14
|
+
}
|
|
15
|
+
}
|
package/dist/bridge/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export type { ActiveSceneMeta, CreatePatch, DeletePatch, Patch, UpdatePatch, ValidationError, ValidationResult, } from './scene-bridge.js';
|
|
1
|
+
export type { ActiveSceneMeta, CreatePatch, DeletePatch, MaterialUpsertPatch, Patch, UpdatePatch, ValidationError, ValidationResult, } from './scene-bridge.js';
|
|
2
2
|
export { SceneBridge } from './scene-bridge.js';
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bridge/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,eAAe,EACf,WAAW,EACX,WAAW,EACX,KAAK,EACL,WAAW,EACX,eAAe,EACf,gBAAgB,GACjB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bridge/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,eAAe,EACf,WAAW,EACX,WAAW,EACX,mBAAmB,EACnB,KAAK,EACL,WAAW,EACX,eAAe,EACf,gBAAgB,GACjB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA"}
|
|
@@ -5,6 +5,7 @@ import type { AnyNode, Collection, CollectionId, SceneMaterialId } from '@pascal
|
|
|
5
5
|
import { type AnyNodeId, type AnyNodeType, SceneMaterial } from '@pascal-app/core/schema';
|
|
6
6
|
import useScene from '@pascal-app/core/store';
|
|
7
7
|
import type { SceneMeta } from '../storage/types.js';
|
|
8
|
+
import { InMemoryAgentSession } from './agent-session.js';
|
|
8
9
|
export type ValidationError = {
|
|
9
10
|
nodeId: string;
|
|
10
11
|
path: string;
|
|
@@ -51,6 +52,8 @@ export declare class SceneBridge {
|
|
|
51
52
|
/** `applyPatch` takes `upsert_material` patches, in the patch's one history step. */
|
|
52
53
|
readonly supportsMaterialUpserts = true;
|
|
53
54
|
private activeScene;
|
|
55
|
+
/** What the agent's session has kept: its named moments, and what it made. */
|
|
56
|
+
readonly agentSession: InMemoryAgentSession;
|
|
54
57
|
/**
|
|
55
58
|
* Scene identity currently bound to this bridge. MCP tools use this to know
|
|
56
59
|
* which editor scene should receive live events after mutations.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scene-bridge.d.ts","sourceRoot":"","sources":["../../src/bridge/scene-bridge.ts"],"names":[],"mappings":"AACA,OAAO,cAAc,CAAA;AACrB,OAAO,EAOL,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EAWvB,MAAM,kBAAkB,CAAA;AACzB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,oCAAoC,CAAA;AACpE,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AACjG,OAAO,EACL,KAAK,SAAS,EAEd,KAAK,WAAW,EAGhB,aAAa,EACd,MAAM,yBAAyB,CAAA;AAEhC,OAAO,QAAQ,MAAM,wBAAwB,CAAA;AAC7C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"scene-bridge.d.ts","sourceRoot":"","sources":["../../src/bridge/scene-bridge.ts"],"names":[],"mappings":"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"}
|
|
@@ -4,6 +4,7 @@ import { adjacentLevelId, applyStructureReconciliation, assertDerivedNodeWrites,
|
|
|
4
4
|
import { AnyNode as AnyNodeSchema, generateId, parseNode, SceneMaterial, } from '@pascal-app/core/schema';
|
|
5
5
|
// Per PLAN §0.6: `useScene` is the DEFAULT export from `@pascal-app/core/store`.
|
|
6
6
|
import useScene from '@pascal-app/core/store';
|
|
7
|
+
import { InMemoryAgentSession } from './agent-session.js';
|
|
7
8
|
/**
|
|
8
9
|
* Headless bridge to the `@pascal-app/core` Zustand store.
|
|
9
10
|
*
|
|
@@ -15,6 +16,8 @@ export class SceneBridge {
|
|
|
15
16
|
/** `applyPatch` takes `upsert_material` patches, in the patch's one history step. */
|
|
16
17
|
supportsMaterialUpserts = true;
|
|
17
18
|
activeScene = null;
|
|
19
|
+
/** What the agent's session has kept: its named moments, and what it made. */
|
|
20
|
+
agentSession = new InMemoryAgentSession();
|
|
18
21
|
/**
|
|
19
22
|
* Scene identity currently bound to this bridge. MCP tools use this to know
|
|
20
23
|
* which editor scene should receive live events after mutations.
|
|
@@ -38,10 +41,12 @@ export class SceneBridge {
|
|
|
38
41
|
}
|
|
39
42
|
/** Load initial state; if empty, creates default Site → Building → Level. */
|
|
40
43
|
loadDefault() {
|
|
44
|
+
this.agentSession.replace();
|
|
41
45
|
useScene.getState().loadScene();
|
|
42
46
|
}
|
|
43
47
|
/** Replace entire scene (undoable via Zundo). */
|
|
44
48
|
setScene(nodes, rootNodeIds, extra) {
|
|
49
|
+
this.agentSession.replace();
|
|
45
50
|
useScene.getState().setScene(nodes, rootNodeIds, extra);
|
|
46
51
|
}
|
|
47
52
|
/** Full snapshot for export, including collections and the material palette. */
|
|
@@ -317,6 +322,8 @@ export class SceneBridge {
|
|
|
317
322
|
applyPatch(patches) {
|
|
318
323
|
const state = useScene.getState();
|
|
319
324
|
const nodes = state.nodes;
|
|
325
|
+
if (state.readOnly)
|
|
326
|
+
throw new Error('scene_read_only');
|
|
320
327
|
// Track synthesized state as we dry-run so later ops can reference
|
|
321
328
|
// earlier-created ids and reflect earlier-deleted ids.
|
|
322
329
|
const simAvailable = new Set(Object.keys(nodes));
|
package/dist/lib/safe-fetch.d.ts
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
import type { LookupAddress } from 'node:dns';
|
|
2
|
+
import { type IncomingMessage } from 'node:http';
|
|
3
|
+
import { type LookupFunction } from 'node:net';
|
|
4
|
+
/** True for any address a server-side fetch must not reach. Unparseable input is blocked. */
|
|
5
|
+
export declare function isBlockedAddress(address: string): boolean;
|
|
6
|
+
export type Resolver = (hostname: string) => Promise<LookupAddress[]>;
|
|
7
|
+
/** A socket `lookup` that only ever answers with the given, already-checked addresses. */
|
|
8
|
+
export declare function pinnedLookup(addresses: LookupAddress[]): LookupFunction;
|
|
9
|
+
export type PinnedRequestInit = {
|
|
10
|
+
headers: Record<string, string>;
|
|
11
|
+
lookup: LookupFunction;
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
};
|
|
14
|
+
export type Requester = (url: URL, init: PinnedRequestInit) => Promise<IncomingMessage>;
|
|
15
|
+
export type SafeFetchDeps = {
|
|
16
|
+
resolve: Resolver;
|
|
17
|
+
request: Requester;
|
|
18
|
+
};
|
|
1
19
|
export type SafeFetchOptions = {
|
|
2
20
|
maxBytes?: number;
|
|
3
21
|
timeoutMs?: number;
|
|
@@ -11,9 +29,9 @@ export type SafeFetchResult = {
|
|
|
11
29
|
hops: string[];
|
|
12
30
|
};
|
|
13
31
|
/**
|
|
14
|
-
* SSRF-safe fetch that follows redirects manually,
|
|
15
|
-
*
|
|
16
|
-
* URLs, non-2xx responses, oversize bodies, or timeouts.
|
|
32
|
+
* SSRF-safe fetch that follows redirects manually, re-resolving and rechecking
|
|
33
|
+
* the host on every hop and connecting only to the checked addresses. Throws
|
|
34
|
+
* `ProtocolError` for blocked URLs, non-2xx responses, oversize bodies, or timeouts.
|
|
17
35
|
*/
|
|
18
|
-
export declare function safeFetch(urlStr: string, opts?: SafeFetchOptions): Promise<SafeFetchResult>;
|
|
36
|
+
export declare function safeFetch(urlStr: string, opts?: SafeFetchOptions, deps?: SafeFetchDeps): Promise<SafeFetchResult>;
|
|
19
37
|
//# sourceMappingURL=safe-fetch.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"safe-fetch.d.ts","sourceRoot":"","sources":["../../src/lib/safe-fetch.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"safe-fetch.d.ts","sourceRoot":"","sources":["../../src/lib/safe-fetch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAE7C,OAAO,EAA0B,KAAK,eAAe,EAAE,MAAM,WAAW,CAAA;AAExE,OAAO,EAAQ,KAAK,cAAc,EAAE,MAAM,UAAU,CAAA;AA2GpD,6FAA6F;AAC7F,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAYzD;AAkDD,MAAM,MAAM,QAAQ,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,EAAE,CAAC,CAAA;AA2BrE,0FAA0F;AAC1F,wBAAgB,YAAY,CAAC,SAAS,EAAE,aAAa,EAAE,GAAG,cAAc,CAcvE;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/B,MAAM,EAAE,cAAc,CAAA;IACtB,MAAM,EAAE,WAAW,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,iBAAiB,KAAK,OAAO,CAAC,eAAe,CAAC,CAAA;AAqBvF,MAAM,MAAM,aAAa,GAAG;IAAE,OAAO,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE,SAAS,CAAA;CAAE,CAAA;AAOrE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,uCAAuC;IACvC,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,CAAA;AAED,MAAM,MAAM,eAAe,GAAG;IAC5B,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,EAAE,CAAA;CACf,CAAA;AAOD;;;;GAIG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,EACd,IAAI,GAAE,gBAAqB,EAC3B,IAAI,GAAE,aAA2B,GAChC,OAAO,CAAC,eAAe,CAAC,CAwE1B"}
|
package/dist/lib/safe-fetch.js
CHANGED
|
@@ -1,83 +1,150 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
2
|
+
import { request as httpRequest } from 'node:http';
|
|
3
|
+
import { request as httpsRequest } from 'node:https';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/server';
|
|
3
6
|
/**
|
|
4
7
|
* SSRF-safe fetch for user-supplied URLs (image URLs in vision tools).
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
9
|
+
* Every hop (the initial URL and each redirect target) is checked the same way:
|
|
10
|
+
* - only http(s) schemes
|
|
11
|
+
* - well-known local hostnames are refused outright
|
|
12
|
+
* - the host is resolved once and *every* resolved address must be public;
|
|
13
|
+
* IPv6 forms that embed an IPv4 address (mapped, compatible, 6to4) are
|
|
14
|
+
* judged by the embedded address
|
|
15
|
+
* - the socket connects only to the addresses that were checked, so a DNS
|
|
16
|
+
* answer that changes between check and connect cannot reach a private host
|
|
17
|
+
* - optional origin allowlist via `PASCAL_ALLOWED_ASSET_ORIGINS` (comma-separated)
|
|
18
|
+
* - max body size (default 20 MB) and request timeout (default 10 s)
|
|
14
19
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* Phase 10 A2 found that photo_to_scene / analyze_floorplan_image /
|
|
18
|
-
* analyze_room_photo all called raw `fetch(url)` with no protection, giving
|
|
19
|
-
* a direct `169.254.169.254` exfil primitive on any host.
|
|
20
|
+
* Used by photo_to_scene / analyze_floorplan_image / analyze_room_photo.
|
|
20
21
|
*/
|
|
21
22
|
const DEFAULT_MAX_BYTES = 20 * 1024 * 1024; // 20 MB
|
|
22
23
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
23
24
|
const MAX_REDIRECTS = 3;
|
|
24
|
-
function
|
|
25
|
-
const parts = addr.split('.')
|
|
26
|
-
if (parts.length !== 4 || parts.some((
|
|
27
|
-
return
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
function parseIPv4(addr) {
|
|
26
|
+
const parts = addr.split('.');
|
|
27
|
+
if (parts.length !== 4 || parts.some((p) => !/^\d{1,3}$/.test(p)))
|
|
28
|
+
return null;
|
|
29
|
+
const octets = parts.map(Number);
|
|
30
|
+
return octets.every((n) => n <= 255) ? octets : null;
|
|
31
|
+
}
|
|
32
|
+
/** Expands any valid IPv6 text form (incl. `::` and a dotted IPv4 tail) to 8 groups. */
|
|
33
|
+
function parseIPv6(addr) {
|
|
34
|
+
let s = addr.toLowerCase();
|
|
35
|
+
const zone = s.indexOf('%');
|
|
36
|
+
if (zone >= 0)
|
|
37
|
+
s = s.slice(0, zone);
|
|
38
|
+
let tail = [];
|
|
39
|
+
const lastColon = s.lastIndexOf(':');
|
|
40
|
+
if (lastColon < 0)
|
|
41
|
+
return null;
|
|
42
|
+
if (s.slice(lastColon + 1).includes('.')) {
|
|
43
|
+
const v4 = parseIPv4(s.slice(lastColon + 1));
|
|
44
|
+
if (!v4)
|
|
45
|
+
return null;
|
|
46
|
+
tail = [(v4[0] << 8) | v4[1], (v4[2] << 8) | v4[3]];
|
|
47
|
+
const prefix = s.slice(0, lastColon + 1);
|
|
48
|
+
s = prefix.endsWith('::') ? prefix : prefix.slice(0, -1);
|
|
49
|
+
}
|
|
50
|
+
const halves = s.split('::');
|
|
51
|
+
if (halves.length > 2)
|
|
52
|
+
return null;
|
|
53
|
+
const groups = (part) => part === ''
|
|
54
|
+
? []
|
|
55
|
+
: part.split(':').map((h) => (/^[0-9a-f]{1,4}$/.test(h) ? parseInt(h, 16) : NaN));
|
|
56
|
+
const head = groups(halves[0]);
|
|
57
|
+
const rest = halves.length === 2 ? groups(halves[1]) : [];
|
|
58
|
+
const explicit = head.length + rest.length + tail.length;
|
|
59
|
+
if ([...head, ...rest].some(Number.isNaN))
|
|
60
|
+
return null;
|
|
61
|
+
if (halves.length === 1)
|
|
62
|
+
return explicit === 8 ? [...head, ...tail] : null;
|
|
63
|
+
if (explicit > 7)
|
|
64
|
+
return null;
|
|
65
|
+
return [...head, ...new Array(8 - explicit).fill(0), ...rest, ...tail];
|
|
66
|
+
}
|
|
67
|
+
function isBlockedV4([a, b]) {
|
|
68
|
+
if (a === 0)
|
|
69
|
+
return true; // 0.0.0.0/8 "this network"
|
|
31
70
|
if (a === 10)
|
|
32
71
|
return true; // 10.0.0.0/8 private
|
|
72
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
73
|
+
return true; // 100.64.0.0/10 CGNAT (incl. Alibaba metadata)
|
|
74
|
+
if (a === 127)
|
|
75
|
+
return true; // 127.0.0.0/8 loopback
|
|
76
|
+
if (a === 169 && b === 254)
|
|
77
|
+
return true; // 169.254.0.0/16 link-local (cloud metadata)
|
|
33
78
|
if (a === 172 && b >= 16 && b <= 31)
|
|
34
79
|
return true; // 172.16.0.0/12 private
|
|
80
|
+
if (a === 192 && b === 0)
|
|
81
|
+
return true; // 192.0.0.0/24 IETF protocol assignments
|
|
35
82
|
if (a === 192 && b === 168)
|
|
36
83
|
return true; // 192.168.0.0/16 private
|
|
37
|
-
if (a ===
|
|
38
|
-
return true; //
|
|
39
|
-
if (a === 0)
|
|
40
|
-
return true; // current-network
|
|
84
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
85
|
+
return true; // 198.18.0.0/15 benchmarking
|
|
41
86
|
if (a >= 224)
|
|
42
|
-
return true; // multicast
|
|
87
|
+
return true; // multicast, reserved, broadcast
|
|
43
88
|
return false;
|
|
44
89
|
}
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return true; // ULA fc00::/7
|
|
56
|
-
if (lower.startsWith('::ffff:')) {
|
|
57
|
-
// v4-mapped
|
|
58
|
-
const v4 = lower.slice(7);
|
|
59
|
-
if (isIPv4(v4))
|
|
60
|
-
return isPrivateOrLoopbackV4(v4);
|
|
90
|
+
function embeddedV4(hi, lo) {
|
|
91
|
+
return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff];
|
|
92
|
+
}
|
|
93
|
+
function isBlockedV6(g) {
|
|
94
|
+
const [g0, g1, g2, g3, g4, g5, g6, g7] = g;
|
|
95
|
+
const first4Zero = g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0;
|
|
96
|
+
// ::/96 (unspecified, loopback, IPv4-compatible), ::ffff:0:0/96 (mapped),
|
|
97
|
+
// ::ffff:0:0:0/96 (translated) — judge by the embedded IPv4 address.
|
|
98
|
+
if (first4Zero && ((g4 === 0 && (g5 === 0 || g5 === 0xffff)) || (g4 === 0xffff && g5 === 0))) {
|
|
99
|
+
return isBlockedV4(embeddedV4(g6, g7));
|
|
61
100
|
}
|
|
101
|
+
if (g0 === 0x64 && g1 === 0xff9b)
|
|
102
|
+
return true; // 64:ff9b::/96 and 64:ff9b:1::/48 NAT64
|
|
103
|
+
if (g0 === 0x100 && g1 === 0 && g2 === 0 && g3 === 0)
|
|
104
|
+
return true; // 100::/64 discard
|
|
105
|
+
if (g0 === 0x2001 && g1 === 0)
|
|
106
|
+
return true; // 2001::/32 Teredo
|
|
107
|
+
if (g0 === 0x2002)
|
|
108
|
+
return isBlockedV4(embeddedV4(g1, g2)); // 2002::/16 6to4
|
|
109
|
+
if ((g0 & 0xfe00) === 0xfc00)
|
|
110
|
+
return true; // fc00::/7 ULA (incl. fd00:ec2::254 metadata)
|
|
111
|
+
if ((g0 & 0xffc0) === 0xfe80)
|
|
112
|
+
return true; // fe80::/10 link-local
|
|
113
|
+
if ((g0 & 0xffc0) === 0xfec0)
|
|
114
|
+
return true; // fec0::/10 site-local
|
|
115
|
+
if ((g0 & 0xff00) === 0xff00)
|
|
116
|
+
return true; // ff00::/8 multicast
|
|
62
117
|
return false;
|
|
63
118
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (
|
|
119
|
+
/** True for any address a server-side fetch must not reach. Unparseable input is blocked. */
|
|
120
|
+
export function isBlockedAddress(address) {
|
|
121
|
+
const literal = address.replace(/^\[|\]$/g, '');
|
|
122
|
+
const family = isIP(literal);
|
|
123
|
+
if (family === 4) {
|
|
124
|
+
const v4 = parseIPv4(literal);
|
|
125
|
+
return v4 ? isBlockedV4(v4) : true;
|
|
126
|
+
}
|
|
127
|
+
if (family === 6) {
|
|
128
|
+
const v6 = parseIPv6(literal);
|
|
129
|
+
return v6 ? isBlockedV6(v6) : true;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
function isLocalHostname(hostname) {
|
|
134
|
+
const lower = hostname.toLowerCase().replace(/\.+$/, '');
|
|
135
|
+
return (lower === 'localhost' ||
|
|
73
136
|
lower.endsWith('.localhost') ||
|
|
74
137
|
lower === 'broadcasthost' ||
|
|
75
138
|
lower.endsWith('.local') ||
|
|
76
139
|
lower.endsWith('.internal') ||
|
|
77
|
-
lower.endsWith('.corp'))
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
140
|
+
lower.endsWith('.corp'));
|
|
141
|
+
}
|
|
142
|
+
function blocked(reason, details, log = {}) {
|
|
143
|
+
console.error(`[pascal-mcp] safe_fetch_blocked ${JSON.stringify({ reason, ...details, ...log })}`);
|
|
144
|
+
return protocolError(reason, details);
|
|
145
|
+
}
|
|
146
|
+
function protocolError(reason, details) {
|
|
147
|
+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, reason, details);
|
|
81
148
|
}
|
|
82
149
|
function assertAllowedUrl(url) {
|
|
83
150
|
let parsed;
|
|
@@ -85,21 +152,11 @@ function assertAllowedUrl(url) {
|
|
|
85
152
|
parsed = new URL(url);
|
|
86
153
|
}
|
|
87
154
|
catch {
|
|
88
|
-
|
|
155
|
+
return protocolError('invalid_url', { url });
|
|
89
156
|
}
|
|
90
157
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
91
|
-
|
|
92
|
-
url,
|
|
93
|
-
protocol: parsed.protocol,
|
|
94
|
-
});
|
|
158
|
+
blocked('url_scheme_not_allowed', { url, protocol: parsed.protocol });
|
|
95
159
|
}
|
|
96
|
-
if (isUnsafeHost(parsed.hostname)) {
|
|
97
|
-
throw new McpError(ErrorCode.InvalidParams, 'url_host_blocked', {
|
|
98
|
-
url,
|
|
99
|
-
hostname: parsed.hostname,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
// Optional env-allowlist narrowing.
|
|
103
160
|
const allowEnv = process.env.PASCAL_ALLOWED_ASSET_ORIGINS;
|
|
104
161
|
if (allowEnv) {
|
|
105
162
|
const origins = allowEnv
|
|
@@ -107,20 +164,85 @@ function assertAllowedUrl(url) {
|
|
|
107
164
|
.map((s) => s.trim())
|
|
108
165
|
.filter(Boolean);
|
|
109
166
|
if (!origins.includes(parsed.origin)) {
|
|
110
|
-
|
|
111
|
-
url,
|
|
112
|
-
origin: parsed.origin,
|
|
113
|
-
});
|
|
167
|
+
blocked('url_origin_not_allowlisted', { url, origin: parsed.origin });
|
|
114
168
|
}
|
|
115
169
|
}
|
|
116
170
|
return parsed;
|
|
117
171
|
}
|
|
172
|
+
/** Resolves `url`'s host and returns its addresses only if every one of them is public. */
|
|
173
|
+
async function resolveCheckedAddresses(url, resolve) {
|
|
174
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
175
|
+
const details = { url: url.toString(), hostname: url.hostname };
|
|
176
|
+
const literalFamily = isIP(host);
|
|
177
|
+
let addresses;
|
|
178
|
+
if (literalFamily) {
|
|
179
|
+
addresses = [{ address: host, family: literalFamily }];
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
if (isLocalHostname(host))
|
|
183
|
+
blocked('url_host_blocked', details);
|
|
184
|
+
try {
|
|
185
|
+
addresses = await resolve(host);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
return protocolError('dns_lookup_failed', {
|
|
189
|
+
...details,
|
|
190
|
+
message: err instanceof Error ? err.message : String(err),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (addresses.length === 0)
|
|
195
|
+
protocolError('dns_lookup_failed', details);
|
|
196
|
+
const unsafe = addresses.find((a) => isBlockedAddress(a.address));
|
|
197
|
+
if (unsafe)
|
|
198
|
+
blocked('url_host_blocked', details, { address: unsafe.address });
|
|
199
|
+
return addresses;
|
|
200
|
+
}
|
|
201
|
+
/** A socket `lookup` that only ever answers with the given, already-checked addresses. */
|
|
202
|
+
export function pinnedLookup(addresses) {
|
|
203
|
+
return (_hostname, options, callback) => {
|
|
204
|
+
const family = options.family === 'IPv4' ? 4 : options.family === 'IPv6' ? 6 : options.family;
|
|
205
|
+
const matching = family === 4 || family === 6 ? addresses.filter((a) => a.family === family) : addresses;
|
|
206
|
+
const first = matching[0];
|
|
207
|
+
if (!first) {
|
|
208
|
+
callback(Object.assign(new Error('no checked address for family'), { code: 'ENOTFOUND' }), '');
|
|
209
|
+
}
|
|
210
|
+
else if (options.all) {
|
|
211
|
+
callback(null, matching);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
callback(null, first.address, first.family);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function requestPinned(url, init) {
|
|
219
|
+
const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const req = send(url, {
|
|
222
|
+
method: 'GET',
|
|
223
|
+
agent: false,
|
|
224
|
+
headers: init.headers,
|
|
225
|
+
lookup: init.lookup,
|
|
226
|
+
signal: init.signal,
|
|
227
|
+
}, resolve);
|
|
228
|
+
req.on('error', reject);
|
|
229
|
+
req.end();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const defaultDeps = {
|
|
233
|
+
resolve: (hostname) => dnsLookup(hostname, { all: true, verbatim: true }),
|
|
234
|
+
request: requestPinned,
|
|
235
|
+
};
|
|
236
|
+
function header(res, name) {
|
|
237
|
+
const value = res.headers[name];
|
|
238
|
+
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
|
|
239
|
+
}
|
|
118
240
|
/**
|
|
119
|
-
* SSRF-safe fetch that follows redirects manually,
|
|
120
|
-
*
|
|
121
|
-
* URLs, non-2xx responses, oversize bodies, or timeouts.
|
|
241
|
+
* SSRF-safe fetch that follows redirects manually, re-resolving and rechecking
|
|
242
|
+
* the host on every hop and connecting only to the checked addresses. Throws
|
|
243
|
+
* `ProtocolError` for blocked URLs, non-2xx responses, oversize bodies, or timeouts.
|
|
122
244
|
*/
|
|
123
|
-
export async function safeFetch(urlStr, opts = {}) {
|
|
245
|
+
export async function safeFetch(urlStr, opts = {}, deps = defaultDeps) {
|
|
124
246
|
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
125
247
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
126
248
|
const hops = [];
|
|
@@ -131,89 +253,66 @@ export async function safeFetch(urlStr, opts = {}) {
|
|
|
131
253
|
for (let i = 0; i <= MAX_REDIRECTS; i++) {
|
|
132
254
|
const parsed = assertAllowedUrl(current);
|
|
133
255
|
hops.push(parsed.toString());
|
|
134
|
-
const
|
|
135
|
-
|
|
256
|
+
const addresses = await resolveCheckedAddresses(parsed, deps.resolve);
|
|
257
|
+
const res = await deps.request(parsed, {
|
|
258
|
+
headers: opts.accept ? { Accept: opts.accept } : {},
|
|
259
|
+
lookup: pinnedLookup(addresses),
|
|
136
260
|
signal: controller.signal,
|
|
137
|
-
headers: opts.accept ? { Accept: opts.accept } : undefined,
|
|
138
261
|
});
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
|
|
262
|
+
const status = res.statusCode ?? 0;
|
|
263
|
+
if (status >= 300 && status < 400) {
|
|
264
|
+
res.resume();
|
|
265
|
+
const location = header(res, 'location');
|
|
142
266
|
if (!location) {
|
|
143
|
-
|
|
144
|
-
url: parsed.toString(),
|
|
145
|
-
status: res.status,
|
|
146
|
-
});
|
|
267
|
+
protocolError('redirect_without_location', { url: parsed.toString(), status });
|
|
147
268
|
}
|
|
148
269
|
current = new URL(location, parsed).toString();
|
|
149
270
|
continue;
|
|
150
271
|
}
|
|
151
|
-
if (
|
|
152
|
-
|
|
272
|
+
if (status < 200 || status >= 300) {
|
|
273
|
+
res.resume();
|
|
274
|
+
protocolError('fetch_failed', {
|
|
153
275
|
url: parsed.toString(),
|
|
154
|
-
status
|
|
155
|
-
statusText: res.
|
|
276
|
+
status,
|
|
277
|
+
statusText: res.statusMessage ?? '',
|
|
156
278
|
});
|
|
157
279
|
}
|
|
158
|
-
|
|
159
|
-
const declared = Number(res.headers.get('content-length'));
|
|
280
|
+
const declared = Number(header(res, 'content-length') ?? Number.NaN);
|
|
160
281
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
declared,
|
|
164
|
-
maxBytes,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
// Stream with a running cap so servers that lie about length still get bounded.
|
|
168
|
-
const reader = res.body?.getReader();
|
|
169
|
-
if (!reader) {
|
|
170
|
-
throw new McpError(ErrorCode.InvalidParams, 'empty_response', {
|
|
171
|
-
url: parsed.toString(),
|
|
172
|
-
});
|
|
282
|
+
res.destroy();
|
|
283
|
+
protocolError('response_too_large', { url: parsed.toString(), declared, maxBytes });
|
|
173
284
|
}
|
|
285
|
+
// Running cap so servers that lie about length still get bounded.
|
|
174
286
|
const chunks = [];
|
|
175
287
|
let total = 0;
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
total
|
|
182
|
-
if (total > maxBytes) {
|
|
183
|
-
try {
|
|
184
|
-
await reader.cancel();
|
|
185
|
-
}
|
|
186
|
-
catch {
|
|
187
|
-
// ignore
|
|
188
|
-
}
|
|
189
|
-
throw new McpError(ErrorCode.InvalidParams, 'response_too_large', {
|
|
190
|
-
url: parsed.toString(),
|
|
191
|
-
received: total,
|
|
192
|
-
maxBytes,
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
chunks.push(value);
|
|
288
|
+
for await (const chunk of res) {
|
|
289
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
290
|
+
total += buf.byteLength;
|
|
291
|
+
if (total > maxBytes) {
|
|
292
|
+
res.destroy();
|
|
293
|
+
protocolError('response_too_large', { url: parsed.toString(), received: total, maxBytes });
|
|
196
294
|
}
|
|
295
|
+
chunks.push(buf);
|
|
197
296
|
}
|
|
198
297
|
return {
|
|
199
|
-
buffer: Buffer.concat(chunks
|
|
200
|
-
contentType: res
|
|
298
|
+
buffer: Buffer.concat(chunks),
|
|
299
|
+
contentType: header(res, 'content-type'),
|
|
201
300
|
finalUrl: parsed.toString(),
|
|
202
301
|
hops,
|
|
203
302
|
};
|
|
204
303
|
}
|
|
205
|
-
|
|
206
|
-
hops: hops.slice(0, MAX_REDIRECTS + 1),
|
|
207
|
-
});
|
|
304
|
+
return protocolError('too_many_redirects', { hops: hops.slice(0, MAX_REDIRECTS + 1) });
|
|
208
305
|
}
|
|
209
306
|
catch (err) {
|
|
210
|
-
if (err instanceof
|
|
307
|
+
if (err instanceof ProtocolError)
|
|
211
308
|
throw err;
|
|
212
|
-
if (err.name === 'AbortError') {
|
|
213
|
-
|
|
309
|
+
if (controller.signal.aborted || err.name === 'AbortError') {
|
|
310
|
+
return protocolError('fetch_timeout', { url: urlStr, timeoutMs });
|
|
214
311
|
}
|
|
215
|
-
|
|
216
|
-
|
|
312
|
+
return protocolError('fetch_error', {
|
|
313
|
+
url: urlStr,
|
|
314
|
+
message: err instanceof Error ? err.message : String(err),
|
|
315
|
+
});
|
|
217
316
|
}
|
|
218
317
|
finally {
|
|
219
318
|
clearTimeout(timer);
|