@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.
Files changed (244) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +166 -62
  3. package/dist/bin/pascal-mcp.js +1 -1
  4. package/dist/bridge/agent-session.d.ts +18 -0
  5. package/dist/bridge/agent-session.d.ts.map +1 -0
  6. package/dist/bridge/agent-session.js +15 -0
  7. package/dist/bridge/index.d.ts +1 -1
  8. package/dist/bridge/index.d.ts.map +1 -1
  9. package/dist/bridge/scene-bridge.d.ts +3 -0
  10. package/dist/bridge/scene-bridge.d.ts.map +1 -1
  11. package/dist/bridge/scene-bridge.js +7 -0
  12. package/dist/lib/safe-fetch.d.ts +22 -4
  13. package/dist/lib/safe-fetch.d.ts.map +1 -1
  14. package/dist/lib/safe-fetch.js +233 -134
  15. package/dist/operations/scene-operations.d.ts +18 -0
  16. package/dist/operations/scene-operations.d.ts.map +1 -1
  17. package/dist/operations/scene-operations.js +11 -0
  18. package/dist/prompts/from-brief.d.ts +1 -1
  19. package/dist/prompts/from-brief.d.ts.map +1 -1
  20. package/dist/prompts/from-brief.js +3 -3
  21. package/dist/prompts/index.d.ts +1 -1
  22. package/dist/prompts/index.d.ts.map +1 -1
  23. package/dist/prompts/iterate-on-feedback.d.ts +1 -1
  24. package/dist/prompts/iterate-on-feedback.d.ts.map +1 -1
  25. package/dist/prompts/iterate-on-feedback.js +2 -2
  26. package/dist/prompts/renovation-from-photos.d.ts +1 -1
  27. package/dist/prompts/renovation-from-photos.d.ts.map +1 -1
  28. package/dist/prompts/renovation-from-photos.js +2 -2
  29. package/dist/prompts/scene-guidance.d.ts.map +1 -1
  30. package/dist/prompts/scene-guidance.js +10 -3
  31. package/dist/resources/agent-guide.d.ts +1 -1
  32. package/dist/resources/agent-guide.d.ts.map +1 -1
  33. package/dist/resources/agent-guide.js +52 -44
  34. package/dist/resources/catalog-items.d.ts +1 -1
  35. package/dist/resources/catalog-items.d.ts.map +1 -1
  36. package/dist/resources/constraints.d.ts +1 -1
  37. package/dist/resources/constraints.d.ts.map +1 -1
  38. package/dist/resources/constraints.js +1 -1
  39. package/dist/resources/design-schema.d.ts +1 -1
  40. package/dist/resources/design-schema.d.ts.map +1 -1
  41. package/dist/resources/index.d.ts +1 -1
  42. package/dist/resources/index.d.ts.map +1 -1
  43. package/dist/resources/scene-current.d.ts +1 -1
  44. package/dist/resources/scene-current.d.ts.map +1 -1
  45. package/dist/resources/scene-summary.d.ts +1 -1
  46. package/dist/resources/scene-summary.d.ts.map +1 -1
  47. package/dist/server.d.ts +7 -2
  48. package/dist/server.d.ts.map +1 -1
  49. package/dist/server.js +59 -21
  50. package/dist/storage/disk-artifact-store.d.ts.map +1 -1
  51. package/dist/storage/sqlite-scene-store.d.ts +1 -1
  52. package/dist/storage/sqlite-scene-store.d.ts.map +1 -1
  53. package/dist/storage/types.d.ts +4 -4
  54. package/dist/storage/types.d.ts.map +1 -1
  55. package/dist/templates/empty-studio.d.ts +3 -3
  56. package/dist/templates/empty-studio.d.ts.map +1 -1
  57. package/dist/templates/garden-house.d.ts +3 -3
  58. package/dist/templates/garden-house.d.ts.map +1 -1
  59. package/dist/templates/index.d.ts.map +1 -1
  60. package/dist/templates/two-bedroom.d.ts +3 -3
  61. package/dist/templates/two-bedroom.d.ts.map +1 -1
  62. package/dist/tools/add-column.d.ts +1 -1
  63. package/dist/tools/add-column.d.ts.map +1 -1
  64. package/dist/tools/add-column.js +3 -2
  65. package/dist/tools/add-object.d.ts +19 -3
  66. package/dist/tools/add-object.d.ts.map +1 -1
  67. package/dist/tools/add-object.js +66 -35
  68. package/dist/tools/annotations.d.ts.map +1 -1
  69. package/dist/tools/apply-patch.d.ts +6 -2
  70. package/dist/tools/apply-patch.d.ts.map +1 -1
  71. package/dist/tools/apply-patch.js +13 -8
  72. package/dist/tools/asset-catalog.d.ts.map +1 -1
  73. package/dist/tools/asset-catalog.js +23 -0
  74. package/dist/tools/author-scene.d.ts +10 -0
  75. package/dist/tools/author-scene.d.ts.map +1 -0
  76. package/dist/tools/author-scene.js +140 -0
  77. package/dist/tools/check-collisions.d.ts +6 -6
  78. package/dist/tools/check-collisions.d.ts.map +1 -1
  79. package/dist/tools/check-collisions.js +6 -6
  80. package/dist/tools/clear-scene.d.ts +1 -1
  81. package/dist/tools/clear-scene.d.ts.map +1 -1
  82. package/dist/tools/clear-scene.js +3 -3
  83. package/dist/tools/collections.d.ts +1 -1
  84. package/dist/tools/collections.d.ts.map +1 -1
  85. package/dist/tools/collections.js +3 -2
  86. package/dist/tools/construction-tools.d.ts +6 -49
  87. package/dist/tools/construction-tools.d.ts.map +1 -1
  88. package/dist/tools/construction-tools.js +9 -158
  89. package/dist/tools/create-unit.d.ts +3 -3
  90. package/dist/tools/create-unit.d.ts.map +1 -1
  91. package/dist/tools/create-unit.js +2 -2
  92. package/dist/tools/errors.d.ts +5 -5
  93. package/dist/tools/errors.d.ts.map +1 -1
  94. package/dist/tools/errors.js +4 -4
  95. package/dist/tools/export-glb.d.ts +1 -1
  96. package/dist/tools/export-glb.d.ts.map +1 -1
  97. package/dist/tools/export-glb.js +2 -2
  98. package/dist/tools/export-json.d.ts +1 -1
  99. package/dist/tools/export-json.d.ts.map +1 -1
  100. package/dist/tools/export-json.js +2 -2
  101. package/dist/tools/get-scene.d.ts +1 -1
  102. package/dist/tools/get-scene.d.ts.map +1 -1
  103. package/dist/tools/get-scene.js +2 -2
  104. package/dist/tools/hosted-services.d.ts +1 -1
  105. package/dist/tools/hosted-services.d.ts.map +1 -1
  106. package/dist/tools/hosted-services.js +13 -3
  107. package/dist/tools/index.d.ts +4 -3
  108. package/dist/tools/index.d.ts.map +1 -1
  109. package/dist/tools/index.js +8 -13
  110. package/dist/tools/level-height-guard.d.ts +2 -0
  111. package/dist/tools/level-height-guard.d.ts.map +1 -0
  112. package/dist/tools/level-height-guard.js +11 -0
  113. package/dist/tools/list-units.d.ts +2 -2
  114. package/dist/tools/list-units.d.ts.map +1 -1
  115. package/dist/tools/list-units.js +2 -2
  116. package/dist/tools/live-sync.d.ts +1 -1
  117. package/dist/tools/live-sync.d.ts.map +1 -1
  118. package/dist/tools/live-sync.js +4 -4
  119. package/dist/tools/measure.d.ts +1 -1
  120. package/dist/tools/measure.d.ts.map +1 -1
  121. package/dist/tools/measure.js +7 -7
  122. package/dist/tools/node-world-point.d.ts +1 -1
  123. package/dist/tools/node-world-point.d.ts.map +1 -1
  124. package/dist/tools/normalize-schema-dialect.d.ts +1 -1
  125. package/dist/tools/normalize-schema-dialect.d.ts.map +1 -1
  126. package/dist/tools/normalize-schema-dialect.js +2 -3
  127. package/dist/tools/patch-guards.d.ts +6 -4
  128. package/dist/tools/patch-guards.d.ts.map +1 -1
  129. package/dist/tools/patch-guards.js +5 -2
  130. package/dist/tools/photo-to-scene/index.d.ts +1 -1
  131. package/dist/tools/photo-to-scene/index.d.ts.map +1 -1
  132. package/dist/tools/photo-to-scene/photo-to-scene.d.ts +3 -3
  133. package/dist/tools/photo-to-scene/photo-to-scene.d.ts.map +1 -1
  134. package/dist/tools/photo-to-scene/photo-to-scene.js +7 -7
  135. package/dist/tools/project-files.d.ts +4 -0
  136. package/dist/tools/project-files.d.ts.map +1 -0
  137. package/dist/tools/project-files.js +30 -0
  138. package/dist/tools/redo.d.ts +2 -2
  139. package/dist/tools/redo.d.ts.map +1 -1
  140. package/dist/tools/redo.js +2 -2
  141. package/dist/tools/retired-tools.d.ts +10 -0
  142. package/dist/tools/retired-tools.d.ts.map +1 -0
  143. package/dist/tools/retired-tools.js +22 -0
  144. package/dist/tools/roof-level-guard.d.ts +2 -0
  145. package/dist/tools/roof-level-guard.d.ts.map +1 -0
  146. package/dist/tools/roof-level-guard.js +22 -0
  147. package/dist/tools/room-tools.d.ts +47 -46
  148. package/dist/tools/room-tools.d.ts.map +1 -1
  149. package/dist/tools/room-tools.js +6 -5
  150. package/dist/tools/scene-lifecycle/create-project.d.ts +1 -1
  151. package/dist/tools/scene-lifecycle/create-project.d.ts.map +1 -1
  152. package/dist/tools/scene-lifecycle/create-project.js +5 -5
  153. package/dist/tools/scene-lifecycle/delete-scene.d.ts +1 -1
  154. package/dist/tools/scene-lifecycle/delete-scene.d.ts.map +1 -1
  155. package/dist/tools/scene-lifecycle/delete-scene.js +6 -6
  156. package/dist/tools/scene-lifecycle/get-project-status.d.ts +1 -1
  157. package/dist/tools/scene-lifecycle/get-project-status.d.ts.map +1 -1
  158. package/dist/tools/scene-lifecycle/get-project-status.js +6 -6
  159. package/dist/tools/scene-lifecycle/index.d.ts +1 -1
  160. package/dist/tools/scene-lifecycle/index.d.ts.map +1 -1
  161. package/dist/tools/scene-lifecycle/list-scenes.d.ts +1 -1
  162. package/dist/tools/scene-lifecycle/list-scenes.d.ts.map +1 -1
  163. package/dist/tools/scene-lifecycle/list-scenes.js +4 -4
  164. package/dist/tools/scene-lifecycle/load-scene.d.ts +2 -2
  165. package/dist/tools/scene-lifecycle/load-scene.d.ts.map +1 -1
  166. package/dist/tools/scene-lifecycle/load-scene.js +5 -5
  167. package/dist/tools/scene-lifecycle/metadata.d.ts +1 -1
  168. package/dist/tools/scene-lifecycle/rename-scene.d.ts +1 -1
  169. package/dist/tools/scene-lifecycle/rename-scene.d.ts.map +1 -1
  170. package/dist/tools/scene-lifecycle/rename-scene.js +6 -6
  171. package/dist/tools/scene-lifecycle/save-scene.d.ts +5 -3
  172. package/dist/tools/scene-lifecycle/save-scene.d.ts.map +1 -1
  173. package/dist/tools/scene-lifecycle/save-scene.js +80 -12
  174. package/dist/tools/scene-lifecycle/test-utils.d.ts +1 -1
  175. package/dist/tools/scene-lifecycle/test-utils.d.ts.map +1 -1
  176. package/dist/tools/set-unit-members.d.ts +2 -2
  177. package/dist/tools/set-unit-members.d.ts.map +1 -1
  178. package/dist/tools/set-unit-members.js +6 -6
  179. package/dist/tools/set-zone.d.ts +2 -2
  180. package/dist/tools/set-zone.d.ts.map +1 -1
  181. package/dist/tools/set-zone.js +30 -18
  182. package/dist/tools/shared-tools.d.ts +15 -5
  183. package/dist/tools/shared-tools.d.ts.map +1 -1
  184. package/dist/tools/shared-tools.js +242 -29
  185. package/dist/tools/structure-tools.d.ts +3 -3
  186. package/dist/tools/templates/create-from-template.d.ts +2 -2
  187. package/dist/tools/templates/create-from-template.d.ts.map +1 -1
  188. package/dist/tools/templates/create-from-template.js +6 -6
  189. package/dist/tools/templates/create-house-from-brief.d.ts +1 -1
  190. package/dist/tools/templates/create-house-from-brief.d.ts.map +1 -1
  191. package/dist/tools/templates/create-house-from-brief.js +6 -6
  192. package/dist/tools/templates/index.d.ts +1 -1
  193. package/dist/tools/templates/index.d.ts.map +1 -1
  194. package/dist/tools/templates/list-templates.d.ts +1 -1
  195. package/dist/tools/templates/list-templates.d.ts.map +1 -1
  196. package/dist/tools/templates/list-templates.js +2 -2
  197. package/dist/tools/undo.d.ts +2 -2
  198. package/dist/tools/undo.d.ts.map +1 -1
  199. package/dist/tools/undo.js +2 -2
  200. package/dist/tools/unit-members.d.ts.map +1 -1
  201. package/dist/tools/unit-members.js +6 -6
  202. package/dist/tools/variants/generate-variants.d.ts +6 -6
  203. package/dist/tools/variants/generate-variants.d.ts.map +1 -1
  204. package/dist/tools/variants/generate-variants.js +6 -6
  205. package/dist/tools/variants/index.d.ts +1 -1
  206. package/dist/tools/variants/index.d.ts.map +1 -1
  207. package/dist/tools/view-scene.d.ts +8 -2
  208. package/dist/tools/view-scene.d.ts.map +1 -1
  209. package/dist/tools/view-scene.js +17 -5
  210. package/dist/tools/vision/analyze-floorplan-image.d.ts +1 -1
  211. package/dist/tools/vision/analyze-floorplan-image.d.ts.map +1 -1
  212. package/dist/tools/vision/analyze-floorplan-image.js +7 -7
  213. package/dist/tools/vision/analyze-room-photo.d.ts +1 -1
  214. package/dist/tools/vision/analyze-room-photo.d.ts.map +1 -1
  215. package/dist/tools/vision/analyze-room-photo.js +7 -7
  216. package/dist/tools/vision/index.d.ts +1 -1
  217. package/dist/tools/vision/index.d.ts.map +1 -1
  218. package/dist/transports/http.d.ts +8 -2
  219. package/dist/transports/http.d.ts.map +1 -1
  220. package/dist/transports/http.js +9 -3
  221. package/dist/transports/stdio.d.ts +15 -9
  222. package/dist/transports/stdio.d.ts.map +1 -1
  223. package/dist/transports/stdio.js +18 -11
  224. package/examples/coordinate-conventions-demo.md +12 -13
  225. package/examples/embed-in-agent.ts +5 -6
  226. package/examples/generate-apartment.md +4 -4
  227. package/examples/photo-to-scene.md +2 -3
  228. package/examples/renovate-from-photos.md +3 -5
  229. package/package.json +13 -9
  230. package/dist/tools/describe-node.d.ts +0 -17
  231. package/dist/tools/describe-node.d.ts.map +0 -1
  232. package/dist/tools/describe-node.js +0 -99
  233. package/dist/tools/find-nodes.d.ts +0 -16
  234. package/dist/tools/find-nodes.d.ts.map +0 -1
  235. package/dist/tools/find-nodes.js +0 -204
  236. package/dist/tools/place-design.d.ts +0 -34
  237. package/dist/tools/place-design.d.ts.map +0 -1
  238. package/dist/tools/place-design.js +0 -91
  239. package/dist/tools/validate-design.d.ts +0 -136
  240. package/dist/tools/validate-design.d.ts.map +0 -1
  241. package/dist/tools/validate-design.js +0 -28
  242. package/dist/tools/validate-scene.d.ts +0 -19
  243. package/dist/tools/validate-scene.d.ts.map +0 -1
  244. package/dist/tools/validate-scene.js +0 -28
@@ -94,7 +94,7 @@ async function main() {
94
94
  }
95
95
  else {
96
96
  // --stdio is the default when no transport flag is passed.
97
- await connectStdio(createServer());
97
+ connectStdio(createServer);
98
98
  console.error('[pascal-mcp] stdio server running');
99
99
  }
100
100
  }
@@ -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
+ }
@@ -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":"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"}
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));
@@ -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, revalidating the host
15
- * allowlist + private-IP check on every hop. Throws `McpError` for blocked
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":"AAkHA,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;AAED;;;;GAIG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,EACd,IAAI,GAAE,gBAAqB,GAC1B,OAAO,CAAC,eAAe,CAAC,CA6F1B"}
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"}
@@ -1,83 +1,150 @@
1
- import { isIPv4, isIPv6 } from 'node:net';
2
- import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
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
- * Blocks the usual server-side-request-forgery attack surface:
7
- * - loopback (127.0.0.0/8, ::1)
8
- * - link-local (169.254.0.0/16 — includes cloud metadata at 169.254.169.254)
9
- * - private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7)
10
- * - non-http(s) schemes
11
- * - manual-redirect with the same allowlist applied to each hop
12
- * - max body size (default 20 MB)
13
- * - request timeout (default 10 s)
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
- * Optional allowlist via `PASCAL_ALLOWED_ASSET_ORIGINS` env var (comma-separated).
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 isPrivateOrLoopbackV4(addr) {
25
- const parts = addr.split('.').map(Number);
26
- if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
27
- return true; // malformed → treat as unsafe
28
- const [a, b] = parts;
29
- if (a === 127)
30
- return true; // 127.0.0.0/8 loopback
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 === 169 && b === 254)
38
- return true; // link-local incl. cloud metadata
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 / reserved
87
+ return true; // multicast, reserved, broadcast
43
88
  return false;
44
89
  }
45
- function isPrivateOrLoopbackV6(addr) {
46
- const lower = addr.toLowerCase();
47
- if (lower === '::1' || lower === '::')
48
- return true;
49
- if (lower.startsWith('fe80:') ||
50
- lower.startsWith('fe90:') ||
51
- lower.startsWith('fea0:') ||
52
- lower.startsWith('feb0:'))
53
- return true; // link-local
54
- if (lower.startsWith('fc') || lower.startsWith('fd'))
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
- function isUnsafeHost(hostname) {
65
- const host = hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets if any
66
- if (isIPv4(host))
67
- return isPrivateOrLoopbackV4(host);
68
- if (isIPv6(host))
69
- return isPrivateOrLoopbackV6(host);
70
- // Hostname (not IP) — block well-known local names.
71
- const lower = host.toLowerCase();
72
- if (lower === 'localhost' ||
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
- return true;
79
- }
80
- return false;
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
- throw new McpError(ErrorCode.InvalidParams, 'invalid_url', { url });
155
+ return protocolError('invalid_url', { url });
89
156
  }
90
157
  if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
91
- throw new McpError(ErrorCode.InvalidParams, 'url_scheme_not_allowed', {
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
- throw new McpError(ErrorCode.InvalidParams, 'url_origin_not_allowlisted', {
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, revalidating the host
120
- * allowlist + private-IP check on every hop. Throws `McpError` for blocked
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 res = await fetch(parsed, {
135
- redirect: 'manual',
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
- // Manual redirect handling
140
- if (res.status >= 300 && res.status < 400) {
141
- const location = res.headers.get('location');
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
- throw new McpError(ErrorCode.InvalidParams, 'redirect_without_location', {
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 (!res.ok) {
152
- throw new McpError(ErrorCode.InvalidParams, 'fetch_failed', {
272
+ if (status < 200 || status >= 300) {
273
+ res.resume();
274
+ protocolError('fetch_failed', {
153
275
  url: parsed.toString(),
154
- status: res.status,
155
- statusText: res.statusText,
276
+ status,
277
+ statusText: res.statusMessage ?? '',
156
278
  });
157
279
  }
158
- // Enforce Content-Length up front if present.
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
- throw new McpError(ErrorCode.InvalidParams, 'response_too_large', {
162
- url: parsed.toString(),
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
- while (true) {
177
- const { done, value } = await reader.read();
178
- if (done)
179
- break;
180
- if (value) {
181
- total += value.byteLength;
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.map((c) => Buffer.from(c))),
200
- contentType: res.headers.get('content-type'),
298
+ buffer: Buffer.concat(chunks),
299
+ contentType: header(res, 'content-type'),
201
300
  finalUrl: parsed.toString(),
202
301
  hops,
203
302
  };
204
303
  }
205
- throw new McpError(ErrorCode.InvalidParams, 'too_many_redirects', {
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 McpError)
307
+ if (err instanceof ProtocolError)
211
308
  throw err;
212
- if (err.name === 'AbortError') {
213
- throw new McpError(ErrorCode.InvalidParams, 'fetch_timeout', { url: urlStr, timeoutMs });
309
+ if (controller.signal.aborted || err.name === 'AbortError') {
310
+ return protocolError('fetch_timeout', { url: urlStr, timeoutMs });
214
311
  }
215
- const message = err instanceof Error ? err.message : String(err);
216
- throw new McpError(ErrorCode.InvalidParams, 'fetch_error', { url: urlStr, message });
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);