@akira-tl/forgerelay 1.1.0 → 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 (150) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -2
  3. package/dist/activity/runtime/mcp-query-tools.js +16 -10
  4. package/dist/cli/mcp/external-mcp.js +481 -0
  5. package/dist/cli/mcp/status.js +295 -0
  6. package/dist/cli.js +14 -1
  7. package/dist/lsp/test-support/server-fixture.js +1 -2
  8. package/dist/mcp/oauth/auth-protocol.js +375 -0
  9. package/dist/mcp/oauth/oauth-provider.js +11 -12
  10. package/dist/mcp/oauth/oauth-store.js +2 -2
  11. package/dist/mcp/oauth/router.js +2 -6
  12. package/dist/mcp/operations/batch/executor.js +1 -1
  13. package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +387 -0
  14. package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +23 -6
  15. package/dist/mcp/operations/external-mcp/external-mcp.js +178 -23
  16. package/dist/mcp/operations/native-bulk-mutations.js +1 -1
  17. package/dist/mcp/process/tools.js +19 -15
  18. package/dist/mcp/request-context.js +18 -0
  19. package/dist/mcp/request-meta.js +28 -3
  20. package/dist/mcp/server/core/capabilities.js +1 -4
  21. package/dist/mcp/server/operations/runtime/filesystem-tools.js +35 -28
  22. package/dist/mcp/server/operations/runtime/operation-runtime.js +7 -7
  23. package/dist/mcp/server/transport/http-server.js +41 -20
  24. package/dist/mcp/server/workspace/runtime/workspace-open.js +13 -9
  25. package/dist/mcp/server/workspace/runtime/workspace-tools.js +29 -25
  26. package/dist/runtime/config/external-mcp-auth-store.js +225 -0
  27. package/dist/runtime/config/external-mcp-config.js +76 -22
  28. package/dist/runtime/config/external-mcp-registry.js +165 -0
  29. package/dist/runtime/testing/server-fixture.js +4 -4
  30. package/dist/server.js +15 -9
  31. package/dist/ui/.vite/manifest.json +297 -298
  32. package/dist/ui/activity-panel-app.html +1 -1
  33. package/dist/ui/assets/activity-panel-app-Bw7ZFX2g.js +114 -0
  34. package/dist/ui/assets/angular-html-DeUNP12X.js +1 -0
  35. package/dist/ui/assets/{angular-ts-BearSZDb.js → angular-ts-B3v8FKi_.js} +1 -1
  36. package/dist/ui/assets/{apl-CYQtr-y6.js → apl-Bs3iQm4P.js} +1 -1
  37. package/dist/ui/assets/{astro-DDzqKIjK.js → astro-D6GHecBY.js} +1 -1
  38. package/dist/ui/assets/{blade-DkwEyHvQ.js → blade-BMNbVOHv.js} +1 -1
  39. package/dist/ui/assets/c-BquuA6bv.js +1 -0
  40. package/dist/ui/assets/{cobol-xBn5JxHE.js → cobol-BuZ6kfps.js} +1 -1
  41. package/dist/ui/assets/{coffee-BaXXvoJw.js → coffee-DgC5uBzL.js} +1 -1
  42. package/dist/ui/assets/cpp-BdvWqCaB.js +1 -0
  43. package/dist/ui/assets/{crystal-DjOpXVU1.js → crystal-0Q5qBvNI.js} +1 -1
  44. package/dist/ui/assets/css-Be_T8idh.js +1 -0
  45. package/dist/ui/assets/{edge-DNaRizoR.js → edge-DaGLdgh_.js} +1 -1
  46. package/dist/ui/assets/{elixir-CwBMwZmy.js → elixir-CMgiFIzB.js} +1 -1
  47. package/dist/ui/assets/{elm-gOUD2CW3.js → elm-B-ufMlMC.js} +1 -1
  48. package/dist/ui/assets/{erb-BcR-fjUp.js → erb-CaGiOZM-.js} +1 -1
  49. package/dist/ui/assets/{git-rebase-BAQPKHF6.js → git-rebase-CpwUWCzK.js} +1 -1
  50. package/dist/ui/assets/{glimmer-js-P_wOfSz_.js → glimmer-js-BRxccvnE.js} +1 -1
  51. package/dist/ui/assets/{glimmer-ts-B44aY1fZ.js → glimmer-ts-CgQbPzlT.js} +1 -1
  52. package/dist/ui/assets/glsl-D7JmHFr0.js +1 -0
  53. package/dist/ui/assets/graphql-C8n0eUBK.js +1 -0
  54. package/dist/ui/assets/{hack-ddU2k0nt.js → hack-lVN7KVQv.js} +1 -1
  55. package/dist/ui/assets/haml-h18lG0pS.js +1 -0
  56. package/dist/ui/assets/{handlebars-BpzaUpmz.js → handlebars-lRFjRPfj.js} +1 -1
  57. package/dist/ui/assets/heavy-payload-Cqx9MOkH.js +290 -0
  58. package/dist/ui/assets/html-DtILqTE1.js +1 -0
  59. package/dist/ui/assets/{html-derivative-B2beVdmA.js → html-derivative-CSTSmPup.js} +1 -1
  60. package/dist/ui/assets/{http-iEskZy8U.js → http-DNI8fsbY.js} +1 -1
  61. package/dist/ui/assets/{hurl-BuUsLSKv.js → hurl-DgRi60JG.js} +1 -1
  62. package/dist/ui/assets/java-CW9pZ5bh.js +1 -0
  63. package/dist/ui/assets/javascript-Cvg2UrWL.js +1 -0
  64. package/dist/ui/assets/{jinja-CRpkWwrH.js → jinja-CZw_S1FB.js} +1 -1
  65. package/dist/ui/assets/{jison-BYfSnuHj.js → jison-C56u_FZv.js} +1 -1
  66. package/dist/ui/assets/json-DW2UTv1q.js +1 -0
  67. package/dist/ui/assets/jsx-Zj5gobdD.js +1 -0
  68. package/dist/ui/assets/{julia-B8Rxy7vD.js → julia-Cvcrre-L.js} +1 -1
  69. package/dist/ui/assets/{just-DzhIWpFP.js → just-8CZ2u6Tj.js} +1 -1
  70. package/dist/ui/assets/{latex-CUNjUUL-.js → latex-D289AXWG.js} +1 -1
  71. package/dist/ui/assets/{liquid-hUg7nC61.js → liquid-CFEX3SkZ.js} +1 -1
  72. package/dist/ui/assets/lua-BKpJ0ybO.js +1 -0
  73. package/dist/ui/assets/{marko-CQmIkwMF.js → marko-D9maZ0a6.js} +1 -1
  74. package/dist/ui/assets/{mdc-CB-6dHYa.js → mdc-rM9l0qWR.js} +1 -1
  75. package/dist/ui/assets/{nginx-CQORo_Bt.js → nginx-C9w6p2C9.js} +1 -1
  76. package/dist/ui/assets/{nim-BNwBNzdn.js → nim-BRePHk0J.js} +1 -1
  77. package/dist/ui/assets/{perl-Cnz14fAJ.js → perl-CskTmiPQ.js} +1 -1
  78. package/dist/ui/assets/{php-YywDo63b.js → php-DCU_FvNI.js} +1 -1
  79. package/dist/ui/assets/{pug-BwbJp4cM.js → pug-XLaVxTvv.js} +1 -1
  80. package/dist/ui/assets/{qml-D2DY6dMH.js → qml-CaONKSl-.js} +1 -1
  81. package/dist/ui/assets/r-BCVR0ZE3.js +1 -0
  82. package/dist/ui/assets/{razor-pMTG7k41.js → razor-DDnGXxnU.js} +1 -1
  83. package/dist/ui/assets/regexp-DWGTYYVx.js +1 -0
  84. package/dist/ui/assets/{rst-CO_ndDm4.js → rst-Dz3GWvQm.js} +1 -1
  85. package/dist/ui/assets/{ruby-xzRn9gTh.js → ruby-DC3jGtMY.js} +1 -1
  86. package/dist/ui/assets/{sas-CBdtFKLA.js → sas-7mNpvsjZ.js} +1 -1
  87. package/dist/ui/assets/scss-uBsoCJVy.js +1 -0
  88. package/dist/ui/assets/shellscript-BCN9N_Jf.js +1 -0
  89. package/dist/ui/assets/{shellsession-BaZnm5rZ.js → shellsession-Hsw46VXV.js} +1 -1
  90. package/dist/ui/assets/{soy-IouG3xdB.js → soy-D6KV69HU.js} +1 -1
  91. package/dist/ui/assets/sql-CVzVQkJh.js +1 -0
  92. package/dist/ui/assets/{stata-dgbkIZHP.js → stata-BwUjXNhI.js} +1 -1
  93. package/dist/ui/assets/{surrealql-CBROnw_p.js → surrealql-CgloWqRa.js} +1 -1
  94. package/dist/ui/assets/{svelte-6r6BpX4-.js → svelte-DcC3n6IC.js} +1 -1
  95. package/dist/ui/assets/{templ-BKD_PsbL.js → templ-DOFfnWTJ.js} +1 -1
  96. package/dist/ui/assets/{tex-CKY1bwHb.js → tex-ln89VPaX.js} +1 -1
  97. package/dist/ui/assets/{ts-tags-BiYP5N_C.js → ts-tags-CWMzCstH.js} +1 -1
  98. package/dist/ui/assets/tsx-DgzfiGeK.js +1 -0
  99. package/dist/ui/assets/{twig-BTe1oPFW.js → twig-Bi2itZL9.js} +1 -1
  100. package/dist/ui/assets/typescript-CQ24f4zW.js +1 -0
  101. package/dist/ui/assets/{vue-CT0IiySh.js → vue-BS8jE5u6.js} +1 -1
  102. package/dist/ui/assets/{vue-html-B-tXcbat.js → vue-html-o38xqhyB.js} +1 -1
  103. package/dist/ui/assets/{vue-vine-C5ZyDCrs.js → vue-vine-D2NvKv50.js} +1 -1
  104. package/dist/ui/assets/xml-_44rx9_2.js +1 -0
  105. package/dist/ui/assets/{xsl-DvDSndNI.js → xsl-B5QWQPoi.js} +1 -1
  106. package/dist/ui/assets/yaml-CKL2pEWe.js +1 -0
  107. package/dist/workspaces/relay/auth/remote-auth.js +3 -5
  108. package/dist/workspaces/relay/tests/test-support.js +7 -4
  109. package/dist/workspaces/relay/workspace-relay.js +14 -8
  110. package/docs/configuration.md +154 -4
  111. package/docs/debugging.md +8 -5
  112. package/docs/roadmap.md +64 -3
  113. package/docs/security.md +18 -0
  114. package/package.json +11 -4
  115. package/scripts/debug/accept/bootstrap.mjs +2 -0
  116. package/scripts/debug/accept/harness.mjs +9 -2
  117. package/scripts/debug/accept/modern-http.mjs +169 -0
  118. package/scripts/debug/accept/support.mjs +10 -6
  119. package/scripts/debug/accept.mjs +1 -0
  120. package/scripts/debug/runtime.mjs +4 -1
  121. package/scripts/debug/runtime.test.mjs +9 -0
  122. package/scripts/release/parity-sandbox.mjs +35 -0
  123. package/scripts/release/release-gate.test.mjs +42 -1
  124. package/scripts/release-parity.mjs +3 -22
  125. package/scripts/release-proof.mjs +24 -4
  126. package/scripts/release-proof.test.mjs +12 -4
  127. package/dist/ui/assets/activity-panel-app-XlqSanXT.js +0 -115
  128. package/dist/ui/assets/angular-html-tB9EchbM.js +0 -1
  129. package/dist/ui/assets/c-Dv-N5hjn.js +0 -1
  130. package/dist/ui/assets/cpp-C3unXX-3.js +0 -1
  131. package/dist/ui/assets/css-8Xr4kNNH.js +0 -1
  132. package/dist/ui/assets/glsl-BvCQg9MI.js +0 -1
  133. package/dist/ui/assets/graphql-C9S90gn_.js +0 -1
  134. package/dist/ui/assets/haml-Bb1w9rMG.js +0 -1
  135. package/dist/ui/assets/heavy-payload-CJKqi8Ac.js +0 -290
  136. package/dist/ui/assets/html-dUOs5pA9.js +0 -1
  137. package/dist/ui/assets/java-BFZCrORY.js +0 -1
  138. package/dist/ui/assets/javascript-DaIQ0Zqj.js +0 -1
  139. package/dist/ui/assets/json-BQC_Yp48.js +0 -1
  140. package/dist/ui/assets/jsx-Dd-CJRf9.js +0 -1
  141. package/dist/ui/assets/lua-BoZTiL2I.js +0 -1
  142. package/dist/ui/assets/r-B5nYsy-M.js +0 -1
  143. package/dist/ui/assets/regexp-oP0lbks_.js +0 -1
  144. package/dist/ui/assets/scss-CEqgKQNf.js +0 -1
  145. package/dist/ui/assets/shellscript-DzJJiWQC.js +0 -1
  146. package/dist/ui/assets/sql-DC2rdTzV.js +0 -1
  147. package/dist/ui/assets/tsx-D2OMiPNF.js +0 -1
  148. package/dist/ui/assets/typescript-BGIABksB.js +0 -1
  149. package/dist/ui/assets/xml-CfeBNlMP.js +0 -1
  150. package/dist/ui/assets/yaml-umbj2r1g.js +0 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.1.1] - 2026-09-10
8
+
9
+ ### Added
10
+
11
+ - Added standalone global and Project External MCP registries in `mcp.json` with hot reload, Project-over-global precedence, explicit `disabled` masking, whole-source last-known-good behavior, and permanent discovery of the existing `mcp.external` Capability even when the registry starts empty.
12
+ - Added human-operated External MCP OAuth through `forgerelay mcp auth/logout`, including machine-private Project-scoped credentials, desktop loopback and masked headless callback flows, silent refresh with cross-process rotation protection, scope step-up, DCR compatibility, and optional CIMD client metadata for authorization servers that require it.
13
+ - Added `forgerelay mcp list/test` and passive External MCP `doctor` diagnostics for resolved source, authentication state, configuration health, negotiated MCP protocol, tool discovery, and actionable failure reporting.
14
+
15
+ ### Changed
16
+
17
+ - Migrated ForgeRelay's MCP runtime to the split MCP SDK v2 and Apps SDK v2, supporting MCP 2026-07-28 modern stateless HTTP alongside the existing legacy sessionful Host path while preserving legacy External MCP and Workspace Relay interoperability.
18
+ - Modern stateless Host Turns now use stable Host conversation metadata when supplied and otherwise remain request-scoped instead of inferring conversation identity from transport or process lifetime. Legacy compatibility retains its bounded transport/session fallback behavior.
19
+ - `config.json.mcpServers` remains a deprecated compatibility source; new External MCP configuration uses `mcp.json`, and relayed External MCP configuration, credentials, refresh, process launch, and upstream calls remain owned by the Execution ForgeRelay.
20
+
21
+ ### Security
22
+
23
+ - External MCP OAuth credentials stay in private `mcp-auth.json` state rather than Project files, are bound to their Project/global identity plus resource/issuer state, and are never exposed through normal diagnostics. Interactive authorization remains human-only; runtime refresh cannot open a browser, and headless callback input is masked.
24
+ - Project-defined stdio MCP servers are explicitly documented as executable project configuration, while static header/env secrets remain user-managed and External MCP paths, URLs, and resource references continue to be pass-through unless the Agent or an explicit Transform Hook acts on them.
25
+
7
26
  ## [1.1.0] - 2026-09-09
8
27
 
9
28
  ### Added
package/README.md CHANGED
@@ -108,7 +108,7 @@ ForgeRelay 不会默认为每个任务创建 worktree。只有你明确要求隔
108
108
  - 项目里的 `AGENTS.md`、`CLAUDE.md` 和 Agent Skills 按需加载,不会每次都把整套说明重新塞进上下文。
109
109
  - 需要并行开发时可以创建真实 Git worktree;集成回主分支时只接受安全的 fast-forward,不自动制造 merge conflict。
110
110
  - Workspace Relay 可以把执行放到另一台 ForgeRelay;Composite Workspace 可以同时协调几个独立环境。
111
- - `read` 可以直接把 PNG、JPEG、WebP 和 GIF 作为临时 MCP Media content 返回;配置好的外部 MCP 也通过 `mcp.external` Capability 转发图片,而不会自动打开它返回的路径或 URL。
111
+ - `read` 可以直接把 PNG、JPEG、WebP 和 GIF 作为临时 MCP Media content 返回;外部 MCP 通过独立的 global/Project `mcp.json` 热加载到 `mcp.external` Capability,并支持人工 OAuth 认证,而不会自动打开它返回的路径或 URL。
112
112
 
113
113
  Lifecycle Hooks、Workspace Tasks、本地 Subagent、Activity/Audit、Checkpoint 和 Recovery 也已经包含在项目里,但第一次安装时不需要先学这些。需要哪个,再去 [Wiki](https://github.com/Akira-TL/forgerelay/wiki) 查哪个。
114
114
 
@@ -128,6 +128,7 @@ ForgeRelay 默认拒绝 elevated / administrator 启动。只有你显式选择
128
128
 
129
129
  - [快速开始](https://github.com/Akira-TL/forgerelay/wiki/Getting-Started)
130
130
  - [配置](https://github.com/Akira-TL/forgerelay/wiki/Configuration)
131
+ - [External MCP](https://github.com/Akira-TL/forgerelay/wiki/External-MCP)
131
132
  - [安全模型](https://github.com/Akira-TL/forgerelay/wiki/Security)
132
133
  - [故障排查](https://github.com/Akira-TL/forgerelay/wiki/Troubleshooting)
133
134
  - [完整 Wiki](https://github.com/Akira-TL/forgerelay/wiki)
@@ -221,7 +222,7 @@ Long commands do not require tight polling either. Once the current wait window
221
222
  - `AGENTS.md`, `CLAUDE.md`, and Agent Skills are loaded as needed instead of being resent in full on every open.
222
223
  - Managed worktrees provide real Git isolation when you ask for parallel work, with fast-forward-only finalization.
223
224
  - Workspace Relay runs work on another ForgeRelay instance; Composite Workspaces coordinate several independent environments from one Host.
224
- - `read` can return PNG, JPEG, WebP, and GIF directly as transient MCP Media content; configured external MCP servers can forward images through the `mcp.external` Capability without ForgeRelay automatically opening returned paths or URLs.
225
+ - `read` can return PNG, JPEG, WebP, and GIF directly as transient MCP Media content; external MCP servers hot-reload from standalone global/Project `mcp.json` files into the `mcp.external` Capability, with explicit human OAuth when needed and no automatic dereferencing of returned paths or URLs.
225
226
 
226
227
  Lifecycle Hooks, Workspace Tasks, local Subagents, Activity/Audit, checkpoints, and recovery are included too. They are optional parts of the workflow; the [Wiki](https://github.com/Akira-TL/forgerelay/wiki) documents them when you need them.
227
228
 
@@ -241,6 +242,7 @@ See the [Security model](https://github.com/Akira-TL/forgerelay/wiki/Security) f
241
242
 
242
243
  - [Getting Started](https://github.com/Akira-TL/forgerelay/wiki/Getting-Started)
243
244
  - [Configuration](https://github.com/Akira-TL/forgerelay/wiki/Configuration)
245
+ - [External MCP](https://github.com/Akira-TL/forgerelay/wiki/External-MCP)
244
246
  - [Security model](https://github.com/Akira-TL/forgerelay/wiki/Security)
245
247
  - [Troubleshooting](https://github.com/Akira-TL/forgerelay/wiki/Troubleshooting)
246
248
  - [Full Wiki](https://github.com/Akira-TL/forgerelay/wiki)
@@ -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 "../../mcp/request-context.js";
3
4
  import { logEvent, transportSessionIdPrefix } from "../../runtime/logging/logger.js";
4
5
  import { hostConversationScopeId } from "../../mcp/request-meta.js";
5
6
  import { ACTIVITY_PANEL_DEFAULT_EXPANDED_META_KEY, ACTIVITY_PANEL_WORKSPACE_META_KEY, } from "../ui/contract.js";
@@ -68,11 +69,12 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
68
69
  idempotentHint: false,
69
70
  },
70
71
  }, async ({ workspaceId }, extra) => {
72
+ const requestContext = mcpHandlerRequestContext(extra);
71
73
  const workspace = workspacePanelState?.(workspaceId);
72
74
  if (!workspace) {
73
75
  throw new Error(`No Workspace presentation is available for ${workspaceId}. Call open_workspace for that workspace before activity_panel.`);
74
76
  }
75
- const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
77
+ const conversationScopeId = hostConversationScopeId(requestContext, connectionScopeId);
76
78
  const relayed = await relay?.panel(workspaceId, conversationScopeId);
77
79
  if (relayed) {
78
80
  return {
@@ -91,7 +93,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
91
93
  revision: snapshot.revision,
92
94
  state: snapshot.state,
93
95
  workspaceId,
94
- transportSessionIdPrefix: transportSessionIdPrefix(extra.sessionId),
96
+ transportSessionIdPrefix: transportSessionIdPrefix(requestContext.transportSessionId),
95
97
  });
96
98
  }
97
99
  return {
@@ -118,7 +120,8 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
118
120
  _meta: { ui: { visibility: ["app"] } },
119
121
  annotations: READ_ONLY_ANNOTATIONS,
120
122
  }, async ({ turnId, workspaceId, knownRevision }, extra) => {
121
- const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
123
+ const requestContext = mcpHandlerRequestContext(extra);
124
+ const conversationScopeId = hostConversationScopeId(requestContext, connectionScopeId);
122
125
  const relayed = await relay?.snapshot({ turnId, workspaceId, knownRevision }, conversationScopeId);
123
126
  if (relayed) {
124
127
  const workspace = workspaceId ? workspacePanelState?.(workspaceId) : undefined;
@@ -149,7 +152,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
149
152
  revision: snapshot.revision,
150
153
  changed: snapshot.changed,
151
154
  state: snapshot.state,
152
- transportSessionIdPrefix: transportSessionIdPrefix(extra.sessionId),
155
+ transportSessionIdPrefix: transportSessionIdPrefix(requestContext.transportSessionId),
153
156
  });
154
157
  }
155
158
  const workspace = workspaceId ? workspacePanelState?.(workspaceId) : undefined;
@@ -176,7 +179,8 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
176
179
  _meta: { ui: { visibility: ["app"] } },
177
180
  annotations: READ_ONLY_ANNOTATIONS,
178
181
  }, async ({ turnId, knownRevision }, extra) => {
179
- const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
182
+ const requestContext = mcpHandlerRequestContext(extra);
183
+ const conversationScopeId = hostConversationScopeId(requestContext, connectionScopeId);
180
184
  const relayed = await relay?.index(turnId, knownRevision, conversationScopeId);
181
185
  if (relayed)
182
186
  return relayed;
@@ -189,7 +193,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
189
193
  changed: index.changed,
190
194
  state: index.state,
191
195
  activities: index.activities.length,
192
- transportSessionIdPrefix: transportSessionIdPrefix(extra.sessionId),
196
+ transportSessionIdPrefix: transportSessionIdPrefix(requestContext.transportSessionId),
193
197
  });
194
198
  }
195
199
  return {
@@ -213,7 +217,8 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
213
217
  _meta: { ui: { visibility: ["app"] } },
214
218
  annotations: READ_ONLY_ANNOTATIONS,
215
219
  }, async ({ turnId, activityId }, extra) => {
216
- const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
220
+ const requestContext = mcpHandlerRequestContext(extra);
221
+ const conversationScopeId = hostConversationScopeId(requestContext, connectionScopeId);
217
222
  const relayed = await relay?.detail(turnId, activityId, conversationScopeId);
218
223
  if (relayed)
219
224
  return relayed;
@@ -225,7 +230,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
225
230
  tool: detail.activity.tool,
226
231
  kind: detail.activity.kind,
227
232
  state: detail.activity.state,
228
- transportSessionIdPrefix: transportSessionIdPrefix(extra.sessionId),
233
+ transportSessionIdPrefix: transportSessionIdPrefix(requestContext.transportSessionId),
229
234
  });
230
235
  }
231
236
  return {
@@ -258,7 +263,8 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
258
263
  _meta: { ui: { visibility: ["app"] } },
259
264
  annotations: READ_ONLY_ANNOTATIONS,
260
265
  }, async ({ turnId, outputId, cursor }, extra) => {
261
- const conversationScopeId = hostConversationScopeId(extra._meta, extra.sessionId, connectionScopeId);
266
+ const requestContext = mcpHandlerRequestContext(extra);
267
+ const conversationScopeId = hostConversationScopeId(requestContext, connectionScopeId);
262
268
  const relayed = await relay?.output(turnId, outputId, conversationScopeId, cursor);
263
269
  if (relayed)
264
270
  return relayed;
@@ -273,7 +279,7 @@ export function registerActivityQueryTools(server, queries, connectionScopeId, p
273
279
  processId: output.processId,
274
280
  status: output.status,
275
281
  outputBytes: Buffer.byteLength(output.output),
276
- transportSessionIdPrefix: transportSessionIdPrefix(extra.sessionId),
282
+ transportSessionIdPrefix: transportSessionIdPrefix(requestContext.transportSessionId),
277
283
  });
278
284
  }
279
285
  return {
@@ -0,0 +1,481 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+ import * as prompts from "@clack/prompts";
5
+ import { computeScopeUnion, selectClientAuthMethod, } from "@modelcontextprotocol/client";
6
+ import { externalMcpCredentialIdentity, } from "../../runtime/config/external-mcp-auth-store.js";
7
+ import { ExternalMcpInteractiveOAuthProvider, beginExternalMcpInteractiveOAuth, finishExternalMcpInteractiveOAuth, } from "../../mcp/operations/external-mcp/external-mcp-oauth.js";
8
+ import { ExternalMcpError, ExternalMcpGateway, } from "../../mcp/operations/external-mcp/external-mcp.js";
9
+ import { findExternalMcpServerStatus, formatAuth, formatExternalMcpList, inspectExternalMcpStatus, resolveExternalMcpScope, } from "./status.js";
10
+ const CALLBACK_TIMEOUT_MS = 5 * 60 * 1_000;
11
+ export async function runExternalMcpCommand(args, dependencies = {}) {
12
+ const [subcommand, ...rest] = args;
13
+ switch (subcommand) {
14
+ case "list":
15
+ case "ls":
16
+ await runExternalMcpList(parseMcpScopeArgs("list", rest), dependencies);
17
+ return;
18
+ case "test":
19
+ await runExternalMcpTest(parseMcpTargetArgs("test", rest), dependencies);
20
+ return;
21
+ case "auth":
22
+ await runExternalMcpAuth(parseMcpTargetArgs("auth", rest), dependencies);
23
+ return;
24
+ case "logout":
25
+ await runExternalMcpLogout(parseMcpTargetArgs("logout", rest), dependencies);
26
+ return;
27
+ case undefined:
28
+ case "help":
29
+ case "--help":
30
+ case "-h":
31
+ printMcpHelp();
32
+ return;
33
+ default:
34
+ throw new Error(`Unknown mcp command: ${subcommand}`);
35
+ }
36
+ }
37
+ function parseMcpScopeArgs(command, args) {
38
+ let projectRoot;
39
+ let global = false;
40
+ const rest = [];
41
+ for (let index = 0; index < args.length; index += 1) {
42
+ const arg = args[index];
43
+ if (arg === "--project") {
44
+ projectRoot = args[++index];
45
+ if (!projectRoot)
46
+ throw new Error("Missing value for --project.");
47
+ continue;
48
+ }
49
+ if (arg === "--global") {
50
+ global = true;
51
+ continue;
52
+ }
53
+ if (arg.startsWith("-"))
54
+ throw new Error(`Unknown mcp ${command} option: ${arg}`);
55
+ rest.push(arg);
56
+ }
57
+ if (global && projectRoot)
58
+ throw new Error("--global and --project cannot be used together.");
59
+ return {
60
+ ...(projectRoot ? { projectRoot } : {}),
61
+ ...(global ? { global: true } : {}),
62
+ rest,
63
+ };
64
+ }
65
+ function parseMcpTargetArgs(command, args) {
66
+ const parsed = parseMcpScopeArgs(command, args);
67
+ if (parsed.rest.length !== 1) {
68
+ throw new Error(`Usage: forgerelay mcp ${command} <server> [--project <path>|--global]`);
69
+ }
70
+ return {
71
+ server: parsed.rest[0],
72
+ ...(parsed.projectRoot ? { projectRoot: parsed.projectRoot } : {}),
73
+ ...(parsed.global ? { global: true } : {}),
74
+ };
75
+ }
76
+ async function runExternalMcpList(options, dependencies) {
77
+ if (options.rest.length > 0) {
78
+ throw new Error("Usage: forgerelay mcp list [--project <path>|--global]");
79
+ }
80
+ const scope = resolveExternalMcpScope(options, dependencies);
81
+ const status = inspectExternalMcpStatus(scope);
82
+ console.log(formatExternalMcpList(status));
83
+ if (status.configIssues > 0) {
84
+ throw new Error("External MCP configuration or credential status contains issues.");
85
+ }
86
+ }
87
+ async function runExternalMcpTest(options, dependencies) {
88
+ const scope = resolveExternalMcpScope(options, dependencies);
89
+ const initialStatus = inspectExternalMcpStatus(scope);
90
+ const serverStatus = findExternalMcpServerStatus(initialStatus, options.server);
91
+ if (!serverStatus)
92
+ throw new Error(`Unknown configured External MCP server: ${options.server}.`);
93
+ if (!serverStatus.enabled) {
94
+ throw new Error(`External MCP ${options.server} is disabled by ${serverStatus.source} configuration.`);
95
+ }
96
+ const config = scope.snapshot.servers[options.server];
97
+ if (!config)
98
+ throw new Error(`Unknown configured External MCP server: ${options.server}.`);
99
+ printTestTarget(scope, serverStatus);
100
+ const gateway = new ExternalMcpGateway(scope.config.mediaMaxBytes, scope.store);
101
+ try {
102
+ const probe = await gateway.probe(scope.snapshot.servers, options.server, undefined, { workspaceRoot: scope.projectRoot, origins: scope.snapshot.origins });
103
+ console.log("Connection: ok");
104
+ console.log(`Protocol: ${probe.protocolEra}` +
105
+ (probe.protocolVersion ? ` · ${probe.protocolVersion}` : ""));
106
+ console.log(`Tools: ${probe.toolCount}${probe.truncated ? "+ · truncated" : ""}`);
107
+ console.log(`${options.server} is ready.`);
108
+ }
109
+ catch (error) {
110
+ if (!(error instanceof ExternalMcpError))
111
+ throw error;
112
+ const latestStatus = inspectExternalMcpStatus(scope);
113
+ const latestServer = findExternalMcpServerStatus(latestStatus, options.server) ?? serverStatus;
114
+ if (error.code === "auth_required" || error.code === "reauthorization_required") {
115
+ console.log(`Auth: ${formatAuth(latestServer)}`);
116
+ console.log("Connection: blocked by authentication");
117
+ console.log(`Reason: ${error.message}`);
118
+ console.log(`Next: ${externalMcpAuthCommand(scope, options.server)}`);
119
+ }
120
+ else if (error.code === "tool_discovery_failed") {
121
+ console.log("Connection: ok");
122
+ console.log("Tools: failed");
123
+ console.log(`Reason: ${error.detail ?? "MCP tools/list failed."}`);
124
+ console.log("Next: check the MCP server logs and retry this test.");
125
+ }
126
+ else {
127
+ console.log("Connection: failed");
128
+ console.log(`Reason: ${error.detail ?? "MCP connection or protocol handshake failed."}`);
129
+ console.log("Next: check the server configuration, process/network reachability, and retry this test.");
130
+ }
131
+ throw new Error(`External MCP ${options.server} test failed.`);
132
+ }
133
+ }
134
+ function printTestTarget(scope, server) {
135
+ console.log(`Testing External MCP ${server.name}`);
136
+ if (scope.mode === "project")
137
+ console.log(`Project: ${scope.projectRoot}`);
138
+ console.log(`Source: ${server.source}`);
139
+ console.log(`Transport: ${server.transport}`);
140
+ console.log(`Auth: ${formatAuth(server)}`);
141
+ }
142
+ function externalMcpAuthCommand(scope, server) {
143
+ return scope.mode === "global"
144
+ ? `forgerelay mcp auth ${cliArgument(server)} --global`
145
+ : `forgerelay mcp auth ${cliArgument(server)} --project ${cliArgument(scope.projectRoot)}`;
146
+ }
147
+ function cliArgument(value) {
148
+ return /^[A-Za-z0-9_./:\\-]+$/.test(value) ? value : JSON.stringify(value);
149
+ }
150
+ function formatCredentialScope(source, projectRoot) {
151
+ return source === "project" ? `project · ${projectRoot}` : "global";
152
+ }
153
+ async function runExternalMcpAuth(options, dependencies) {
154
+ assertInteractive(dependencies);
155
+ const target = resolveExternalMcpCliTarget(options, dependencies);
156
+ if (hasStaticAuthorizationHeader(target.serverConfig.headers)) {
157
+ throw new Error(`External MCP ${target.server} uses a static Authorization header; remove that header before using OAuth authentication.`);
158
+ }
159
+ console.log(`Authenticating External MCP ${target.server}`);
160
+ console.log(`Source: ${target.source}`);
161
+ console.log(`Transport: ${target.serverConfig.transport}`);
162
+ console.log(`Credential scope: ${formatCredentialScope(target.source, target.projectRoot)}`);
163
+ const identity = externalMcpCredentialIdentity(target.source, target.server, target.projectRoot);
164
+ const existing = target.store.read(identity);
165
+ const receiver = await (dependencies.createLoopbackReceiver ?? createLoopbackReceiver)(target.serverConfig.oauth?.callbackPort);
166
+ let authorizationUrl;
167
+ let authorizationServerPrinted = false;
168
+ let needsPaste = dependencies.headless ?? isHeadlessEnvironment(dependencies.env ?? process.env);
169
+ try {
170
+ const provider = new ExternalMcpInteractiveOAuthProvider({
171
+ serverUrl: target.serverConfig.url,
172
+ redirectUrl: receiver.redirectUrl,
173
+ ...(target.serverConfig.oauth?.clientMetadataUrl
174
+ ? { clientMetadataUrl: target.serverConfig.oauth.clientMetadataUrl }
175
+ : {}),
176
+ existing,
177
+ onAuthorizationUrl: async (url) => {
178
+ authorizationUrl = new URL(url);
179
+ await dependencies.observeAuthorizationUrl?.(url);
180
+ const authorizationServer = provider.authorizationServerUrl();
181
+ if (authorizationServer && !authorizationServerPrinted) {
182
+ authorizationServerPrinted = true;
183
+ console.log(`Authorization server: ${authorizationServer}`);
184
+ }
185
+ console.log(`Authorization URL: ${url.toString()}`);
186
+ if (needsPaste)
187
+ return;
188
+ const opened = await (dependencies.openBrowser ?? openBrowser)(url);
189
+ if (opened) {
190
+ console.log("Opened the authorization URL in your browser.");
191
+ }
192
+ else {
193
+ needsPaste = true;
194
+ console.log("Could not open a browser automatically; open the authorization URL manually.");
195
+ }
196
+ },
197
+ });
198
+ await beginExternalMcpInteractiveOAuth(provider, target.serverConfig.url, computeScopeUnion(existing?.tokens?.scope, existing?.reauthorization?.scope));
199
+ if (!authorizationUrl)
200
+ throw new Error("External MCP OAuth did not provide an authorization URL.");
201
+ let callbackUrl;
202
+ if (needsPaste) {
203
+ console.log("After authorization, paste the final callback URL below.");
204
+ callbackUrl = parseCallbackUrl(await promptMaskedCallback(dependencies));
205
+ }
206
+ else {
207
+ try {
208
+ console.log("Waiting for the browser callback...");
209
+ callbackUrl = await receiver.waitForCallback(CALLBACK_TIMEOUT_MS);
210
+ }
211
+ catch {
212
+ console.log("Browser callback was not received. Paste the final callback URL below.");
213
+ callbackUrl = parseCallbackUrl(await promptMaskedCallback(dependencies));
214
+ }
215
+ }
216
+ const staged = await finishExternalMcpInteractiveOAuth(provider, target.serverConfig.url, callbackUrl);
217
+ await target.store.withIdentityLock(identity, () => target.store.replace(identity, target.serverConfig.url, staged));
218
+ console.log(`Granted scopes: ${staged.tokens?.scope?.trim() || "not reported"}`);
219
+ console.log(`Authenticated External MCP ${target.server} (${target.source}).`);
220
+ console.log(`Credential store: ${target.store.filePath}`);
221
+ }
222
+ finally {
223
+ await receiver.close().catch(() => undefined);
224
+ }
225
+ }
226
+ async function runExternalMcpLogout(options, dependencies) {
227
+ const target = resolveExternalMcpCliTarget(options, dependencies);
228
+ console.log(`Logging out External MCP ${target.server}`);
229
+ console.log(`Source: ${target.source}`);
230
+ console.log(`Credential scope: ${formatCredentialScope(target.source, target.projectRoot)}`);
231
+ const identity = externalMcpCredentialIdentity(target.source, target.server, target.projectRoot);
232
+ let existing;
233
+ await target.store.withIdentityLock(identity, async () => {
234
+ existing = target.store.read(identity);
235
+ if (existing)
236
+ await target.store.remove(identity);
237
+ });
238
+ if (!existing) {
239
+ console.log(`No stored OAuth credential for External MCP ${target.server} (${target.source}).`);
240
+ return;
241
+ }
242
+ let revocation = "unsupported";
243
+ try {
244
+ revocation = await revokeCredential(existing);
245
+ }
246
+ catch {
247
+ revocation = "failed";
248
+ }
249
+ console.log(`Removed local OAuth credential for External MCP ${target.server} (${target.source}).`);
250
+ if (revocation === "revoked")
251
+ console.log("Remote revocation: succeeded.");
252
+ else if (revocation === "unsupported")
253
+ console.log("Remote revocation: not advertised by the authorization server.");
254
+ else
255
+ console.log("Remote revocation: failed; the local credential was still removed.");
256
+ }
257
+ function resolveExternalMcpCliTarget(options, dependencies) {
258
+ const scope = resolveExternalMcpScope(options, dependencies);
259
+ const serverConfig = scope.snapshot.servers[options.server];
260
+ const source = scope.snapshot.origins[options.server];
261
+ if (!serverConfig || !source) {
262
+ const maskedBy = scope.snapshot.masked[options.server];
263
+ if (maskedBy) {
264
+ throw new Error(`External MCP ${options.server} is disabled by ${maskedBy} configuration.`);
265
+ }
266
+ throw new Error(`Unknown configured External MCP server: ${options.server}.`);
267
+ }
268
+ if (serverConfig.transport !== "streamable-http") {
269
+ throw new Error(`External MCP ${options.server} uses stdio; interactive OAuth is only available for streamable-http servers.`);
270
+ }
271
+ return {
272
+ store: scope.store,
273
+ projectRoot: scope.projectRoot,
274
+ server: options.server,
275
+ source,
276
+ serverConfig,
277
+ };
278
+ }
279
+ async function revokeCredential(record) {
280
+ const metadata = record.discoveryState?.authorizationServerMetadata;
281
+ if (!metadata || !("revocation_endpoint" in metadata) || typeof metadata.revocation_endpoint !== "string") {
282
+ return "unsupported";
283
+ }
284
+ const endpoint = metadata.revocation_endpoint;
285
+ const token = record.tokens?.refresh_token ?? record.tokens?.access_token;
286
+ if (!token || !record.clientInformation)
287
+ return "unsupported";
288
+ const url = new URL(endpoint);
289
+ assertSecureOAuthEndpoint(url);
290
+ const params = new URLSearchParams({
291
+ token,
292
+ token_type_hint: record.tokens?.refresh_token ? "refresh_token" : "access_token",
293
+ });
294
+ const headers = new Headers({
295
+ "content-type": "application/x-www-form-urlencoded",
296
+ accept: "application/json",
297
+ });
298
+ applyClientAuthentication(record.clientInformation, metadata, headers, params);
299
+ const response = await fetch(url, {
300
+ method: "POST",
301
+ headers,
302
+ body: params,
303
+ signal: AbortSignal.timeout(15_000),
304
+ });
305
+ await response.text().catch(() => "");
306
+ if (!response.ok)
307
+ throw new Error(`OAuth revocation failed with HTTP ${response.status}.`);
308
+ return "revoked";
309
+ }
310
+ function applyClientAuthentication(client, metadata, headers, params) {
311
+ const supported = metadata
312
+ && "revocation_endpoint_auth_methods_supported" in metadata
313
+ && Array.isArray(metadata.revocation_endpoint_auth_methods_supported)
314
+ ? metadata.revocation_endpoint_auth_methods_supported
315
+ : metadata?.token_endpoint_auth_methods_supported ?? [];
316
+ const method = selectClientAuthMethod(client, supported);
317
+ if (method === "client_secret_basic") {
318
+ if (!client.client_secret)
319
+ throw new Error("OAuth revocation requires a client secret.");
320
+ headers.set("authorization", `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`, "utf8").toString("base64")}`);
321
+ return;
322
+ }
323
+ params.set("client_id", client.client_id);
324
+ if (method === "client_secret_post" && client.client_secret)
325
+ params.set("client_secret", client.client_secret);
326
+ }
327
+ function assertSecureOAuthEndpoint(url) {
328
+ const loopback = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
329
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
330
+ throw new Error("Refusing OAuth revocation over an insecure non-loopback endpoint.");
331
+ }
332
+ }
333
+ function hasStaticAuthorizationHeader(headers) {
334
+ return Object.keys(headers ?? {}).some((name) => name.toLowerCase() === "authorization");
335
+ }
336
+ function assertInteractive(dependencies) {
337
+ const interactive = dependencies.isInteractive ?? (Boolean(input.isTTY) && Boolean(output.isTTY));
338
+ if (!interactive) {
339
+ throw new Error("forgerelay mcp auth requires an interactive terminal for browser/callback authorization.");
340
+ }
341
+ }
342
+ async function promptMaskedCallback(dependencies) {
343
+ if (dependencies.promptCallbackUrl)
344
+ return dependencies.promptCallbackUrl();
345
+ const result = await prompts.password({
346
+ message: "Final OAuth callback URL",
347
+ mask: "*",
348
+ validate: (value) => value?.trim() ? undefined : "Paste the final callback URL.",
349
+ });
350
+ if (prompts.isCancel(result))
351
+ throw new Error("External MCP authentication cancelled.");
352
+ return String(result);
353
+ }
354
+ function parseCallbackUrl(value) {
355
+ try {
356
+ return new URL(value.trim());
357
+ }
358
+ catch {
359
+ throw new Error("The pasted OAuth callback URL is not a valid URL.");
360
+ }
361
+ }
362
+ async function createLoopbackReceiver(port = 0) {
363
+ let resolveCallback;
364
+ let rejectCallback;
365
+ let settled = false;
366
+ let timer;
367
+ const callbackPromise = new Promise((resolvePromise, rejectPromise) => {
368
+ resolveCallback = resolvePromise;
369
+ rejectCallback = rejectPromise;
370
+ });
371
+ const server = createServer((request, response) => {
372
+ if (!request.url || request.method !== "GET") {
373
+ response.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
374
+ response.end("Method not allowed.\n");
375
+ return;
376
+ }
377
+ const address = server.address();
378
+ const url = new URL(request.url, `http://127.0.0.1:${address?.port ?? 0}`);
379
+ if (url.pathname !== "/callback") {
380
+ response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
381
+ response.end("Not found.\n");
382
+ return;
383
+ }
384
+ response.writeHead(200, {
385
+ "content-type": "text/plain; charset=utf-8",
386
+ "cache-control": "no-store",
387
+ });
388
+ response.end("ForgeRelay received the authorization response. You can return to the terminal.\n");
389
+ if (!settled) {
390
+ settled = true;
391
+ if (timer)
392
+ clearTimeout(timer);
393
+ resolveCallback(url);
394
+ }
395
+ });
396
+ server.on("error", (error) => {
397
+ if (!settled) {
398
+ settled = true;
399
+ if (timer)
400
+ clearTimeout(timer);
401
+ rejectCallback(error);
402
+ }
403
+ });
404
+ await new Promise((resolveListen, rejectListen) => {
405
+ server.once("error", rejectListen);
406
+ server.listen(port, "127.0.0.1", () => {
407
+ server.off("error", rejectListen);
408
+ resolveListen();
409
+ });
410
+ });
411
+ const listeningPort = server.address().port;
412
+ return {
413
+ redirectUrl: new URL(`http://127.0.0.1:${listeningPort}/callback`),
414
+ waitForCallback(timeoutMs = CALLBACK_TIMEOUT_MS) {
415
+ if (!settled && !timer) {
416
+ timer = setTimeout(() => {
417
+ if (settled)
418
+ return;
419
+ settled = true;
420
+ rejectCallback(new Error("Timed out waiting for OAuth callback."));
421
+ }, timeoutMs);
422
+ timer.unref?.();
423
+ }
424
+ return callbackPromise;
425
+ },
426
+ async close() {
427
+ if (timer)
428
+ clearTimeout(timer);
429
+ if (!server.listening)
430
+ return;
431
+ await new Promise((resolveClose) => server.close(() => resolveClose()));
432
+ },
433
+ };
434
+ }
435
+ async function openBrowser(url) {
436
+ const command = browserCommand(url.toString());
437
+ if (!command)
438
+ return false;
439
+ return new Promise((resolveOpen) => {
440
+ const child = spawn(command.file, command.args, {
441
+ detached: true,
442
+ stdio: "ignore",
443
+ windowsHide: true,
444
+ });
445
+ let resolved = false;
446
+ child.once("spawn", () => {
447
+ resolved = true;
448
+ child.unref();
449
+ resolveOpen(true);
450
+ });
451
+ child.once("error", () => {
452
+ if (!resolved)
453
+ resolveOpen(false);
454
+ });
455
+ });
456
+ }
457
+ function browserCommand(url) {
458
+ if (process.platform === "darwin")
459
+ return { file: "open", args: [url] };
460
+ if (process.platform === "win32")
461
+ return { file: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
462
+ return { file: "xdg-open", args: [url] };
463
+ }
464
+ function isHeadlessEnvironment(env) {
465
+ if (env.SSH_CONNECTION || env.SSH_TTY)
466
+ return true;
467
+ if (process.platform !== "linux")
468
+ return false;
469
+ return !env.DISPLAY && !env.WAYLAND_DISPLAY;
470
+ }
471
+ function printMcpHelp() {
472
+ console.log([
473
+ "ForgeRelay mcp",
474
+ "",
475
+ "Usage:",
476
+ " forgerelay mcp list [--project <path>|--global]",
477
+ " forgerelay mcp test <server> [--project <path>|--global]",
478
+ " forgerelay mcp auth <server> [--project <path>|--global]",
479
+ " forgerelay mcp logout <server> [--project <path>|--global]",
480
+ ].join("\n"));
481
+ }