@akira-tl/forgerelay 1.0.1 → 1.1.1

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 (169) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +4 -0
  3. package/capabilities/host-integration/external-mcp/GUIDE.md +29 -0
  4. package/capabilities/lifecycle-hooks/GUIDE.md +13 -3
  5. package/dist/activity/runtime/mcp-query-tools.js +16 -10
  6. package/dist/cli/mcp/external-mcp.js +481 -0
  7. package/dist/cli/mcp/status.js +295 -0
  8. package/dist/cli.js +14 -1
  9. package/dist/lsp/test-support/server-fixture.js +1 -2
  10. package/dist/mcp/filesystem/filesystem-tools.js +7 -3
  11. package/dist/mcp/hooks/command-runner.js +12 -7
  12. package/dist/mcp/hooks/external-mcp-transform.js +224 -0
  13. package/dist/mcp/hooks/hook-cli.js +3 -0
  14. package/dist/mcp/hooks/hooks.js +49 -3
  15. package/dist/mcp/oauth/auth-protocol.js +375 -0
  16. package/dist/mcp/oauth/oauth-provider.js +11 -12
  17. package/dist/mcp/oauth/oauth-store.js +2 -2
  18. package/dist/mcp/oauth/router.js +2 -6
  19. package/dist/mcp/operations/batch/executor.js +1 -1
  20. package/dist/mcp/operations/bulk-read.js +7 -6
  21. package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +387 -0
  22. package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +59 -0
  23. package/dist/mcp/operations/external-mcp/external-mcp.js +382 -0
  24. package/dist/mcp/operations/media-content.js +28 -0
  25. package/dist/mcp/operations/native-bulk-mutations.js +1 -1
  26. package/dist/mcp/process/tools.js +19 -15
  27. package/dist/mcp/request-context.js +18 -0
  28. package/dist/mcp/request-meta.js +28 -3
  29. package/dist/mcp/server/core/activity-support.js +2 -2
  30. package/dist/mcp/server/core/capabilities/external-mcp.js +31 -0
  31. package/dist/mcp/server/core/capabilities.js +7 -0
  32. package/dist/mcp/server/core/capability-registry.js +2 -0
  33. package/dist/mcp/server/core/tool-support.js +29 -0
  34. package/dist/mcp/server/operations/runtime/filesystem-tools.js +91 -48
  35. package/dist/mcp/server/operations/runtime/operation-runtime.js +18 -11
  36. package/dist/mcp/server/transport/http-server.js +42 -21
  37. package/dist/mcp/server/workspace/runtime/workspace-open.js +13 -9
  38. package/dist/mcp/server/workspace/runtime/workspace-tools.js +29 -25
  39. package/dist/runtime/config/config.js +4 -0
  40. package/dist/runtime/config/external-mcp-auth-store.js +225 -0
  41. package/dist/runtime/config/external-mcp-config.js +146 -0
  42. package/dist/runtime/config/external-mcp-registry.js +165 -0
  43. package/dist/runtime/testing/server-fixture.js +10 -5
  44. package/dist/server.js +19 -10
  45. package/dist/subagents/sessions/mcp/audit.js +81 -0
  46. package/dist/ui/.vite/manifest.json +297 -298
  47. package/dist/ui/activity-panel-app.html +1 -1
  48. package/dist/ui/assets/activity-panel-app-Bw7ZFX2g.js +114 -0
  49. package/dist/ui/assets/angular-html-DeUNP12X.js +1 -0
  50. package/dist/ui/assets/{angular-ts-BearSZDb.js → angular-ts-B3v8FKi_.js} +1 -1
  51. package/dist/ui/assets/{apl-CYQtr-y6.js → apl-Bs3iQm4P.js} +1 -1
  52. package/dist/ui/assets/{astro-DDzqKIjK.js → astro-D6GHecBY.js} +1 -1
  53. package/dist/ui/assets/{blade-DkwEyHvQ.js → blade-BMNbVOHv.js} +1 -1
  54. package/dist/ui/assets/c-BquuA6bv.js +1 -0
  55. package/dist/ui/assets/{cobol-xBn5JxHE.js → cobol-BuZ6kfps.js} +1 -1
  56. package/dist/ui/assets/{coffee-BaXXvoJw.js → coffee-DgC5uBzL.js} +1 -1
  57. package/dist/ui/assets/cpp-BdvWqCaB.js +1 -0
  58. package/dist/ui/assets/{crystal-DjOpXVU1.js → crystal-0Q5qBvNI.js} +1 -1
  59. package/dist/ui/assets/css-Be_T8idh.js +1 -0
  60. package/dist/ui/assets/{edge-DNaRizoR.js → edge-DaGLdgh_.js} +1 -1
  61. package/dist/ui/assets/{elixir-CwBMwZmy.js → elixir-CMgiFIzB.js} +1 -1
  62. package/dist/ui/assets/{elm-gOUD2CW3.js → elm-B-ufMlMC.js} +1 -1
  63. package/dist/ui/assets/{erb-BcR-fjUp.js → erb-CaGiOZM-.js} +1 -1
  64. package/dist/ui/assets/{git-rebase-BAQPKHF6.js → git-rebase-CpwUWCzK.js} +1 -1
  65. package/dist/ui/assets/{glimmer-js-P_wOfSz_.js → glimmer-js-BRxccvnE.js} +1 -1
  66. package/dist/ui/assets/{glimmer-ts-B44aY1fZ.js → glimmer-ts-CgQbPzlT.js} +1 -1
  67. package/dist/ui/assets/glsl-D7JmHFr0.js +1 -0
  68. package/dist/ui/assets/graphql-C8n0eUBK.js +1 -0
  69. package/dist/ui/assets/{hack-ddU2k0nt.js → hack-lVN7KVQv.js} +1 -1
  70. package/dist/ui/assets/haml-h18lG0pS.js +1 -0
  71. package/dist/ui/assets/{handlebars-BpzaUpmz.js → handlebars-lRFjRPfj.js} +1 -1
  72. package/dist/ui/assets/heavy-payload-Cqx9MOkH.js +290 -0
  73. package/dist/ui/assets/html-DtILqTE1.js +1 -0
  74. package/dist/ui/assets/{html-derivative-B2beVdmA.js → html-derivative-CSTSmPup.js} +1 -1
  75. package/dist/ui/assets/{http-iEskZy8U.js → http-DNI8fsbY.js} +1 -1
  76. package/dist/ui/assets/{hurl-BuUsLSKv.js → hurl-DgRi60JG.js} +1 -1
  77. package/dist/ui/assets/java-CW9pZ5bh.js +1 -0
  78. package/dist/ui/assets/javascript-Cvg2UrWL.js +1 -0
  79. package/dist/ui/assets/{jinja-CRpkWwrH.js → jinja-CZw_S1FB.js} +1 -1
  80. package/dist/ui/assets/{jison-BYfSnuHj.js → jison-C56u_FZv.js} +1 -1
  81. package/dist/ui/assets/json-DW2UTv1q.js +1 -0
  82. package/dist/ui/assets/jsx-Zj5gobdD.js +1 -0
  83. package/dist/ui/assets/{julia-B8Rxy7vD.js → julia-Cvcrre-L.js} +1 -1
  84. package/dist/ui/assets/{just-DzhIWpFP.js → just-8CZ2u6Tj.js} +1 -1
  85. package/dist/ui/assets/{latex-CUNjUUL-.js → latex-D289AXWG.js} +1 -1
  86. package/dist/ui/assets/{liquid-hUg7nC61.js → liquid-CFEX3SkZ.js} +1 -1
  87. package/dist/ui/assets/lua-BKpJ0ybO.js +1 -0
  88. package/dist/ui/assets/{marko-CQmIkwMF.js → marko-D9maZ0a6.js} +1 -1
  89. package/dist/ui/assets/{mdc-CB-6dHYa.js → mdc-rM9l0qWR.js} +1 -1
  90. package/dist/ui/assets/{nginx-CQORo_Bt.js → nginx-C9w6p2C9.js} +1 -1
  91. package/dist/ui/assets/{nim-BNwBNzdn.js → nim-BRePHk0J.js} +1 -1
  92. package/dist/ui/assets/{perl-Cnz14fAJ.js → perl-CskTmiPQ.js} +1 -1
  93. package/dist/ui/assets/{php-YywDo63b.js → php-DCU_FvNI.js} +1 -1
  94. package/dist/ui/assets/{pug-BwbJp4cM.js → pug-XLaVxTvv.js} +1 -1
  95. package/dist/ui/assets/{qml-D2DY6dMH.js → qml-CaONKSl-.js} +1 -1
  96. package/dist/ui/assets/r-BCVR0ZE3.js +1 -0
  97. package/dist/ui/assets/{razor-pMTG7k41.js → razor-DDnGXxnU.js} +1 -1
  98. package/dist/ui/assets/regexp-DWGTYYVx.js +1 -0
  99. package/dist/ui/assets/{rst-CO_ndDm4.js → rst-Dz3GWvQm.js} +1 -1
  100. package/dist/ui/assets/{ruby-xzRn9gTh.js → ruby-DC3jGtMY.js} +1 -1
  101. package/dist/ui/assets/{sas-CBdtFKLA.js → sas-7mNpvsjZ.js} +1 -1
  102. package/dist/ui/assets/scss-uBsoCJVy.js +1 -0
  103. package/dist/ui/assets/shellscript-BCN9N_Jf.js +1 -0
  104. package/dist/ui/assets/{shellsession-BaZnm5rZ.js → shellsession-Hsw46VXV.js} +1 -1
  105. package/dist/ui/assets/{soy-IouG3xdB.js → soy-D6KV69HU.js} +1 -1
  106. package/dist/ui/assets/sql-CVzVQkJh.js +1 -0
  107. package/dist/ui/assets/{stata-dgbkIZHP.js → stata-BwUjXNhI.js} +1 -1
  108. package/dist/ui/assets/{surrealql-CBROnw_p.js → surrealql-CgloWqRa.js} +1 -1
  109. package/dist/ui/assets/{svelte-6r6BpX4-.js → svelte-DcC3n6IC.js} +1 -1
  110. package/dist/ui/assets/{templ-BKD_PsbL.js → templ-DOFfnWTJ.js} +1 -1
  111. package/dist/ui/assets/{tex-CKY1bwHb.js → tex-ln89VPaX.js} +1 -1
  112. package/dist/ui/assets/{ts-tags-BiYP5N_C.js → ts-tags-CWMzCstH.js} +1 -1
  113. package/dist/ui/assets/tsx-DgzfiGeK.js +1 -0
  114. package/dist/ui/assets/{twig-BTe1oPFW.js → twig-Bi2itZL9.js} +1 -1
  115. package/dist/ui/assets/typescript-CQ24f4zW.js +1 -0
  116. package/dist/ui/assets/{vue-CT0IiySh.js → vue-BS8jE5u6.js} +1 -1
  117. package/dist/ui/assets/{vue-html-B-tXcbat.js → vue-html-o38xqhyB.js} +1 -1
  118. package/dist/ui/assets/{vue-vine-C5ZyDCrs.js → vue-vine-D2NvKv50.js} +1 -1
  119. package/dist/ui/assets/xml-_44rx9_2.js +1 -0
  120. package/dist/ui/assets/{xsl-DvDSndNI.js → xsl-B5QWQPoi.js} +1 -1
  121. package/dist/ui/assets/yaml-CKL2pEWe.js +1 -0
  122. package/dist/workspaces/relay/auth/remote-auth.js +3 -5
  123. package/dist/workspaces/relay/result-support.js +19 -0
  124. package/dist/workspaces/relay/tests/test-support.js +10 -4
  125. package/dist/workspaces/relay/workspace-relay.js +20 -12
  126. package/docs/configuration.md +200 -2
  127. package/docs/debugging.md +8 -5
  128. package/docs/roadmap.md +64 -3
  129. package/docs/security.md +18 -0
  130. package/package.json +12 -4
  131. package/scripts/debug/accept/bootstrap.mjs +5 -0
  132. package/scripts/debug/accept/harness.mjs +10 -3
  133. package/scripts/debug/accept/media.mjs +335 -0
  134. package/scripts/debug/accept/modern-http.mjs +169 -0
  135. package/scripts/debug/accept/support.mjs +10 -6
  136. package/scripts/debug/accept.mjs +12 -1
  137. package/scripts/debug/relay-accept/support.mjs +40 -0
  138. package/scripts/debug/relay-accept.mjs +8 -9
  139. package/scripts/debug/runtime.mjs +4 -1
  140. package/scripts/debug/runtime.test.mjs +9 -0
  141. package/scripts/release/parity-sandbox.mjs +35 -0
  142. package/scripts/release/release-gate.test.mjs +42 -1
  143. package/scripts/release-parity.mjs +3 -22
  144. package/scripts/release-proof.mjs +24 -4
  145. package/scripts/release-proof.test.mjs +12 -4
  146. package/dist/ui/assets/activity-panel-app-XlqSanXT.js +0 -115
  147. package/dist/ui/assets/angular-html-tB9EchbM.js +0 -1
  148. package/dist/ui/assets/c-Dv-N5hjn.js +0 -1
  149. package/dist/ui/assets/cpp-C3unXX-3.js +0 -1
  150. package/dist/ui/assets/css-8Xr4kNNH.js +0 -1
  151. package/dist/ui/assets/glsl-BvCQg9MI.js +0 -1
  152. package/dist/ui/assets/graphql-C9S90gn_.js +0 -1
  153. package/dist/ui/assets/haml-Bb1w9rMG.js +0 -1
  154. package/dist/ui/assets/heavy-payload-CJKqi8Ac.js +0 -290
  155. package/dist/ui/assets/html-dUOs5pA9.js +0 -1
  156. package/dist/ui/assets/java-BFZCrORY.js +0 -1
  157. package/dist/ui/assets/javascript-DaIQ0Zqj.js +0 -1
  158. package/dist/ui/assets/json-BQC_Yp48.js +0 -1
  159. package/dist/ui/assets/jsx-Dd-CJRf9.js +0 -1
  160. package/dist/ui/assets/lua-BoZTiL2I.js +0 -1
  161. package/dist/ui/assets/r-B5nYsy-M.js +0 -1
  162. package/dist/ui/assets/regexp-oP0lbks_.js +0 -1
  163. package/dist/ui/assets/scss-CEqgKQNf.js +0 -1
  164. package/dist/ui/assets/shellscript-DzJJiWQC.js +0 -1
  165. package/dist/ui/assets/sql-DC2rdTzV.js +0 -1
  166. package/dist/ui/assets/tsx-D2OMiPNF.js +0 -1
  167. package/dist/ui/assets/typescript-BGIABksB.js +0 -1
  168. package/dist/ui/assets/xml-CfeBNlMP.js +0 -1
  169. package/dist/ui/assets/yaml-umbj2r1g.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -39,6 +39,8 @@
39
39
  "dev": "node scripts/debug/serve.mjs",
40
40
  "debug:serve": "node scripts/debug/serve.mjs",
41
41
  "debug:accept": "node scripts/debug/accept.mjs",
42
+ "debug:accept:media": "node scripts/debug/accept/media.mjs",
43
+ "debug:accept:modern": "node scripts/debug/accept/modern-http.mjs",
42
44
  "debug:accept:relay": "node scripts/debug/relay-accept.mjs",
43
45
  "traffic:audit": "node --import tsx scripts/debug/traffic/traffic-audit.mjs",
44
46
  "lsp:interop": "node scripts/lsp/interop.mjs",
@@ -56,7 +58,7 @@
56
58
  "release:push-ready": "node scripts/release/push-ready.mjs",
57
59
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
58
60
  "start": "node dist/cli.js serve",
59
- "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/workspaces/relay/tests/recovery.test.ts && tsx src/workspaces/relay/tests/checkpoint.test.ts && tsx src/runtime/config/config.test.ts && tsx src/runtime/shell/command-shell-runtime.test.ts && tsx src/runtime/instructions/shell-instructions.test.ts && tsx src/runtime/instructions/powershell-skill.test.ts && tsx src/cli/shell/setup.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/runtime/state/runtime-lease.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/mcp/server/operations/hooks.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/mcp/server/workspace/workspace-recovery.test.ts && tsx src/mcp/server/workspace/workspace-checkpoint.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/maintenance.test.ts && tsx src/cli/maintenance-prune.test.ts && tsx src/cli/cli.test.ts",
61
+ "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/workspaces/relay/tests/recovery.test.ts && tsx src/workspaces/relay/tests/checkpoint.test.ts && tsx src/runtime/config/config.test.ts && tsx src/runtime/config/external-mcp-registry.test.ts && tsx src/runtime/config/external-mcp-auth-store.test.ts && tsx src/mcp/operations/external-mcp/external-mcp-oauth.test.ts && tsx src/cli/mcp/status.test.ts && tsx src/cli/mcp/diagnostics.test.ts && tsx src/cli/mcp/external-mcp.test.ts && tsx src/runtime/shell/command-shell-runtime.test.ts && tsx src/runtime/instructions/shell-instructions.test.ts && tsx src/runtime/instructions/powershell-skill.test.ts && tsx src/cli/shell/setup.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/runtime/state/runtime-lease.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-context.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/mcp/server/operations/hooks.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/mcp/server/workspace/workspace-recovery.test.ts && tsx src/mcp/server/workspace/workspace-checkpoint.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/maintenance.test.ts && tsx src/cli/maintenance-prune.test.ts && tsx src/cli/cli.test.ts",
60
62
  "typecheck": "tsc -p tsconfig.json --noEmit",
61
63
  "release:check": "node scripts/release-version.mjs check",
62
64
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -79,13 +81,18 @@
79
81
  "dependencies": {
80
82
  "@agentclientprotocol/sdk": "^1.1.0",
81
83
  "@clack/prompts": "^1.5.1",
82
- "@modelcontextprotocol/ext-apps": "^1.7.2",
83
- "@modelcontextprotocol/sdk": "^1.29.0",
84
+ "@modelcontextprotocol/client": "^2.0.0",
85
+ "@modelcontextprotocol/core": "^2.0.0",
86
+ "@modelcontextprotocol/express": "^2.0.0",
87
+ "@modelcontextprotocol/ext-apps": "^2.0.0",
88
+ "@modelcontextprotocol/node": "^2.0.0",
89
+ "@modelcontextprotocol/server": "^2.0.0",
84
90
  "@pierre/diffs": "^1.2.5",
85
91
  "better-sqlite3": "^13.0.3",
86
92
  "diff": "^8.0.3",
87
93
  "drizzle-orm": "^0.45.2",
88
94
  "express": "^5.2.1",
95
+ "express-rate-limit": "8.5.2",
89
96
  "lucide": "^1.24.0",
90
97
  "react": "^19.2.6",
91
98
  "react-dom": "^19.2.6",
@@ -96,6 +103,7 @@
96
103
  "zod": "^4.4.3"
97
104
  },
98
105
  "devDependencies": {
106
+ "@modelcontextprotocol/sdk": "1.29.0",
99
107
  "@types/better-sqlite3": "^7.6.13",
100
108
  "@types/express": "^5.0.6",
101
109
  "@types/node": "^25.9.1",
@@ -192,7 +192,10 @@ export async function runBootstrapAcceptance({ server, packageJson, ownerToken,
192
192
  "capability-guides.read",
193
193
  "code.intelligence",
194
194
  "workspace.tasks",
195
+ "workspace.recovery",
196
+ "workspace.checkpoint",
195
197
  "batch.execute",
198
+ "mcp.external",
196
199
  ...(process.platform === "linux" ? ["artifact.native-download"] : []),
197
200
  "ui.mcp-app",
198
201
  "review.changes",
@@ -203,7 +206,9 @@ export async function runBootstrapAcceptance({ server, packageJson, ownerToken,
203
206
  "hooks.check",
204
207
  "review.changes",
205
208
  "code.intelligence",
209
+ "workspace.checkpoint",
206
210
  "workspace.tasks",
211
+ "mcp.external",
207
212
  "batch.execute",
208
213
  ...(process.platform === "linux" ? ["artifact.download"] : []),
209
214
  ]);
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
6
6
  import { join, resolve } from "node:path";
7
7
  import {
8
8
  createDebugEnvironment,
9
+ debugConfigDir,
9
10
  debugRoot,
10
11
  repoRoot,
11
12
  } from "../runtime.mjs";
@@ -19,6 +20,7 @@ import { assertCurlAvailable, assertDebugPortFree, pass, setupGitProject } from
19
20
  export async function createAcceptanceHarness() {
20
21
  const packageJson = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
21
22
  const acceptanceRoot = resolve(debugRoot, "acceptance");
23
+ const configDir = resolve(acceptanceRoot, "config");
22
24
  const stateDir = resolve(acceptanceRoot, "state");
23
25
  const worktreeRoot = resolve(acceptanceRoot, "worktrees");
24
26
  const hookLog = resolve(acceptanceRoot, "hooks.jsonl");
@@ -35,7 +37,11 @@ export async function createAcceptanceHarness() {
35
37
  assertCurlAvailable();
36
38
  await assertDebugPortFree();
37
39
  rmSync(acceptanceRoot, { recursive: true, force: true });
38
- mkdirSync(acceptanceRoot, { recursive: true });
40
+ mkdirSync(configDir, { recursive: true });
41
+ writeFileSync(
42
+ join(configDir, "config.json"),
43
+ readFileSync(join(debugConfigDir, "config.json"), "utf8"),
44
+ );
39
45
  setupGitProject(checkoutWorkspace);
40
46
  setupGitProject(lifecycleDeleteWorkspace);
41
47
  writeFileSync(join(lifecycleDeleteWorkspace, "keep.txt"), "keep checkout files\n");
@@ -44,6 +50,7 @@ export async function createAcceptanceHarness() {
44
50
 
45
51
  const { env } = createDebugEnvironment({
46
52
  ownerToken,
53
+ configDir,
47
54
  stateDir,
48
55
  worktreeRoot,
49
56
  hookLog,
@@ -56,7 +63,7 @@ export async function createAcceptanceHarness() {
56
63
  encoding: "utf8",
57
64
  });
58
65
  assert.equal(doctor.status, 0, doctor.stderr);
59
- assert.match(doctor.stdout, /Public base URL: http:\/\/127\.0\.0\.1:7677/);
66
+ assert.match(doctor.stdout, /Client-facing base URL: http:\/\/127\.0\.0\.1:7677/);
60
67
  assert.match(doctor.stdout, /Tool mode: full/);
61
68
  assert.match(doctor.stdout, /Widgets: changes/);
62
69
  assert.match(doctor.stdout, /Trust proxy: off/);
@@ -69,7 +76,7 @@ export async function createAcceptanceHarness() {
69
76
  });
70
77
 
71
78
  return {
72
- packageJson, acceptanceRoot, stateDir, worktreeRoot, hookLog, checkoutWorkspace,
79
+ packageJson, acceptanceRoot, configDir, stateDir, worktreeRoot, hookLog, checkoutWorkspace,
73
80
  lifecycleDeleteWorkspace, codeIntelligenceLog, fakeLanguageServer, gitProject, releaseProject,
74
81
  releaseRemote, ownerToken, tempAcceptanceRoot, env, server,
75
82
  };
@@ -0,0 +1,335 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { randomBytes, randomUUID } from "node:crypto";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join, resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { deflateSync } from "node:zlib";
16
+ import { debugBaseUrl, debugMcpUrl, debugRoot, repoRoot } from "../runtime.mjs";
17
+ import {
18
+ assertDebugPortFree,
19
+ authorizeDebugClient,
20
+ callTool,
21
+ curlRequest,
22
+ initializeRequest,
23
+ jsonRequest,
24
+ mcpHeaders,
25
+ mcpRequest,
26
+ pass,
27
+ stopServer,
28
+ toolText,
29
+ waitForHealth,
30
+ } from "./support.mjs";
31
+
32
+ const acceptanceRoot = resolve(debugRoot, "media-acceptance");
33
+ const configDir = join(acceptanceRoot, "config");
34
+ const stateDir = join(acceptanceRoot, "state");
35
+ const workspaceRoot = join(acceptanceRoot, "workspace");
36
+ const outsideRoot = resolve(tmpdir(), `forgerelay-media-outside-${randomUUID()}`);
37
+ const ownerToken = randomBytes(32).toString("base64url");
38
+ const credentialSentinel = "MEDIA_ACCEPTANCE_EXTERNAL_MCP_CREDENTIAL_SECRET";
39
+ const directImageBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZcXcAAAAASUVORK5CYII=";
40
+ const externalFixture = fileURLToPath(
41
+ new URL("../../../src/mcp/operations/external-mcp/test-fixtures/external-mcp-server.mjs", import.meta.url),
42
+ );
43
+ const transformScriptPath = join(workspaceRoot, ".forgerelay", "path-image-transform.mjs");
44
+ const transformHookPath = join(workspaceRoot, ".forgerelay", "hooks", "20-render-path-image.json");
45
+
46
+ await assertDebugPortFree();
47
+ rmSync(acceptanceRoot, { recursive: true, force: true });
48
+ rmSync(outsideRoot, { recursive: true, force: true });
49
+ mkdirSync(configDir, { recursive: true });
50
+ mkdirSync(stateDir, { recursive: true });
51
+ mkdirSync(join(workspaceRoot, "renders"), { recursive: true });
52
+ mkdirSync(join(workspaceRoot, ".forgerelay", "hooks"), { recursive: true });
53
+ mkdirSync(outsideRoot, { recursive: true });
54
+
55
+ const visualSentinel = createVisualSentinelPng();
56
+ const visualSentinelBase64 = visualSentinel.toString("base64");
57
+ writeFileSync(join(workspaceRoot, "visual-sentinel.png"), visualSentinel);
58
+ writeFileSync(join(workspaceRoot, "renders", "output.png"), visualSentinel);
59
+ writeFileSync(join(outsideRoot, "outside.txt"), "outside allowed root\n");
60
+ writeFileSync(join(workspaceRoot, "README.md"), "media acceptance workspace\n");
61
+
62
+ const mediaMaxBytes = visualSentinel.byteLength + Math.max(32, Math.floor(visualSentinel.byteLength / 2));
63
+ assert.ok(mediaMaxBytes >= visualSentinel.byteLength);
64
+ assert.ok(mediaMaxBytes < visualSentinel.byteLength * 2);
65
+
66
+ writeFileSync(join(configDir, "config.json"), `${JSON.stringify({
67
+ host: "127.0.0.1",
68
+ port: 7677,
69
+ allowedRoots: [workspaceRoot],
70
+ publicBaseUrl: debugBaseUrl,
71
+ allowedHosts: ["localhost", "127.0.0.1", "::1"],
72
+ stateDir,
73
+ worktreeRoot: join(acceptanceRoot, "worktrees"),
74
+ mediaMaxBytes,
75
+ mcpServers: {
76
+ blender: {
77
+ transport: "stdio",
78
+ command: process.execPath,
79
+ args: [externalFixture],
80
+ env: { FORGERELAY_MEDIA_ACCEPTANCE_SECRET: credentialSentinel },
81
+ },
82
+ },
83
+ }, null, 2)}\n`);
84
+ writeFileSync(join(configDir, "auth.json"), `${JSON.stringify({
85
+ ownerToken,
86
+ instanceId: "media-acceptance-7677",
87
+ }, null, 2)}\n`, { mode: 0o600 });
88
+
89
+ const cleanEnv = Object.fromEntries(
90
+ Object.entries(process.env).filter(([name]) =>
91
+ name !== "HOST" && name !== "PORT" && !name.startsWith("FORGERELAY_")
92
+ ),
93
+ );
94
+ const env = {
95
+ ...cleanEnv,
96
+ HOST: "127.0.0.1",
97
+ PORT: "7677",
98
+ FORGERELAY_CONFIG_DIR: configDir,
99
+ FORGERELAY_OAUTH_OWNER_TOKEN: ownerToken,
100
+ FORGERELAY_PUBLIC_BASE_URL: debugBaseUrl,
101
+ FORGERELAY_TOOL_MODE: "full",
102
+ FORGERELAY_WIDGETS: "off",
103
+ FORGERELAY_LOG_LEVEL: "info",
104
+ FORGERELAY_LOG_FORMAT: "json",
105
+ };
106
+
107
+ let serverLogs = "";
108
+ const server = spawn(process.execPath, ["--import", "tsx", "src/cli.ts", "serve"], {
109
+ cwd: repoRoot,
110
+ env,
111
+ stdio: ["ignore", "pipe", "pipe"],
112
+ });
113
+ server.stdout?.on("data", (chunk) => { serverLogs += chunk.toString(); });
114
+ server.stderr?.on("data", (chunk) => { serverLogs += chunk.toString(); });
115
+
116
+ let completed = false;
117
+ try {
118
+ await waitForHealth(server);
119
+ pass("media acceptance health", debugBaseUrl);
120
+
121
+ const authorizationServer = jsonRequest(`${debugBaseUrl}/.well-known/oauth-authorization-server`);
122
+ assert.equal(authorizationServer.status, 200);
123
+ const oauth = authorizeDebugClient(authorizationServer.json, ownerToken);
124
+ const initialized = mcpRequest(oauth.accessToken, undefined, initializeRequest(1));
125
+ const sessionId = initialized.response.headers.get("mcp-session-id");
126
+ assert.ok(sessionId);
127
+ const initializedNotification = curlRequest({
128
+ method: "POST",
129
+ url: debugMcpUrl,
130
+ headers: mcpHeaders(oauth.accessToken, sessionId),
131
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
132
+ });
133
+ assert.equal(initializedNotification.status, 202);
134
+ pass("media acceptance OAuth/MCP", "real HTTP MCP session initialized on 7677");
135
+
136
+ let requestId = 10;
137
+ const nextId = () => requestId++;
138
+ const conversation = { "openai/session": "media-acceptance-7677" };
139
+ const opened = callTool(oauth.accessToken, sessionId, nextId(), "open_workspace", {
140
+ path: workspaceRoot,
141
+ context: "none",
142
+ }, conversation);
143
+ assert.equal(opened.isError, undefined, toolText(opened));
144
+ const workspaceId = opened.structuredContent.workspaceId;
145
+ assert.match(workspaceId, /^ws_/);
146
+
147
+ const readImage = callTool(oauth.accessToken, sessionId, nextId(), "read", {
148
+ workspaceId,
149
+ path: "visual-sentinel.png",
150
+ }, conversation);
151
+ assert.equal(readImage.isError, undefined, toolText(readImage));
152
+ const readImageContent = readImage.content.find((entry) => entry.type === "image");
153
+ assert.ok(readImageContent);
154
+ assert.equal(readImageContent.mimeType, "image/png");
155
+ assert.equal(readImageContent.data, visualSentinelBase64);
156
+ assert.deepEqual(readImage.structuredContent.media, {
157
+ type: "image",
158
+ mimeType: "image/png",
159
+ bytes: visualSentinel.byteLength,
160
+ });
161
+ assert.equal(JSON.stringify(readImage.structuredContent).includes(visualSentinelBase64), false);
162
+ assert.equal(JSON.stringify(readImage._meta ?? {}).includes(visualSentinelBase64), false);
163
+ pass(
164
+ "real 7677 image Read",
165
+ `${visualSentinel.byteLength} PNG bytes reached the Host-facing MCP result unchanged`,
166
+ );
167
+
168
+ const outsideRead = callTool(oauth.accessToken, sessionId, nextId(), "read", {
169
+ path: join(outsideRoot, "outside.txt"),
170
+ }, conversation);
171
+ assert.equal(outsideRead.isError, true);
172
+ pass("real 7677 allowed-root containment", "unscoped Read outside the configured root was rejected");
173
+
174
+ const externalCall = (tool) => callTool(oauth.accessToken, sessionId, nextId(), "capability", {
175
+ workspaceId,
176
+ name: "mcp.external",
177
+ action: "run",
178
+ arguments: { operation: "call", server: "blender", tool },
179
+ }, conversation);
180
+
181
+ const directImage = externalCall("direct_image");
182
+ assert.equal(directImage.isError, undefined, toolText(directImage));
183
+ const externalImage = directImage.content.find((entry) => entry.type === "image");
184
+ assert.ok(externalImage);
185
+ assert.equal(externalImage.mimeType, "image/png");
186
+ assert.equal(externalImage.data, directImageBase64);
187
+ assert.equal(JSON.stringify(directImage.structuredContent).includes(directImageBase64), false);
188
+ pass("real 7677 external MCP ImageContent", "published mcp.external Capability forwarded direct image content");
189
+
190
+ const pathOnly = externalCall("path_only");
191
+ assert.equal(pathOnly.isError, undefined, toolText(pathOnly));
192
+ assert.equal(pathOnly.content.some((entry) => entry.type === "image"), false);
193
+ assert.equal(toolText(pathOnly), "renders/output.png");
194
+ pass("external MCP default reference pass-through", "path-only result stayed path-only without a transform Hook");
195
+
196
+ installPathTransform({ duplicate: false });
197
+ const transformed = externalCall("path_only");
198
+ assert.equal(transformed.isError, undefined, toolText(transformed));
199
+ const transformedImage = transformed.content.find((entry) => entry.type === "image");
200
+ assert.ok(transformedImage);
201
+ assert.equal(transformedImage.mimeType, "image/png");
202
+ assert.equal(transformedImage.data, visualSentinelBase64);
203
+ assert.equal(JSON.stringify(transformed.structuredContent).includes(visualSentinelBase64), false);
204
+ pass("external MCP explicit transform", "the same path-only result became ImageContent only after the matching Hook was installed");
205
+
206
+ installPathTransform({ duplicate: true });
207
+ const transformedOversize = externalCall("path_only");
208
+ assert.equal(transformedOversize.isError, true);
209
+ assert.match(toolText(transformedOversize), /mcp\.media_too_large/i);
210
+ assert.equal(transformedOversize.content.some((entry) => entry.type === "image"), false);
211
+ assert.equal(JSON.stringify(transformedOversize).includes(visualSentinelBase64), false);
212
+ pass("transformed media budget", "post-transform media was revalidated against the configured aggregate limit");
213
+
214
+ const malformed = externalCall("malformed_image");
215
+ assert.equal(malformed.isError, true);
216
+ assert.match(toolText(malformed), /mcp\.media_malformed/i);
217
+ const upstreamFailure = externalCall("fail");
218
+ assert.equal(upstreamFailure.isError, true);
219
+ assert.equal(JSON.stringify(upstreamFailure).includes(credentialSentinel), false);
220
+ pass("external MCP negative boundaries", "malformed media and upstream failure stayed bounded and credential-safe");
221
+
222
+ completed = true;
223
+ } finally {
224
+ await stopServer(server).catch(() => undefined);
225
+ if (completed) {
226
+ assertDirectoryDoesNotContain(stateDir, visualSentinelBase64);
227
+ assertDirectoryDoesNotContain(stateDir, directImageBase64);
228
+ assert.equal(serverLogs.includes(visualSentinelBase64), false, "server logs persisted transformed/read image base64");
229
+ assert.equal(serverLogs.includes(directImageBase64), false, "server logs persisted external image base64");
230
+ assert.equal(serverLogs.includes(credentialSentinel), false, "server logs leaked external MCP credentials");
231
+ pass("media persistence boundary", "image base64 stayed out of ForgeRelay state and logs");
232
+ }
233
+ rmSync(outsideRoot, { recursive: true, force: true });
234
+ }
235
+
236
+ if (completed) {
237
+ console.log("Media acceptance passed.");
238
+ }
239
+
240
+ function installPathTransform({ duplicate }) {
241
+ writeFileSync(transformScriptPath, [
242
+ 'import { readFileSync } from "node:fs";',
243
+ 'import { resolve } from "node:path";',
244
+ 'let input = "";',
245
+ 'for await (const chunk of process.stdin) input += chunk;',
246
+ 'const envelope = JSON.parse(input);',
247
+ 'const path = envelope.result?.content?.find((entry) => entry.type === "text")?.text;',
248
+ 'if (typeof path !== "string") throw new Error("expected path-only upstream result");',
249
+ 'const data = readFileSync(resolve(process.cwd(), path)).toString("base64");',
250
+ `const images = Array.from({ length: ${duplicate ? 2 : 1} }, () => ({ type: "image", data, mimeType: "image/png" }));`,
251
+ 'process.stdout.write(JSON.stringify({ version: 1, result: { content: images } }));',
252
+ '',
253
+ ].join("\n"));
254
+ writeFileSync(transformHookPath, `${JSON.stringify({
255
+ event: "ExternalMcpAfterForward",
256
+ matcher: {
257
+ capability: "mcp.external",
258
+ externalServer: "blender",
259
+ externalTool: "path_only",
260
+ },
261
+ command: 'node ".forgerelay/path-image-transform.mjs"',
262
+ timeoutSeconds: 10,
263
+ report: true,
264
+ }, null, 2)}\n`);
265
+ }
266
+
267
+ function assertDirectoryDoesNotContain(root, needle) {
268
+ if (!existsSync(root)) return;
269
+ const target = Buffer.from(needle, "utf8");
270
+ const pending = [root];
271
+ while (pending.length > 0) {
272
+ const directory = pending.pop();
273
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
274
+ const path = join(directory, entry.name);
275
+ if (entry.isDirectory()) pending.push(path);
276
+ else if (entry.isFile()) {
277
+ assert.equal(readFileSync(path).includes(target), false, `ForgeRelay persisted media base64 in ${path}`);
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ function createVisualSentinelPng() {
284
+ const width = 180;
285
+ const height = 100;
286
+ const stride = (width * 4) + 1;
287
+ const raw = Buffer.alloc(stride * height);
288
+ for (let y = 0; y < height; y += 1) {
289
+ const row = y * stride;
290
+ raw[row] = 0;
291
+ for (let x = 0; x < width; x += 1) {
292
+ const offset = row + 1 + (x * 4);
293
+ let pixel = [246, 246, 246, 255];
294
+ if (x >= 14 && x < 64 && y >= 20 && y < 80) pixel = [220, 36, 48, 255];
295
+ if (x >= 116 && x < 166 && y >= 20 && y < 80) pixel = [36, 86, 220, 255];
296
+ if (x >= 84 && x < 96 && y >= 10 && y < 90) pixel = [18, 18, 18, 255];
297
+ raw[offset] = pixel[0];
298
+ raw[offset + 1] = pixel[1];
299
+ raw[offset + 2] = pixel[2];
300
+ raw[offset + 3] = pixel[3];
301
+ }
302
+ }
303
+
304
+ const ihdr = Buffer.alloc(13);
305
+ ihdr.writeUInt32BE(width, 0);
306
+ ihdr.writeUInt32BE(height, 4);
307
+ ihdr[8] = 8;
308
+ ihdr[9] = 6;
309
+ return Buffer.concat([
310
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
311
+ pngChunk("IHDR", ihdr),
312
+ pngChunk("IDAT", deflateSync(raw, { level: 9 })),
313
+ pngChunk("IEND", Buffer.alloc(0)),
314
+ ]);
315
+ }
316
+
317
+ function pngChunk(type, data) {
318
+ const typeBuffer = Buffer.from(type, "ascii");
319
+ const length = Buffer.alloc(4);
320
+ length.writeUInt32BE(data.length, 0);
321
+ const crc = Buffer.alloc(4);
322
+ crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
323
+ return Buffer.concat([length, typeBuffer, data, crc]);
324
+ }
325
+
326
+ function crc32(buffer) {
327
+ let crc = 0xffffffff;
328
+ for (const byte of buffer) {
329
+ crc ^= byte;
330
+ for (let bit = 0; bit < 8; bit += 1) {
331
+ crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
332
+ }
333
+ }
334
+ return (crc ^ 0xffffffff) >>> 0;
335
+ }
@@ -0,0 +1,169 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { randomBytes } from "node:crypto";
4
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
7
+ import { debugRoot, repoRoot } from "../runtime.mjs";
8
+ import { assertPortFree, pass, stopServer, waitForHealth } from "./support.mjs";
9
+
10
+ const port = 7678;
11
+ const baseUrl = `http://127.0.0.1:${port}`;
12
+ const mcpUrl = `${baseUrl}/mcp`;
13
+ const acceptanceRoot = resolve(debugRoot, "modern-http-acceptance");
14
+ const configDir = join(acceptanceRoot, "config");
15
+ const stateDir = join(acceptanceRoot, "state");
16
+ const worktreeRoot = join(acceptanceRoot, "worktrees");
17
+ const workspaceRoot = join(acceptanceRoot, "workspace");
18
+ const ownerToken = randomBytes(32).toString("base64url");
19
+
20
+ await assertPortFree(port);
21
+ rmSync(acceptanceRoot, { recursive: true, force: true });
22
+ mkdirSync(configDir, { recursive: true });
23
+ mkdirSync(stateDir, { recursive: true });
24
+ mkdirSync(workspaceRoot, { recursive: true });
25
+ writeFileSync(join(workspaceRoot, "README.md"), "modern MCP acceptance workspace\n");
26
+ writeFileSync(join(configDir, "config.json"), `${JSON.stringify({
27
+ host: "127.0.0.1",
28
+ port,
29
+ allowedRoots: [workspaceRoot],
30
+ publicBaseUrl: baseUrl,
31
+ allowedHosts: ["localhost", "127.0.0.1", "::1"],
32
+ stateDir,
33
+ worktreeRoot,
34
+ }, null, 2)}\n`);
35
+ writeFileSync(join(configDir, "auth.json"), `${JSON.stringify({
36
+ ownerToken,
37
+ instanceId: "modern-http-acceptance-7678",
38
+ }, null, 2)}\n`, { mode: 0o600 });
39
+
40
+ const cleanEnv = Object.fromEntries(
41
+ Object.entries(process.env).filter(([name]) =>
42
+ name !== "HOST" && name !== "PORT" && !name.startsWith("FORGERELAY_")
43
+ ),
44
+ );
45
+ const env = {
46
+ ...cleanEnv,
47
+ HOST: "127.0.0.1",
48
+ PORT: String(port),
49
+ FORGERELAY_CONFIG_DIR: configDir,
50
+ FORGERELAY_OAUTH_OWNER_TOKEN: ownerToken,
51
+ FORGERELAY_PUBLIC_BASE_URL: baseUrl,
52
+ FORGERELAY_TOOL_MODE: "full",
53
+ FORGERELAY_WIDGETS: "off",
54
+ FORGERELAY_LOG_LEVEL: "debug",
55
+ FORGERELAY_LOG_FORMAT: "json",
56
+ };
57
+
58
+ let serverLogs = "";
59
+ const server = spawn(process.execPath, ["--import", "tsx", "src/cli.ts", "serve"], {
60
+ cwd: repoRoot,
61
+ env,
62
+ stdio: ["ignore", "pipe", "pipe"],
63
+ });
64
+ server.stdout?.on("data", (chunk) => { serverLogs += chunk.toString(); });
65
+ server.stderr?.on("data", (chunk) => { serverLogs += chunk.toString(); });
66
+
67
+ let client;
68
+ try {
69
+ await waitForHealth(server, baseUrl);
70
+ pass("modern HTTP health", baseUrl);
71
+
72
+ const unauthorized = await fetch(mcpUrl, {
73
+ method: "POST",
74
+ headers: {
75
+ "content-type": "application/json",
76
+ accept: "application/json, text/event-stream",
77
+ },
78
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "server/discover", params: {} }),
79
+ });
80
+ assert.equal(unauthorized.status, 401);
81
+ assert.match(unauthorized.headers.get("www-authenticate") ?? "", /Bearer .*resource_metadata=/i);
82
+ pass("modern HTTP bearer challenge", "unauthenticated MCP request advertises protected-resource metadata");
83
+
84
+ const tokenResponse = await fetch(`${baseUrl}/auth/cli`, {
85
+ method: "POST",
86
+ headers: { "content-type": "application/json" },
87
+ body: JSON.stringify({ owner_token: ownerToken }),
88
+ });
89
+ const tokenBody = await tokenResponse.text();
90
+ assert.equal(tokenResponse.status, 200, tokenBody);
91
+ const tokens = JSON.parse(tokenBody);
92
+ assert.equal(tokens.token_type, "bearer");
93
+ assert.ok(tokens.access_token);
94
+ pass("modern HTTP auth", "real ForgeRelay bearer token issued on 7678");
95
+
96
+ const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
97
+ requestInit: {
98
+ headers: { authorization: `Bearer ${tokens.access_token}` },
99
+ },
100
+ });
101
+ client = new Client(
102
+ { name: "forgerelay-modern-http-acceptance", version: "1.0.0" },
103
+ { versionNegotiation: { mode: { pin: "2026-07-28" } } },
104
+ );
105
+ await client.connect(transport);
106
+ assert.equal(client.getProtocolEra(), "modern");
107
+ assert.equal(client.getNegotiatedProtocolVersion(), "2026-07-28");
108
+ assert.equal(transport.sessionId, undefined);
109
+ pass("modern protocol negotiation", "2026-07-28 connected without Mcp-Session-Id");
110
+
111
+ const listed = await client.listTools();
112
+ assert.ok(listed.tools.some((tool) => tool.name === "open_workspace"));
113
+ const opened = await client.callTool({
114
+ name: "open_workspace",
115
+ arguments: { path: workspaceRoot, context: "none" },
116
+ });
117
+ assert.notEqual(opened.isError, true);
118
+ const workspaceId = String(opened.structuredContent?.workspaceId ?? "");
119
+ assert.match(workspaceId, /^ws_/);
120
+ pass("modern tools call", "tools/list and open_workspace completed over stateless HTTP");
121
+
122
+ const conversationMeta = { "dev.forgerelay/conversation": "modern-http-acceptance-conversation" };
123
+ const panel = await client.callTool({
124
+ name: "activity_panel",
125
+ arguments: { workspaceId },
126
+ _meta: conversationMeta,
127
+ });
128
+ assert.notEqual(panel.isError, true);
129
+ const turnId = String(panel.structuredContent?.turnId ?? "");
130
+ assert.match(turnId, /^turn_/);
131
+ const restored = await client.callTool({
132
+ name: "activity_snapshot",
133
+ arguments: { workspaceId },
134
+ _meta: conversationMeta,
135
+ });
136
+ assert.notEqual(restored.isError, true);
137
+ assert.equal(restored.structuredContent?.turnId, turnId);
138
+ pass("modern vendor conversation continuity", "stable ForgeRelay metadata restored the Host Turn across stateless requests");
139
+
140
+ const requestScopedPanel = await client.callTool({
141
+ name: "activity_panel",
142
+ arguments: { workspaceId },
143
+ });
144
+ assert.notEqual(requestScopedPanel.isError, true);
145
+ const requestScopedSnapshot = await client.callTool({
146
+ name: "activity_snapshot",
147
+ arguments: { workspaceId },
148
+ });
149
+ assert.equal(requestScopedSnapshot.isError, true);
150
+ assert.match(
151
+ requestScopedSnapshot.content?.map((entry) => entry.type === "text" ? entry.text : "").join("\n") ?? "",
152
+ /could not resolve the current Host Turn/i,
153
+ );
154
+ pass("modern request scope isolation", "requests without stable metadata do not share Host Turn state");
155
+
156
+ assert.equal(serverLogs.includes("\"protocolEra\":\"modern\""), true);
157
+ pass("modern routing observability", "ForgeRelay classified the accepted request as modern");
158
+
159
+ console.log("Modern HTTP acceptance passed.");
160
+ } catch (error) {
161
+ if (serverLogs) process.stderr.write(`\n--- ForgeRelay 7678 logs ---\n${serverLogs}`);
162
+ throw error;
163
+ } finally {
164
+ try {
165
+ await client?.close();
166
+ } finally {
167
+ await stopServer(server);
168
+ }
169
+ }