@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.
- package/CHANGELOG.md +19 -0
- package/README.md +4 -2
- package/dist/activity/runtime/mcp-query-tools.js +16 -10
- package/dist/cli/mcp/external-mcp.js +481 -0
- package/dist/cli/mcp/status.js +295 -0
- package/dist/cli.js +14 -1
- package/dist/lsp/test-support/server-fixture.js +1 -2
- package/dist/mcp/oauth/auth-protocol.js +375 -0
- package/dist/mcp/oauth/oauth-provider.js +11 -12
- package/dist/mcp/oauth/oauth-store.js +2 -2
- package/dist/mcp/oauth/router.js +2 -6
- package/dist/mcp/operations/batch/executor.js +1 -1
- package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +387 -0
- package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +23 -6
- package/dist/mcp/operations/external-mcp/external-mcp.js +178 -23
- package/dist/mcp/operations/native-bulk-mutations.js +1 -1
- package/dist/mcp/process/tools.js +19 -15
- package/dist/mcp/request-context.js +18 -0
- package/dist/mcp/request-meta.js +28 -3
- package/dist/mcp/server/core/capabilities.js +1 -4
- package/dist/mcp/server/operations/runtime/filesystem-tools.js +35 -28
- package/dist/mcp/server/operations/runtime/operation-runtime.js +7 -7
- package/dist/mcp/server/transport/http-server.js +41 -20
- package/dist/mcp/server/workspace/runtime/workspace-open.js +13 -9
- package/dist/mcp/server/workspace/runtime/workspace-tools.js +29 -25
- package/dist/runtime/config/external-mcp-auth-store.js +225 -0
- package/dist/runtime/config/external-mcp-config.js +76 -22
- package/dist/runtime/config/external-mcp-registry.js +165 -0
- package/dist/runtime/testing/server-fixture.js +4 -4
- package/dist/server.js +15 -9
- package/dist/ui/.vite/manifest.json +297 -298
- package/dist/ui/activity-panel-app.html +1 -1
- package/dist/ui/assets/activity-panel-app-Bw7ZFX2g.js +114 -0
- package/dist/ui/assets/angular-html-DeUNP12X.js +1 -0
- package/dist/ui/assets/{angular-ts-BearSZDb.js → angular-ts-B3v8FKi_.js} +1 -1
- package/dist/ui/assets/{apl-CYQtr-y6.js → apl-Bs3iQm4P.js} +1 -1
- package/dist/ui/assets/{astro-DDzqKIjK.js → astro-D6GHecBY.js} +1 -1
- package/dist/ui/assets/{blade-DkwEyHvQ.js → blade-BMNbVOHv.js} +1 -1
- package/dist/ui/assets/c-BquuA6bv.js +1 -0
- package/dist/ui/assets/{cobol-xBn5JxHE.js → cobol-BuZ6kfps.js} +1 -1
- package/dist/ui/assets/{coffee-BaXXvoJw.js → coffee-DgC5uBzL.js} +1 -1
- package/dist/ui/assets/cpp-BdvWqCaB.js +1 -0
- package/dist/ui/assets/{crystal-DjOpXVU1.js → crystal-0Q5qBvNI.js} +1 -1
- package/dist/ui/assets/css-Be_T8idh.js +1 -0
- package/dist/ui/assets/{edge-DNaRizoR.js → edge-DaGLdgh_.js} +1 -1
- package/dist/ui/assets/{elixir-CwBMwZmy.js → elixir-CMgiFIzB.js} +1 -1
- package/dist/ui/assets/{elm-gOUD2CW3.js → elm-B-ufMlMC.js} +1 -1
- package/dist/ui/assets/{erb-BcR-fjUp.js → erb-CaGiOZM-.js} +1 -1
- package/dist/ui/assets/{git-rebase-BAQPKHF6.js → git-rebase-CpwUWCzK.js} +1 -1
- package/dist/ui/assets/{glimmer-js-P_wOfSz_.js → glimmer-js-BRxccvnE.js} +1 -1
- package/dist/ui/assets/{glimmer-ts-B44aY1fZ.js → glimmer-ts-CgQbPzlT.js} +1 -1
- package/dist/ui/assets/glsl-D7JmHFr0.js +1 -0
- package/dist/ui/assets/graphql-C8n0eUBK.js +1 -0
- package/dist/ui/assets/{hack-ddU2k0nt.js → hack-lVN7KVQv.js} +1 -1
- package/dist/ui/assets/haml-h18lG0pS.js +1 -0
- package/dist/ui/assets/{handlebars-BpzaUpmz.js → handlebars-lRFjRPfj.js} +1 -1
- package/dist/ui/assets/heavy-payload-Cqx9MOkH.js +290 -0
- package/dist/ui/assets/html-DtILqTE1.js +1 -0
- package/dist/ui/assets/{html-derivative-B2beVdmA.js → html-derivative-CSTSmPup.js} +1 -1
- package/dist/ui/assets/{http-iEskZy8U.js → http-DNI8fsbY.js} +1 -1
- package/dist/ui/assets/{hurl-BuUsLSKv.js → hurl-DgRi60JG.js} +1 -1
- package/dist/ui/assets/java-CW9pZ5bh.js +1 -0
- package/dist/ui/assets/javascript-Cvg2UrWL.js +1 -0
- package/dist/ui/assets/{jinja-CRpkWwrH.js → jinja-CZw_S1FB.js} +1 -1
- package/dist/ui/assets/{jison-BYfSnuHj.js → jison-C56u_FZv.js} +1 -1
- package/dist/ui/assets/json-DW2UTv1q.js +1 -0
- package/dist/ui/assets/jsx-Zj5gobdD.js +1 -0
- package/dist/ui/assets/{julia-B8Rxy7vD.js → julia-Cvcrre-L.js} +1 -1
- package/dist/ui/assets/{just-DzhIWpFP.js → just-8CZ2u6Tj.js} +1 -1
- package/dist/ui/assets/{latex-CUNjUUL-.js → latex-D289AXWG.js} +1 -1
- package/dist/ui/assets/{liquid-hUg7nC61.js → liquid-CFEX3SkZ.js} +1 -1
- package/dist/ui/assets/lua-BKpJ0ybO.js +1 -0
- package/dist/ui/assets/{marko-CQmIkwMF.js → marko-D9maZ0a6.js} +1 -1
- package/dist/ui/assets/{mdc-CB-6dHYa.js → mdc-rM9l0qWR.js} +1 -1
- package/dist/ui/assets/{nginx-CQORo_Bt.js → nginx-C9w6p2C9.js} +1 -1
- package/dist/ui/assets/{nim-BNwBNzdn.js → nim-BRePHk0J.js} +1 -1
- package/dist/ui/assets/{perl-Cnz14fAJ.js → perl-CskTmiPQ.js} +1 -1
- package/dist/ui/assets/{php-YywDo63b.js → php-DCU_FvNI.js} +1 -1
- package/dist/ui/assets/{pug-BwbJp4cM.js → pug-XLaVxTvv.js} +1 -1
- package/dist/ui/assets/{qml-D2DY6dMH.js → qml-CaONKSl-.js} +1 -1
- package/dist/ui/assets/r-BCVR0ZE3.js +1 -0
- package/dist/ui/assets/{razor-pMTG7k41.js → razor-DDnGXxnU.js} +1 -1
- package/dist/ui/assets/regexp-DWGTYYVx.js +1 -0
- package/dist/ui/assets/{rst-CO_ndDm4.js → rst-Dz3GWvQm.js} +1 -1
- package/dist/ui/assets/{ruby-xzRn9gTh.js → ruby-DC3jGtMY.js} +1 -1
- package/dist/ui/assets/{sas-CBdtFKLA.js → sas-7mNpvsjZ.js} +1 -1
- package/dist/ui/assets/scss-uBsoCJVy.js +1 -0
- package/dist/ui/assets/shellscript-BCN9N_Jf.js +1 -0
- package/dist/ui/assets/{shellsession-BaZnm5rZ.js → shellsession-Hsw46VXV.js} +1 -1
- package/dist/ui/assets/{soy-IouG3xdB.js → soy-D6KV69HU.js} +1 -1
- package/dist/ui/assets/sql-CVzVQkJh.js +1 -0
- package/dist/ui/assets/{stata-dgbkIZHP.js → stata-BwUjXNhI.js} +1 -1
- package/dist/ui/assets/{surrealql-CBROnw_p.js → surrealql-CgloWqRa.js} +1 -1
- package/dist/ui/assets/{svelte-6r6BpX4-.js → svelte-DcC3n6IC.js} +1 -1
- package/dist/ui/assets/{templ-BKD_PsbL.js → templ-DOFfnWTJ.js} +1 -1
- package/dist/ui/assets/{tex-CKY1bwHb.js → tex-ln89VPaX.js} +1 -1
- package/dist/ui/assets/{ts-tags-BiYP5N_C.js → ts-tags-CWMzCstH.js} +1 -1
- package/dist/ui/assets/tsx-DgzfiGeK.js +1 -0
- package/dist/ui/assets/{twig-BTe1oPFW.js → twig-Bi2itZL9.js} +1 -1
- package/dist/ui/assets/typescript-CQ24f4zW.js +1 -0
- package/dist/ui/assets/{vue-CT0IiySh.js → vue-BS8jE5u6.js} +1 -1
- package/dist/ui/assets/{vue-html-B-tXcbat.js → vue-html-o38xqhyB.js} +1 -1
- package/dist/ui/assets/{vue-vine-C5ZyDCrs.js → vue-vine-D2NvKv50.js} +1 -1
- package/dist/ui/assets/xml-_44rx9_2.js +1 -0
- package/dist/ui/assets/{xsl-DvDSndNI.js → xsl-B5QWQPoi.js} +1 -1
- package/dist/ui/assets/yaml-CKL2pEWe.js +1 -0
- package/dist/workspaces/relay/auth/remote-auth.js +3 -5
- package/dist/workspaces/relay/tests/test-support.js +7 -4
- package/dist/workspaces/relay/workspace-relay.js +14 -8
- package/docs/configuration.md +154 -4
- package/docs/debugging.md +8 -5
- package/docs/roadmap.md +64 -3
- package/docs/security.md +18 -0
- package/package.json +11 -4
- package/scripts/debug/accept/bootstrap.mjs +2 -0
- package/scripts/debug/accept/harness.mjs +9 -2
- package/scripts/debug/accept/modern-http.mjs +169 -0
- package/scripts/debug/accept/support.mjs +10 -6
- package/scripts/debug/accept.mjs +1 -0
- package/scripts/debug/runtime.mjs +4 -1
- package/scripts/debug/runtime.test.mjs +9 -0
- package/scripts/release/parity-sandbox.mjs +35 -0
- package/scripts/release/release-gate.test.mjs +42 -1
- package/scripts/release-parity.mjs +3 -22
- package/scripts/release-proof.mjs +24 -4
- package/scripts/release-proof.test.mjs +12 -4
- package/dist/ui/assets/activity-panel-app-XlqSanXT.js +0 -115
- package/dist/ui/assets/angular-html-tB9EchbM.js +0 -1
- package/dist/ui/assets/c-Dv-N5hjn.js +0 -1
- package/dist/ui/assets/cpp-C3unXX-3.js +0 -1
- package/dist/ui/assets/css-8Xr4kNNH.js +0 -1
- package/dist/ui/assets/glsl-BvCQg9MI.js +0 -1
- package/dist/ui/assets/graphql-C9S90gn_.js +0 -1
- package/dist/ui/assets/haml-Bb1w9rMG.js +0 -1
- package/dist/ui/assets/heavy-payload-CJKqi8Ac.js +0 -290
- package/dist/ui/assets/html-dUOs5pA9.js +0 -1
- package/dist/ui/assets/java-BFZCrORY.js +0 -1
- package/dist/ui/assets/javascript-DaIQ0Zqj.js +0 -1
- package/dist/ui/assets/json-BQC_Yp48.js +0 -1
- package/dist/ui/assets/jsx-Dd-CJRf9.js +0 -1
- package/dist/ui/assets/lua-BoZTiL2I.js +0 -1
- package/dist/ui/assets/r-B5nYsy-M.js +0 -1
- package/dist/ui/assets/regexp-oP0lbks_.js +0 -1
- package/dist/ui/assets/scss-CEqgKQNf.js +0 -1
- package/dist/ui/assets/shellscript-DzJJiWQC.js +0 -1
- package/dist/ui/assets/sql-DC2rdTzV.js +0 -1
- package/dist/ui/assets/tsx-D2OMiPNF.js +0 -1
- package/dist/ui/assets/typescript-BGIABksB.js +0 -1
- package/dist/ui/assets/xml-CfeBNlMP.js +0 -1
- 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
|
|
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;
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
+
}
|