@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
@@ -1,5 +1,5 @@
1
1
  import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
2
- import { openAiConversationScopeId } from "../../../request-meta.js";
2
+ import { mcpHandlerRequestContext } from "../../../request-context.js";
3
3
  import { formatPathForPrompt } from "../../../../workspaces/resources/skills.js";
4
4
  import { compositeCapabilityContext } from "../../core/capability-support.js";
5
5
  import { compositeWorkspaceLogContext, logToolCall, textBlock } from "../../core/tool-support.js";
@@ -7,14 +7,13 @@ import { openWorkspaceToolDefinition } from "./workspace-open-schema.js";
7
7
  import { presentLocalWorkspaceOpen, workspaceTaskUsageInstruction, } from "./workspace-open-presentation.js";
8
8
  export function registerOpenWorkspaceTool(options) {
9
9
  const { server } = options;
10
- registerAppTool(server, "open_workspace", openWorkspaceToolDefinition, (args, extra) => handleOpenWorkspace(options, args, extra));
10
+ registerAppTool(server, "open_workspace", openWorkspaceToolDefinition, (args, extra) => handleOpenWorkspace(options, args, mcpHandlerRequestContext(extra)));
11
11
  }
12
- async function handleOpenWorkspace(options, input, extra) {
13
- const { config, workspaces, remoteWorkspaces, compositeWorkspaces, workspaceTasks, processSessions, capabilityRegistry, compositeTaskGuides, loadCompositeMemberContext, rememberWorkspacePanelState, hostScopeIdFor, presentation, } = options;
12
+ async function handleOpenWorkspace(options, input, requestContext) {
13
+ const { config, workspaces, remoteWorkspaces, compositeWorkspaces, workspaceTasks, processSessions, capabilityRegistry, compositeTaskGuides, loadCompositeMemberContext, rememberWorkspacePanelState, hostScopeIdFor, contextDeliveryScopeIdFor, presentation, } = options;
14
14
  const { action = "open", memberAction, member, kind, name, memberName, path, relay, workspaceId, mode, baseRef, newWorktree, newWorkspace, context, root, status, state, staleOnly, offset, limit, } = input;
15
- const { _meta, sessionId } = extra;
16
15
  const startedAt = performance.now();
17
- const conversationScopeId = openAiConversationScopeId(_meta);
16
+ const conversationScopeId = contextDeliveryScopeIdFor(requestContext);
18
17
  const protectedWorkspaceIds = processSessions.activeWorkspaceIds();
19
18
  const inspectTaskSummary = (targetWorkspaceId) => {
20
19
  try {
@@ -469,7 +468,7 @@ async function handleOpenWorkspace(options, input, extra) {
469
468
  newWorktree !== undefined || newWorkspace !== undefined) {
470
469
  throw new Error("Resuming a relayed Workspace by workspaceId accepts context only.");
471
470
  }
472
- const resumed = await remoteWorkspaces.resumeWorkspace(workspaceId, context ?? "auto", hostScopeIdFor(_meta, sessionId));
471
+ const resumed = await remoteWorkspaces.resumeWorkspace(workspaceId, context ?? "auto", hostScopeIdFor(requestContext));
473
472
  rememberWorkspacePanelState(workspaceId, resumed);
474
473
  return resumed;
475
474
  }
@@ -483,7 +482,7 @@ async function handleOpenWorkspace(options, input, extra) {
483
482
  newWorktree,
484
483
  newWorkspace,
485
484
  context,
486
- }, hostScopeIdFor(_meta, sessionId));
485
+ }, hostScopeIdFor(requestContext));
487
486
  const relayedSkills = Array.isArray(opened.skills)
488
487
  ? opened.skills
489
488
  : [];
@@ -568,5 +567,10 @@ async function handleOpenWorkspace(options, input, extra) {
568
567
  rememberWorkspacePanelState(opened.workspaceId, response);
569
568
  return response;
570
569
  }
571
- return presentLocalWorkspaceOpen(presentation, input, { conversationScopeId, protectedWorkspaceIds, startedAt, sessionId });
570
+ return presentLocalWorkspaceOpen(presentation, input, {
571
+ conversationScopeId,
572
+ protectedWorkspaceIds,
573
+ startedAt,
574
+ sessionId: requestContext.transportSessionId,
575
+ });
572
576
  }
@@ -1,5 +1,6 @@
1
1
  import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
2
2
  import * as z from "zod/v4";
3
+ import { mcpHandlerRequestContext } from "../../../request-context.js";
3
4
  import { CapabilityError } from "../../core/capability-registry.js";
4
5
  import { attachHookReports, runToolWithHooks } from "../../../hooks/hooks.js";
5
6
  import { toolNames } from "../../../server-instructions.js";
@@ -36,8 +37,9 @@ export function registerWorkspaceAuxiliaryTools(options) {
36
37
  openWorldHint: false,
37
38
  },
38
39
  }, async ({ workspaceId, path }, extra) => {
40
+ const requestContext = mcpHandlerRequestContext(extra);
39
41
  if (remoteWorkspaces.has(workspaceId)) {
40
- return remoteWorkspaces.workspaceInstruction(workspaceId, path, hostScopeIdFor(extra._meta, extra.sessionId));
42
+ return remoteWorkspaces.workspaceInstruction(workspaceId, path, hostScopeIdFor(requestContext));
41
43
  }
42
44
  if (compositeWorkspaces.has(workspaceId)) {
43
45
  throw new Error("Composite Workspace instructions belong to a selected member; open that member context before viewing its instruction files.");
@@ -91,6 +93,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
91
93
  openWorldHint: true,
92
94
  },
93
95
  }, async ({ workspaceId, member, name, action, arguments: capabilityArguments, file }, extra) => {
96
+ const requestContext = mcpHandlerRequestContext(extra);
94
97
  if (name === "workspace.tasks" && compositeWorkspaces.has(workspaceId)) {
95
98
  if (member !== undefined) {
96
99
  throw new Error(`workspace.tasks belongs to Composite Workspace ${workspaceId} itself and does not accept member.`);
@@ -105,9 +108,9 @@ export function registerWorkspaceAuxiliaryTools(options) {
105
108
  if (action === "run") {
106
109
  const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, context, {
107
110
  nativeFile: file,
108
- signal: extra.signal,
109
- requestMeta: extra._meta,
110
- sessionId: extra.sessionId,
111
+ signal: requestContext.signal,
112
+ requestMeta: requestContext.requestMeta,
113
+ sessionId: requestContext.transportSessionId,
111
114
  });
112
115
  const result = {
113
116
  content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
@@ -146,7 +149,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
146
149
  return result;
147
150
  }
148
151
  catch (error) {
149
- if (extra.signal.aborted)
152
+ if (requestContext.signal.aborted)
150
153
  throw error;
151
154
  const capabilityError = error instanceof CapabilityError
152
155
  ? error
@@ -171,16 +174,16 @@ export function registerWorkspaceAuxiliaryTools(options) {
171
174
  }
172
175
  const target = resolveExecutionTarget(workspaceId, member);
173
176
  const executionWorkspaceId = target.executionWorkspaceId;
174
- const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
177
+ const executionContext = await prepareExecutionContext(target, requestContext);
175
178
  if (remoteWorkspaces.has(executionWorkspaceId)) {
176
179
  const response = await remoteWorkspaces.capability(executionWorkspaceId, {
177
180
  name,
178
181
  action,
179
182
  ...(capabilityArguments !== undefined ? { arguments: capabilityArguments } : {}),
180
183
  ...(file !== undefined ? { file } : {}),
181
- }, hostScopeIdFor(extra._meta, extra.sessionId));
184
+ }, hostScopeIdFor(requestContext));
182
185
  return action === "run" && name !== "workspace.tasks"
183
- ? presentSemanticWorkResult(response, target, hostScopeIdFor(extra._meta, extra.sessionId))
186
+ ? presentSemanticWorkResult(response, target, hostScopeIdFor(requestContext))
184
187
  : presentExecutionResult(response, target);
185
188
  }
186
189
  if (action === "run" && name === "batch.execute") {
@@ -189,9 +192,9 @@ export function registerWorkspaceAuxiliaryTools(options) {
189
192
  try {
190
193
  const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, capabilityContextFor(workspace), {
191
194
  nativeFile: file,
192
- signal: extra.signal,
193
- requestMeta: extra._meta,
194
- sessionId: extra.sessionId,
195
+ signal: requestContext.signal,
196
+ requestMeta: requestContext.requestMeta,
197
+ sessionId: requestContext.transportSessionId,
195
198
  });
196
199
  const result = {
197
200
  content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
@@ -199,16 +202,16 @@ export function registerWorkspaceAuxiliaryTools(options) {
199
202
  };
200
203
  logToolCall(config, {
201
204
  tool: toolNames.capability,
202
- ...workspaceLogContext(workspace, extra.sessionId),
205
+ ...workspaceLogContext(workspace, requestContext.transportSessionId),
203
206
  capability: name,
204
207
  action,
205
208
  success: true,
206
209
  durationMs: Math.round(performance.now() - startedAt),
207
210
  });
208
- return presentSemanticWorkResult(result, target, hostScopeIdFor(extra._meta, extra.sessionId));
211
+ return presentSemanticWorkResult(result, target, hostScopeIdFor(requestContext));
209
212
  }
210
213
  catch (error) {
211
- if (extra.signal.aborted)
214
+ if (requestContext.signal.aborted)
212
215
  throw error;
213
216
  const capabilityError = error instanceof CapabilityError
214
217
  ? error
@@ -224,7 +227,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
224
227
  };
225
228
  logFailedToolResponse(config, {
226
229
  tool: toolNames.capability,
227
- ...workspaceLogContext(workspace, extra.sessionId),
230
+ ...workspaceLogContext(workspace, requestContext.transportSessionId),
228
231
  capability: name,
229
232
  action,
230
233
  }, result.content, startedAt);
@@ -235,11 +238,11 @@ export function registerWorkspaceAuxiliaryTools(options) {
235
238
  const response = await coreOperations.capabilityRun({ workspaceId: executionWorkspaceId, name, arguments: capabilityArguments, file }, executionContext);
236
239
  return name === "workspace.tasks"
237
240
  ? presentExecutionResult(response, target)
238
- : presentSemanticWorkResult(response, target, hostScopeIdFor(extra._meta, extra.sessionId));
241
+ : presentSemanticWorkResult(response, target, hostScopeIdFor(requestContext));
239
242
  }
240
243
  const workspace = workspaces.getWorkspace(executionWorkspaceId);
241
- return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(extra._meta, extra.sessionId), activityRequestFor({ workspaceId: executionWorkspaceId, name, action, arguments: capabilityArguments, file }, executionContext), {
242
- signal: extra.signal,
244
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(requestContext), activityRequestFor({ workspaceId: executionWorkspaceId, name, action, arguments: capabilityArguments, file }, executionContext), {
245
+ signal: requestContext.signal,
243
246
  tool: toolNames.capability,
244
247
  invocation: workspaceHookInvocation(workspace),
245
248
  payload: { name, action },
@@ -332,6 +335,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
332
335
  _meta: {},
333
336
  annotations: WRITE_TOOL_ANNOTATIONS,
334
337
  }, async ({ workspaceId, action = "close", commitMessage }, extra) => {
338
+ const requestContext = mcpHandlerRequestContext(extra);
335
339
  if (compositeWorkspaces.has(workspaceId)) {
336
340
  const startedAt = performance.now();
337
341
  if (commitMessage !== undefined) {
@@ -392,7 +396,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
392
396
  };
393
397
  }
394
398
  if (remoteWorkspaces.has(workspaceId)) {
395
- const response = await remoteWorkspaces.closeWorkspace(workspaceId, { action, ...(commitMessage !== undefined ? { commitMessage } : {}) }, hostScopeIdFor(extra._meta, extra.sessionId));
399
+ const response = await remoteWorkspaces.closeWorkspace(workspaceId, { action, ...(commitMessage !== undefined ? { commitMessage } : {}) }, hostScopeIdFor(requestContext));
396
400
  if (action === "delete") {
397
401
  activityQueries.deleteWorkspaceHistory(config.stateDir, workspaceId);
398
402
  }
@@ -409,7 +413,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
409
413
  throw new Error(`Workspace ${session.id} still owns a running process. Poll, interrupt, or wait for it before deleting this Workspace.`);
410
414
  }
411
415
  const response = await runToolWithHooks(hooks, {
412
- signal: extra.signal,
416
+ signal: requestContext.signal,
413
417
  tool: toolNames.closeWorkspace,
414
418
  invocation: {
415
419
  workspaceId: session.id,
@@ -428,7 +432,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
428
432
  const result = `Deleted ForgeRelay Workspace ${session.id}. Physical project files were not removed.`;
429
433
  logToolCall(config, {
430
434
  tool: toolNames.closeWorkspace,
431
- ...workspaceLogContext(session, extra.sessionId),
435
+ ...workspaceLogContext(session, requestContext.transportSessionId),
432
436
  action: "delete",
433
437
  path: session.root,
434
438
  success: true,
@@ -463,7 +467,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
463
467
  }
464
468
  const hookRoot = session.sourceRoot ?? session.root;
465
469
  const response = await runToolWithHooks(hooks, {
466
- signal: extra.signal,
470
+ signal: requestContext.signal,
467
471
  tool: toolNames.closeWorkspace,
468
472
  invocation: {
469
473
  workspaceId: session.id,
@@ -509,7 +513,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
509
513
  }
510
514
  const workspace = workspaces.getWorkspace(session.id);
511
515
  const response = await runToolWithHooks(hooks, {
512
- signal: extra.signal,
516
+ signal: requestContext.signal,
513
517
  tool: toolNames.closeWorkspace,
514
518
  invocation: workspaceHookInvocation(workspace),
515
519
  payload: { workspaceId: workspace.id, action, commitMessage, mode: workspace.mode },
@@ -558,7 +562,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
558
562
  ].join("\n");
559
563
  logToolCall(config, {
560
564
  tool: toolNames.closeWorkspace,
561
- ...workspaceLogContext(workspace, extra.sessionId),
565
+ ...workspaceLogContext(workspace, requestContext.transportSessionId),
562
566
  action,
563
567
  path: closed.sourceRoot,
564
568
  success: true,
@@ -610,7 +614,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
610
614
  const result = `Closed checkout-backed Workspace ${checkoutWorkspaceId}; its ForgeRelay identity was preserved for later reopen. Physical project files were not removed.`;
611
615
  logToolCall(config, {
612
616
  tool: toolNames.closeWorkspace,
613
- ...workspaceLogContext(workspace, extra.sessionId),
617
+ ...workspaceLogContext(workspace, requestContext.transportSessionId),
614
618
  action: "close",
615
619
  path: workspace.root,
616
620
  success: true,
@@ -4,6 +4,8 @@ import { join, resolve } from "node:path";
4
4
  import { expandHomePath } from "../../mcp/filesystem/roots.js";
5
5
  import { mergeHookConfigs, parseHookConfig } from "../../mcp/hooks/hooks.js";
6
6
  import { forgerelayAgentsDir, forgerelaySkillsDir, generateInstanceId, loadForgeRelayFiles, } from "./user-config.js";
7
+ import { parseExternalMcpServers, } from "./external-mcp-config.js";
8
+ import { DEFAULT_MEDIA_MAX_BYTES } from "../../mcp/operations/media-content.js";
7
9
  import { shellInstructionPath } from "../instructions/shell-instructions.js";
8
10
  import { resolveConfiguredCommandShellRuntime, } from "../shell/command-shell-runtime.js";
9
11
  const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
@@ -303,6 +305,7 @@ export function loadConfig(env = process.env) {
303
305
  ? files.config.artifactsEnabled === true
304
306
  : parseBoolean(productEnv(env, "ARTIFACTS")),
305
307
  artifactMaxFileBytes: parsePositiveInteger(productEnv(env, "ARTIFACT_MAX_FILE_BYTES") ?? numberConfigValue(files.config.artifactMaxFileBytes), DEFAULT_ARTIFACT_MAX_FILE_BYTES, "FORGERELAY_ARTIFACT_MAX_FILE_BYTES"),
308
+ mediaMaxBytes: parsePositiveInteger(productEnv(env, "MEDIA_MAX_BYTES") ?? numberConfigValue(files.config.mediaMaxBytes), DEFAULT_MEDIA_MAX_BYTES, "FORGERELAY_MEDIA_MAX_BYTES"),
306
309
  taskReminderInterval: parseNonNegativeInteger(productEnv(env, "TASK_REMINDER_INTERVAL") ?? numberConfigValue(files.config.taskReminderInterval), DEFAULT_TASK_REMINDER_INTERVAL, "FORGERELAY_TASK_REMINDER_INTERVAL"),
307
310
  skillsEnabled: productEnv(env, "SKILLS") === undefined ? true : parseBoolean(productEnv(env, "SKILLS")),
308
311
  skillPaths: parsePathList(productEnv(env, "SKILL_PATHS")),
@@ -313,6 +316,7 @@ export function loadConfig(env = process.env) {
313
316
  : parseBoolean(productEnv(env, "SUBAGENTS")),
314
317
  languageServers: files.config.languageServers ?? {},
315
318
  allowAgentLanguageServerInstall: files.config.allowAgentLanguageServerInstall === true,
319
+ mcpServers: parseExternalMcpServers(files.config.mcpServers),
316
320
  agentDir: resolve(expandHomePath(productEnv(env, "AGENT_DIR") ?? files.config.agentDir ?? defaultAgentDir())),
317
321
  systemInstructionsPath: parseSystemInstructionsPath(productEnv(env, "SYSTEM_INSTRUCTIONS_PATH") ?? files.config.systemInstructionsPath),
318
322
  hooks: mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles),
@@ -0,0 +1,225 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { withFileLock } from "../state/lock/file-lock.js";
5
+ import { forgerelayConfigDir } from "./user-config.js";
6
+ const AUTH_FILE_VERSION = 1;
7
+ const AUTH_FILE_NAME = "mcp-auth.json";
8
+ export class ExternalMcpCredentialStore {
9
+ configDir;
10
+ filePath;
11
+ constructor(options = {}) {
12
+ this.configDir = resolve(options.configDir ?? forgerelayConfigDir(options.env));
13
+ this.filePath = join(this.configDir, AUTH_FILE_NAME);
14
+ }
15
+ read(identity) {
16
+ const record = this.readFile().credentials[credentialKey(identity)];
17
+ if (!record || !sameIdentity(record.identity, identity))
18
+ return undefined;
19
+ return cloneRecord(record);
20
+ }
21
+ inspect() {
22
+ const exists = existsSync(this.filePath);
23
+ const file = this.readFile();
24
+ return { exists, credentialCount: Object.keys(file.credentials).length };
25
+ }
26
+ async replace(identity, serverUrl, record) {
27
+ let written;
28
+ await this.updateFile((file) => {
29
+ written = {
30
+ ...cloneRecord(record),
31
+ identity: normalizeIdentity(identity),
32
+ serverUrl,
33
+ revision: randomUUID(),
34
+ updatedAt: new Date().toISOString(),
35
+ };
36
+ file.credentials[credentialKey(identity)] = written;
37
+ });
38
+ return cloneRecord(written);
39
+ }
40
+ async update(identity, serverUrl, update) {
41
+ let written;
42
+ await this.updateFile((file) => {
43
+ const key = credentialKey(identity);
44
+ const existing = file.credentials[key];
45
+ const current = existing && sameIdentity(existing.identity, identity) ? cloneRecord(existing) : undefined;
46
+ const next = update(current);
47
+ if (!next) {
48
+ delete file.credentials[key];
49
+ written = undefined;
50
+ return;
51
+ }
52
+ written = {
53
+ ...cloneRecord(next),
54
+ identity: normalizeIdentity(identity),
55
+ serverUrl,
56
+ revision: randomUUID(),
57
+ updatedAt: new Date().toISOString(),
58
+ };
59
+ file.credentials[key] = written;
60
+ });
61
+ return written ? cloneRecord(written) : undefined;
62
+ }
63
+ async remove(identity) {
64
+ let removed = false;
65
+ await this.updateFile((file) => {
66
+ const key = credentialKey(identity);
67
+ const existing = file.credentials[key];
68
+ if (!existing || !sameIdentity(existing.identity, identity))
69
+ return;
70
+ delete file.credentials[key];
71
+ removed = true;
72
+ });
73
+ return removed;
74
+ }
75
+ withIdentityLock(identity, operation) {
76
+ mkdirSync(this.configDir, { recursive: true });
77
+ return withFileLock(join(this.configDir, `.mcp-auth-${credentialKeyHash(identity)}.lock`), operation, {
78
+ timeoutMs: 15_000,
79
+ staleMs: 60_000,
80
+ mode: 0o600,
81
+ });
82
+ }
83
+ readFile() {
84
+ if (!existsSync(this.filePath))
85
+ return emptyAuthFile();
86
+ let raw;
87
+ try {
88
+ raw = JSON.parse(readFileSync(this.filePath, "utf8"));
89
+ }
90
+ catch {
91
+ throw new Error(`Unable to read External MCP credential store ${this.filePath}: invalid JSON.`);
92
+ }
93
+ return parseAuthFile(raw, this.filePath);
94
+ }
95
+ async updateFile(update) {
96
+ mkdirSync(this.configDir, { recursive: true });
97
+ await withFileLock(`${this.filePath}.lock`, () => {
98
+ const file = this.readFile();
99
+ update(file);
100
+ writePrivateJsonFile(this.filePath, file);
101
+ }, { mode: 0o600 });
102
+ }
103
+ }
104
+ export function externalMcpCredentialIdentity(source, server, projectRoot) {
105
+ return source === "project"
106
+ ? { kind: "project", server, projectRoot: resolve(projectRoot) }
107
+ : { kind: "global", server };
108
+ }
109
+ export function credentialKey(identity) {
110
+ const normalized = normalizeIdentity(identity);
111
+ return normalized.kind === "global"
112
+ ? `global:${normalized.server}`
113
+ : `project:${credentialKeyHash(normalized)}:${normalized.server}`;
114
+ }
115
+ function credentialKeyHash(identity) {
116
+ const normalized = normalizeIdentity(identity);
117
+ const material = normalized.kind === "global"
118
+ ? `global\0${normalized.server}`
119
+ : `project\0${normalized.projectRoot}\0${normalized.server}`;
120
+ return createHash("sha256").update(material).digest("hex");
121
+ }
122
+ function normalizeIdentity(identity) {
123
+ return identity.kind === "project"
124
+ ? { kind: "project", server: identity.server, projectRoot: resolve(identity.projectRoot) }
125
+ : { kind: "global", server: identity.server };
126
+ }
127
+ function sameIdentity(left, right) {
128
+ const a = normalizeIdentity(left);
129
+ const b = normalizeIdentity(right);
130
+ return a.kind === b.kind
131
+ && a.server === b.server
132
+ && (a.kind === "global" || (b.kind === "project" && a.projectRoot === b.projectRoot));
133
+ }
134
+ function emptyAuthFile() {
135
+ return { version: AUTH_FILE_VERSION, credentials: {} };
136
+ }
137
+ function parseAuthFile(value, filePath) {
138
+ if (!isRecord(value) || value.version !== AUTH_FILE_VERSION || !isRecord(value.credentials)) {
139
+ throw new Error(`Unable to read External MCP credential store ${filePath}: unsupported format.`);
140
+ }
141
+ const credentials = {};
142
+ for (const [key, raw] of Object.entries(value.credentials)) {
143
+ credentials[key] = parseCredentialRecord(raw, filePath);
144
+ }
145
+ return { version: AUTH_FILE_VERSION, credentials };
146
+ }
147
+ function parseCredentialRecord(value, filePath) {
148
+ if (!isRecord(value) || !isCredentialIdentity(value.identity)) {
149
+ throw new Error(`Unable to read External MCP credential store ${filePath}: invalid credential identity.`);
150
+ }
151
+ if (typeof value.serverUrl !== "string"
152
+ || typeof value.revision !== "string"
153
+ || typeof value.updatedAt !== "string") {
154
+ throw new Error(`Unable to read External MCP credential store ${filePath}: invalid credential metadata.`);
155
+ }
156
+ if (value.tokens !== undefined
157
+ && (!isRecord(value.tokens) || typeof value.tokens.access_token !== "string")) {
158
+ throw new Error(`Unable to read External MCP credential store ${filePath}: invalid token record.`);
159
+ }
160
+ if (value.clientInformation !== undefined
161
+ && (!isRecord(value.clientInformation) || typeof value.clientInformation.client_id !== "string")) {
162
+ throw new Error(`Unable to read External MCP credential store ${filePath}: invalid client record.`);
163
+ }
164
+ if (value.discoveryState !== undefined
165
+ && (!isRecord(value.discoveryState) || typeof value.discoveryState.authorizationServerUrl !== "string")) {
166
+ throw new Error(`Unable to read External MCP credential store ${filePath}: invalid discovery record.`);
167
+ }
168
+ const reauthorization = parseReauthorization(value.reauthorization, filePath);
169
+ return {
170
+ identity: normalizeIdentity(value.identity),
171
+ serverUrl: value.serverUrl,
172
+ revision: value.revision,
173
+ updatedAt: value.updatedAt,
174
+ ...(value.tokens ? { tokens: structuredClone(value.tokens) } : {}),
175
+ ...(value.clientInformation
176
+ ? { clientInformation: structuredClone(value.clientInformation) }
177
+ : {}),
178
+ ...(value.discoveryState
179
+ ? { discoveryState: structuredClone(value.discoveryState) }
180
+ : {}),
181
+ ...(typeof value.authorizationServerUrl === "string"
182
+ ? { authorizationServerUrl: value.authorizationServerUrl }
183
+ : {}),
184
+ ...(typeof value.resourceUrl === "string" ? { resourceUrl: value.resourceUrl } : {}),
185
+ ...(reauthorization ? { reauthorization } : {}),
186
+ };
187
+ }
188
+ function parseReauthorization(value, filePath) {
189
+ if (value === undefined)
190
+ return undefined;
191
+ if (!isRecord(value)
192
+ || !["authorization_required", "invalid_grant", "insufficient_scope", "binding_changed"].includes(String(value.reason))
193
+ || typeof value.observedAt !== "string"
194
+ || (value.scope !== undefined && typeof value.scope !== "string")) {
195
+ throw new Error(`Unable to read External MCP credential store ${filePath}: invalid reauthorization state.`);
196
+ }
197
+ return {
198
+ reason: value.reason,
199
+ observedAt: value.observedAt,
200
+ ...(typeof value.scope === "string" && value.scope.trim() ? { scope: value.scope.trim() } : {}),
201
+ };
202
+ }
203
+ function isCredentialIdentity(value) {
204
+ if (!isRecord(value) || typeof value.server !== "string" || !value.server)
205
+ return false;
206
+ if (value.kind === "global")
207
+ return true;
208
+ return value.kind === "project" && typeof value.projectRoot === "string" && Boolean(value.projectRoot);
209
+ }
210
+ function cloneRecord(value) {
211
+ return structuredClone(value);
212
+ }
213
+ function writePrivateJsonFile(filePath, value) {
214
+ const temporary = `${filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
215
+ try {
216
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
217
+ renameSync(temporary, filePath);
218
+ }
219
+ finally {
220
+ rmSync(temporary, { force: true });
221
+ }
222
+ }
223
+ function isRecord(value) {
224
+ return typeof value === "object" && value !== null && !Array.isArray(value);
225
+ }
@@ -0,0 +1,146 @@
1
+ const MCP_SERVER_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
2
+ const MAX_MCP_SERVERS = 32;
3
+ export function parseExternalMcpServers(value) {
4
+ return parseServerRegistry(value, "mcpServers", false);
5
+ }
6
+ export function parseExternalMcpStandaloneConfig(value) {
7
+ if (!isRecord(value))
8
+ throw new Error("External MCP configuration must be a JSON object.");
9
+ rejectUnknownKeys(value, new Set(["servers"]), "External MCP configuration");
10
+ if (!("servers" in value))
11
+ throw new Error("External MCP configuration must contain a servers object.");
12
+ return parseServerRegistry(value.servers, "servers", true);
13
+ }
14
+ function parseServerRegistry(value, label, allowDisabled) {
15
+ if (value === undefined && !allowDisabled)
16
+ return {};
17
+ if (!isRecord(value))
18
+ throw new Error(`${label} must be an object keyed by server name.`);
19
+ const entries = Object.entries(value);
20
+ if (entries.length > MAX_MCP_SERVERS) {
21
+ throw new Error(`${label} may contain at most ${MAX_MCP_SERVERS} configured servers.`);
22
+ }
23
+ return Object.fromEntries(entries.map(([name, raw]) => {
24
+ if (!MCP_SERVER_NAME_PATTERN.test(name)) {
25
+ throw new Error(`Invalid ${label} name '${name}'. Use a lowercase stable name.`);
26
+ }
27
+ if (!isRecord(raw))
28
+ throw new Error(`${label}.${name} must be an object.`);
29
+ const disabled = allowDisabled && "disabled" in raw ? parseDisabled(raw.disabled, `${label}.${name}.disabled`) : false;
30
+ const serverValue = allowDisabled && "disabled" in raw
31
+ ? Object.fromEntries(Object.entries(raw).filter(([key]) => key !== "disabled"))
32
+ : raw;
33
+ if (disabled && Object.keys(serverValue).length === 0) {
34
+ return [name, { disabled: true }];
35
+ }
36
+ const server = serverValue.transport === "stdio"
37
+ ? parseStdio(name, serverValue, label)
38
+ : serverValue.transport === "streamable-http"
39
+ ? parseHttp(name, serverValue, label)
40
+ : undefined;
41
+ if (!server)
42
+ throw new Error(`${label}.${name}.transport must be 'stdio' or 'streamable-http'.`);
43
+ return [name, disabled ? { disabled: true } : server];
44
+ }));
45
+ }
46
+ function parseStdio(name, value, label) {
47
+ const command = requiredString(value.command, `${label}.${name}.command`);
48
+ const args = optionalStringArray(value.args, `${label}.${name}.args`);
49
+ const env = optionalStringRecord(value.env, `${label}.${name}.env`);
50
+ const cwd = optionalString(value.cwd, `${label}.${name}.cwd`);
51
+ rejectUnknownKeys(value, new Set(["transport", "command", "args", "env", "cwd"]), `${label}.${name}`);
52
+ return {
53
+ transport: "stdio",
54
+ command,
55
+ ...(args ? { args } : {}),
56
+ ...(env ? { env } : {}),
57
+ ...(cwd ? { cwd } : {}),
58
+ };
59
+ }
60
+ function parseHttp(name, value, label) {
61
+ const url = requiredString(value.url, `${label}.${name}.url`);
62
+ let parsed;
63
+ try {
64
+ parsed = new URL(url);
65
+ }
66
+ catch {
67
+ throw new Error(`${label}.${name}.url must be a valid HTTP(S) URL.`);
68
+ }
69
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
70
+ throw new Error(`${label}.${name}.url must use http or https.`);
71
+ }
72
+ const headers = optionalStringRecord(value.headers, `${label}.${name}.headers`);
73
+ const oauth = parseOAuthClientConfig(value.oauth, `${label}.${name}.oauth`);
74
+ rejectUnknownKeys(value, new Set(["transport", "url", "headers", "oauth"]), `${label}.${name}`);
75
+ return {
76
+ transport: "streamable-http",
77
+ url: parsed.toString(),
78
+ ...(headers ? { headers } : {}),
79
+ ...(oauth ? { oauth } : {}),
80
+ };
81
+ }
82
+ function parseOAuthClientConfig(value, label) {
83
+ if (value === undefined)
84
+ return undefined;
85
+ if (!isRecord(value))
86
+ throw new Error(`${label} must be an object.`);
87
+ rejectUnknownKeys(value, new Set(["clientMetadataUrl", "callbackPort"]), label);
88
+ const clientMetadataUrl = requiredString(value.clientMetadataUrl, `${label}.clientMetadataUrl`);
89
+ let parsed;
90
+ try {
91
+ parsed = new URL(clientMetadataUrl);
92
+ }
93
+ catch {
94
+ throw new Error(`${label}.clientMetadataUrl must be a valid HTTPS URL.`);
95
+ }
96
+ if (parsed.protocol !== "https:" || parsed.pathname === "/") {
97
+ throw new Error(`${label}.clientMetadataUrl must use https and contain a non-root path.`);
98
+ }
99
+ const callbackPort = value.callbackPort;
100
+ if (!Number.isInteger(callbackPort) || Number(callbackPort) < 1024 || Number(callbackPort) > 65535) {
101
+ throw new Error(`${label}.callbackPort must be an integer from 1024 to 65535.`);
102
+ }
103
+ return {
104
+ clientMetadataUrl: parsed.toString(),
105
+ callbackPort: Number(callbackPort),
106
+ };
107
+ }
108
+ function parseDisabled(value, label) {
109
+ if (typeof value !== "boolean")
110
+ throw new Error(`${label} must be a boolean.`);
111
+ return value;
112
+ }
113
+ function requiredString(value, label) {
114
+ if (typeof value !== "string" || !value.trim())
115
+ throw new Error(`${label} must be a non-empty string.`);
116
+ return value.trim();
117
+ }
118
+ function optionalString(value, label) {
119
+ if (value === undefined)
120
+ return undefined;
121
+ return requiredString(value, label);
122
+ }
123
+ function optionalStringArray(value, label) {
124
+ if (value === undefined)
125
+ return undefined;
126
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
127
+ throw new Error(`${label} must be an array of strings.`);
128
+ }
129
+ return [...value];
130
+ }
131
+ function optionalStringRecord(value, label) {
132
+ if (value === undefined)
133
+ return undefined;
134
+ if (!isRecord(value) || Object.values(value).some((entry) => typeof entry !== "string")) {
135
+ throw new Error(`${label} must be an object of string values.`);
136
+ }
137
+ return { ...value };
138
+ }
139
+ function rejectUnknownKeys(value, allowed, label) {
140
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
141
+ if (unknown.length > 0)
142
+ throw new Error(`${label} contains unsupported fields: ${unknown.join(", ")}.`);
143
+ }
144
+ function isRecord(value) {
145
+ return typeof value === "object" && value !== null && !Array.isArray(value);
146
+ }