@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
@@ -0,0 +1,295 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { loadConfig } from "../../runtime/config/config.js";
4
+ import { ExternalMcpCredentialStore, externalMcpCredentialIdentity, } from "../../runtime/config/external-mcp-auth-store.js";
5
+ import { ExternalMcpConfigRegistry, } from "../../runtime/config/external-mcp-registry.js";
6
+ export function resolveExternalMcpScope(request, dependencies = {}) {
7
+ if (request.global && request.projectRoot) {
8
+ throw new Error("--global and --project cannot be used together.");
9
+ }
10
+ const env = dependencies.env ?? process.env;
11
+ const config = loadConfig(env);
12
+ const cwd = resolve(dependencies.cwd ?? process.cwd());
13
+ const registry = new ExternalMcpConfigRegistry({
14
+ configDir: config.configDir,
15
+ legacyServers: config.mcpServers,
16
+ });
17
+ const store = new ExternalMcpCredentialStore({ configDir: config.configDir });
18
+ if (request.global) {
19
+ return {
20
+ mode: "global",
21
+ projectRoot: cwd,
22
+ projectSelection: "global",
23
+ config,
24
+ registry,
25
+ snapshot: registry.resolveGlobal(),
26
+ store,
27
+ };
28
+ }
29
+ const selected = selectProjectRoot(request.projectRoot, cwd, env);
30
+ return {
31
+ mode: "project",
32
+ projectRoot: selected.root,
33
+ projectSelection: selected.selection,
34
+ config,
35
+ registry,
36
+ snapshot: registry.resolve(selected.root),
37
+ store,
38
+ };
39
+ }
40
+ export function inspectExternalMcpStatus(scope) {
41
+ let credentialStore = "missing";
42
+ let credentialStoreMessage;
43
+ try {
44
+ credentialStore = scope.store.inspect().exists ? "ok" : "missing";
45
+ }
46
+ catch (error) {
47
+ credentialStore = "invalid";
48
+ credentialStoreMessage = boundedStatusMessage(error);
49
+ }
50
+ const effective = Object.entries(scope.snapshot.servers).map(([name, config]) => {
51
+ const source = scope.snapshot.origins[name];
52
+ return serverStatus(scope, name, source, config, credentialStore === "invalid");
53
+ });
54
+ const disabled = Object.entries(scope.snapshot.masked).map(([name, source]) => ({
55
+ name,
56
+ source,
57
+ enabled: false,
58
+ }));
59
+ const servers = [...effective, ...disabled].sort((left, right) => left.name.localeCompare(right.name));
60
+ return {
61
+ scope,
62
+ servers,
63
+ configIssues: scope.snapshot.sources.filter((source) => source.state === "invalid").length
64
+ + (credentialStore === "invalid" ? 1 : 0),
65
+ credentialStore,
66
+ ...(credentialStoreMessage ? { credentialStoreMessage } : {}),
67
+ };
68
+ }
69
+ export function findExternalMcpServerStatus(status, name) {
70
+ return status.servers.find((server) => server.name === name);
71
+ }
72
+ export function formatExternalMcpList(status) {
73
+ const lines = ["External MCP", ""];
74
+ if (status.scope.mode === "global") {
75
+ lines.push("Scope: global");
76
+ }
77
+ else {
78
+ lines.push("Scope: project", `Project: ${status.scope.projectRoot}`);
79
+ }
80
+ lines.push("", "Config:");
81
+ for (const source of status.scope.snapshot.sources) {
82
+ lines.push(` ${source.source.padEnd(7)} ${source.path} · ${formatConfigSourceState(source, status)}`);
83
+ }
84
+ if (status.credentialStore === "invalid") {
85
+ lines.push(` auth ${status.scope.store.filePath} · invalid`);
86
+ if (status.credentialStoreMessage)
87
+ lines.push(` ${status.credentialStoreMessage}`);
88
+ }
89
+ else {
90
+ lines.push(` auth ${status.scope.store.filePath} · ${status.credentialStore}`);
91
+ }
92
+ lines.push("", "Servers:");
93
+ if (status.servers.length === 0) {
94
+ lines.push(" none");
95
+ }
96
+ else {
97
+ for (const server of status.servers) {
98
+ lines.push(` ${server.name}`);
99
+ lines.push(` source: ${server.source}`);
100
+ if (!server.enabled) {
101
+ lines.push(" status: disabled");
102
+ continue;
103
+ }
104
+ lines.push(` transport: ${server.transport}`);
105
+ lines.push(` auth: ${formatAuth(server)}`);
106
+ lines.push(" status: configured");
107
+ }
108
+ }
109
+ if (status.scope.snapshot.diagnostics.length > 0) {
110
+ lines.push("", "Config issues:");
111
+ for (const diagnostic of status.scope.snapshot.diagnostics) {
112
+ const source = status.scope.snapshot.sources.find((candidate) => candidate.source === diagnostic.source);
113
+ lines.push(` ${diagnostic.source}: ${diagnostic.message}`);
114
+ if (source?.usingLastKnownGood) {
115
+ lines.push(" Using this process's last-known-good configuration.");
116
+ }
117
+ else {
118
+ lines.push(" Existing ForgeRelay runtimes that loaded a valid version may continue using last-known-good configuration.");
119
+ }
120
+ }
121
+ }
122
+ return lines.join("\n");
123
+ }
124
+ export function formatExternalMcpDoctor(status) {
125
+ const enabled = status.servers.filter((server) => server.enabled);
126
+ const disabled = status.servers.length - enabled.length;
127
+ const authenticated = enabled.filter((server) => server.authState === "authenticated").length;
128
+ const authRequired = enabled.filter((server) => server.authState === "auth-required").length;
129
+ const reauthRequired = enabled.filter((server) => server.authState === "reauth-required").length;
130
+ const sourceSummary = status.scope.snapshot.sources
131
+ .map((source) => `${source.source}=${compactSourceState(source, status)}`)
132
+ .join(" · ");
133
+ return [
134
+ "External MCP:",
135
+ ` Scope: ${status.scope.mode}${status.scope.mode === "project" ? ` (${status.scope.projectRoot})` : ""}`,
136
+ ` Config: ${sourceSummary}`,
137
+ ` Effective servers: ${enabled.length}`,
138
+ ` Disabled: ${disabled}`,
139
+ ` OAuth authenticated: ${authenticated}`,
140
+ ` Auth required: ${authRequired}`,
141
+ ` Reauthorization required: ${reauthRequired}`,
142
+ ` Credential store: ${status.credentialStore}`,
143
+ ` Config issues: ${status.configIssues}`,
144
+ " Hot reload: active",
145
+ " Active checks: not run (use `forgerelay mcp test <server>`)",
146
+ ].join("\n");
147
+ }
148
+ export function formatAuth(status) {
149
+ switch (status.authKind) {
150
+ case "oauth":
151
+ if (status.authState === "authenticated")
152
+ return "oauth · authenticated";
153
+ if (status.authState === "auth-required")
154
+ return "oauth · auth required";
155
+ if (status.authState === "reauth-required")
156
+ return "oauth · reauthorization required";
157
+ return "oauth · not authenticated";
158
+ case "static":
159
+ return "static · configured";
160
+ case "config-managed":
161
+ return "config-managed";
162
+ case "none-detected":
163
+ default:
164
+ return "none detected";
165
+ }
166
+ }
167
+ function serverStatus(scope, name, source, config, credentialStoreInvalid) {
168
+ if (config.transport === "stdio") {
169
+ return {
170
+ name,
171
+ source,
172
+ enabled: true,
173
+ transport: config.transport,
174
+ authKind: "config-managed",
175
+ authState: "configured",
176
+ };
177
+ }
178
+ if (hasStaticAuthorizationHeader(config.headers)) {
179
+ return {
180
+ name,
181
+ source,
182
+ enabled: true,
183
+ transport: config.transport,
184
+ authKind: "static",
185
+ authState: "configured",
186
+ };
187
+ }
188
+ if (credentialStoreInvalid) {
189
+ return {
190
+ name,
191
+ source,
192
+ enabled: true,
193
+ transport: config.transport,
194
+ authKind: config.oauth ? "oauth" : "none-detected",
195
+ authState: "not-detected",
196
+ };
197
+ }
198
+ const identity = externalMcpCredentialIdentity(source, name, scope.projectRoot);
199
+ let record;
200
+ try {
201
+ record = scope.store.read(identity);
202
+ }
203
+ catch {
204
+ record = undefined;
205
+ }
206
+ const bindingMismatch = record ? !credentialBindingMatches(record, config.url) : false;
207
+ const authState = bindingMismatch
208
+ ? "reauth-required"
209
+ : record?.reauthorization?.reason === "authorization_required" && !record.tokens?.access_token
210
+ ? "auth-required"
211
+ : record?.reauthorization
212
+ ? "reauth-required"
213
+ : record?.tokens?.access_token
214
+ ? "authenticated"
215
+ : "not-detected";
216
+ const authKind = record || config.oauth ? "oauth" : "none-detected";
217
+ return {
218
+ name,
219
+ source,
220
+ enabled: true,
221
+ transport: config.transport,
222
+ authKind,
223
+ authState,
224
+ };
225
+ }
226
+ function credentialBindingMatches(record, serverUrl) {
227
+ try {
228
+ if (new URL(record.serverUrl).toString() !== new URL(serverUrl).toString())
229
+ return false;
230
+ }
231
+ catch {
232
+ return false;
233
+ }
234
+ const expectedIssuer = record.discoveryState?.authorizationServerMetadata?.issuer
235
+ ?? record.authorizationServerUrl
236
+ ?? record.discoveryState?.authorizationServerUrl;
237
+ if (!expectedIssuer)
238
+ return true;
239
+ for (const candidate of [record.tokens?.issuer, record.clientInformation?.issuer]) {
240
+ if (candidate && candidate !== expectedIssuer)
241
+ return false;
242
+ }
243
+ return true;
244
+ }
245
+ function selectProjectRoot(explicit, cwd, env) {
246
+ if (explicit)
247
+ return { root: resolve(explicit), selection: "explicit" };
248
+ if (env.FORGERELAY_WORKSPACE_ROOT) {
249
+ return { root: resolve(env.FORGERELAY_WORKSPACE_ROOT), selection: "workspace-env" };
250
+ }
251
+ const ancestor = findNearestProjectConfig(cwd);
252
+ if (ancestor)
253
+ return { root: ancestor, selection: "ancestor-config" };
254
+ return { root: cwd, selection: "cwd" };
255
+ }
256
+ function findNearestProjectConfig(start) {
257
+ let current = resolve(start);
258
+ while (true) {
259
+ if (existsSync(join(current, ".forgerelay", "mcp.json")))
260
+ return current;
261
+ const parent = dirname(current);
262
+ if (parent === current)
263
+ return undefined;
264
+ current = parent;
265
+ }
266
+ }
267
+ function hasStaticAuthorizationHeader(headers) {
268
+ return Object.keys(headers ?? {}).some((name) => name.toLowerCase() === "authorization");
269
+ }
270
+ function formatConfigSourceState(source, status) {
271
+ if (source.source === "legacy") {
272
+ const legacyCount = status.servers.filter((server) => server.enabled && server.source === "legacy").length;
273
+ return legacyCount > 0 ? `compatibility · ${legacyCount} server${legacyCount === 1 ? "" : "s"}` : "compatibility · none";
274
+ }
275
+ if (source.state === "valid")
276
+ return "ok";
277
+ if (source.state === "missing")
278
+ return "missing";
279
+ return source.usingLastKnownGood ? "invalid · using last-known-good" : "invalid";
280
+ }
281
+ function compactSourceState(source, status) {
282
+ if (source.source === "legacy") {
283
+ return status.servers.some((server) => server.enabled && server.source === "legacy") ? "compat" : "none";
284
+ }
285
+ if (source.state === "valid")
286
+ return "ok";
287
+ if (source.state === "missing")
288
+ return "missing";
289
+ return source.usingLastKnownGood ? "invalid/lkg" : "invalid";
290
+ }
291
+ function boundedStatusMessage(error) {
292
+ const raw = error instanceof Error ? error.message : String(error);
293
+ const normalized = raw.replace(/\s+/g, " ").trim() || "External MCP status unavailable.";
294
+ return normalized.length <= 320 ? normalized : `${normalized.slice(0, 317)}...`;
295
+ }
package/dist/cli.js CHANGED
@@ -12,6 +12,8 @@ import { loadConfig } from "./runtime/config/config.js";
12
12
  import { acquireRuntimeLease } from "./runtime/state/runtime-lease.js";
13
13
  import { runInit } from "./cli/init.js";
14
14
  import { runMaintenanceCommand } from "./cli/maintenance.js";
15
+ import { runExternalMcpCommand } from "./cli/mcp/external-mcp.js";
16
+ import { formatExternalMcpDoctor, inspectExternalMcpStatus, resolveExternalMcpScope, } from "./cli/mcp/status.js";
15
17
  import { runHooksCommand } from "./mcp/hooks/hook-cli.js";
16
18
  import { executeSubagentSession } from "./subagents/sessions/execution.js";
17
19
  import { SubagentDeliveryMailbox } from "./subagents/sessions/delivery-mailbox.js";
@@ -63,6 +65,9 @@ async function main(argv) {
63
65
  case "auth":
64
66
  await runAuthCommand(args);
65
67
  return;
68
+ case "mcp":
69
+ await runExternalMcpCommand(args);
70
+ return;
66
71
  case "maintenance":
67
72
  runMaintenanceCommand(args);
68
73
  return;
@@ -77,7 +82,7 @@ async function main(argv) {
77
82
  function normalizeCommand(command) {
78
83
  if (!command || command === "serve" || command === "start")
79
84
  return "serve";
80
- if (command === "init" || command === "doctor" || command === "config" || command === "hooks" || command === "agents" || command === "auth" || command === "maintenance")
85
+ if (command === "init" || command === "doctor" || command === "config" || command === "hooks" || command === "agents" || command === "auth" || command === "mcp" || command === "maintenance")
81
86
  return command;
82
87
  if (command === "help" || command === "--help" || command === "-h")
83
88
  return "help";
@@ -379,6 +384,10 @@ async function runDoctor() {
379
384
  console.log(`Agent-managed Language Server install: ${config.allowAgentLanguageServerInstall ? "enabled" : "disabled"}`);
380
385
  console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`);
381
386
  console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`);
387
+ console.log(formatExternalMcpDoctor(inspectExternalMcpStatus(resolveExternalMcpScope({}, {
388
+ env: process.env,
389
+ cwd: process.cwd(),
390
+ }))));
382
391
  }
383
392
  catch (error) {
384
393
  console.log(`Config status: ${error instanceof Error ? error.message : String(error)}`);
@@ -430,6 +439,10 @@ function printHelp() {
430
439
  " forgerelay auth test <alias>",
431
440
  " forgerelay auth rename <old-alias> <new-alias>",
432
441
  " forgerelay auth remove <alias>",
442
+ " forgerelay mcp list [--project <path>|--global]",
443
+ " forgerelay mcp test <server> [--project <path>|--global]",
444
+ " forgerelay mcp auth <server> [--project <path>|--global]",
445
+ " forgerelay mcp logout <server> [--project <path>|--global]",
433
446
  " forgerelay maintenance inspect [--json]",
434
447
  " forgerelay -v, --version Print the installed version",
435
448
  "",
@@ -2,8 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
- import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5
+ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
7
6
  import { ActivityAuditStore } from "../../activity/history/audit-store.js";
8
7
  import { BashOutputStore } from "../../activity/history/bash-output-store.js";
9
8
  import { HostTurnStore } from "../../activity/history/host-turn-store.js";
@@ -2,6 +2,7 @@ import { constants } from "node:fs";
2
2
  import { access, mkdir, readFile, realpath, writeFile, } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
4
  import { createPatch } from "diff";
5
+ import { claimMediaBytes } from "../operations/media-content.js";
5
6
  import { resolveCanonicalAllowedPath } from "./roots.js";
6
7
  const DEFAULT_MAX_LINES = 2_000;
7
8
  const DEFAULT_MAX_BYTES = 50 * 1_024;
@@ -22,9 +23,14 @@ export async function readFileTool(input, context) {
22
23
  const buffer = await readFile(path);
23
24
  const mimeType = supportedImageMimeType(buffer);
24
25
  if (mimeType) {
26
+ if (input.offset !== undefined || input.limit !== undefined) {
27
+ throw new Error("Image reads do not accept offset or limit.");
28
+ }
29
+ if (context.mediaBudget)
30
+ claimMediaBytes(context.mediaBudget, buffer.byteLength);
25
31
  return {
26
32
  content: [
27
- { type: "text", text: `Read image file [${mimeType}]` },
33
+ { type: "text", text: `Read image file [${mimeType}; ${buffer.byteLength} bytes]` },
28
34
  { type: "image", data: buffer.toString("base64"), mimeType },
29
35
  ],
30
36
  };
@@ -208,8 +214,6 @@ function supportedImageMimeType(buffer) {
208
214
  buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
209
215
  buffer.subarray(8, 12).toString("ascii") === "WEBP")
210
216
  return "image/webp";
211
- if (buffer.length >= 2 && buffer.subarray(0, 2).toString("ascii") === "BM")
212
- return "image/bmp";
213
217
  return undefined;
214
218
  }
215
219
  async function withMutationQueue(path, operation) {
@@ -10,7 +10,7 @@ export function executeHookCommand(input) {
10
10
  detached: input.detached,
11
11
  windowsHide: true,
12
12
  windowsVerbatimArguments: input.windowsVerbatimArguments,
13
- stdio: ["ignore", "pipe", "pipe"],
13
+ stdio: [input.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
14
14
  });
15
15
  let stdout = "";
16
16
  let stderr = "";
@@ -28,12 +28,17 @@ export function executeHookCommand(input) {
28
28
  forceKillTimer.unref();
29
29
  };
30
30
  input.signal?.addEventListener("abort", abort, { once: true });
31
+ const maxCaptureBytes = input.maxCaptureBytes ?? MAX_CAPTURE_BYTES;
31
32
  child.stdout?.on("data", (chunk) => {
32
- stdout = appendCaptured(stdout, chunk);
33
+ stdout = appendCaptured(stdout, chunk, maxCaptureBytes);
33
34
  });
34
35
  child.stderr?.on("data", (chunk) => {
35
- stderr = appendCaptured(stderr, chunk);
36
+ stderr = appendCaptured(stderr, chunk, maxCaptureBytes);
36
37
  });
38
+ if (input.stdin !== undefined) {
39
+ child.stdin?.on("error", () => undefined);
40
+ child.stdin?.end(input.stdin);
41
+ }
37
42
  const timeout = setTimeout(() => {
38
43
  timedOut = true;
39
44
  terminateProcessTree(child, "SIGTERM", input.detached);
@@ -65,13 +70,13 @@ export function executeHookCommand(input) {
65
70
  });
66
71
  });
67
72
  }
68
- function appendCaptured(current, chunk) {
69
- if (Buffer.byteLength(current) >= MAX_CAPTURE_BYTES)
73
+ function appendCaptured(current, chunk, maxBytes) {
74
+ if (Buffer.byteLength(current) >= maxBytes)
70
75
  return current;
71
76
  const next = current + chunk.toString();
72
- if (Buffer.byteLength(next) <= MAX_CAPTURE_BYTES)
77
+ if (Buffer.byteLength(next) <= maxBytes)
73
78
  return next;
74
- return Buffer.from(next).subarray(0, MAX_CAPTURE_BYTES).toString("utf8");
79
+ return Buffer.from(next).subarray(0, maxBytes).toString("utf8");
75
80
  }
76
81
  export function hookFailureOutput(stdout, stderr) {
77
82
  const output = (stderr.trim() || stdout.trim()).replace(/\s+/g, " ");
@@ -0,0 +1,224 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { commandPreview, logEvent, workspaceLogLabel } from "../../runtime/logging/logger.js";
3
+ import { resolveCompatibilityCommandShellRuntime, snapshotCommandShellRuntime, } from "../../runtime/shell/command-shell-runtime.js";
4
+ import { resolveShellCommandForRuntime } from "../process/process-platform.js";
5
+ import { executeHookCommand } from "./command-runner.js";
6
+ import { loadProjectHookConfig, matchHookRule, } from "./hooks.js";
7
+ export class ExternalMcpTransformError extends Error {
8
+ server;
9
+ tool;
10
+ hookName;
11
+ constructor(server, tool, hookName, message) {
12
+ super(message);
13
+ this.server = server;
14
+ this.tool = tool;
15
+ this.hookName = hookName;
16
+ this.name = "ExternalMcpTransformError";
17
+ }
18
+ }
19
+ const TRANSFORM_PROTOCOL_VERSION = 1;
20
+ const MIN_TRANSFORM_CAPTURE_BYTES = 1024 * 1024;
21
+ const TRANSFORM_CAPTURE_OVERHEAD_BYTES = 256 * 1024;
22
+ export class ExternalMcpTransformRunner {
23
+ hooks;
24
+ logging;
25
+ baseEnv;
26
+ commandShellRuntime;
27
+ maxCaptureBytes;
28
+ constructor(hooks, logging, baseEnv = process.env, commandShellRuntime, mediaMaxBytes = 20 * 1024 * 1024) {
29
+ this.hooks = hooks;
30
+ this.logging = logging;
31
+ this.baseEnv = baseEnv;
32
+ this.commandShellRuntime = snapshotCommandShellRuntime(commandShellRuntime ?? resolveCompatibilityCommandShellRuntime(process.platform, baseEnv));
33
+ this.maxCaptureBytes = Math.max(MIN_TRANSFORM_CAPTURE_BYTES, Math.ceil((mediaMaxBytes * 4) / 3) + TRANSFORM_CAPTURE_OVERHEAD_BYTES);
34
+ }
35
+ async transformRequest(context, arguments_, signal) {
36
+ return this.run("request", context, arguments_, signal);
37
+ }
38
+ async transformResult(context, result, signal) {
39
+ return this.run("result", context, result, signal);
40
+ }
41
+ async run(phase, context, initialValue, signal) {
42
+ signal?.throwIfAborted();
43
+ const event = phase === "request"
44
+ ? "ExternalMcpBeforeForward"
45
+ : "ExternalMcpAfterForward";
46
+ const invocation = {
47
+ workspaceId: context.workspaceId,
48
+ workspaceRoot: context.workspaceRoot,
49
+ workspaceMode: context.workspaceMode,
50
+ payload: transformMetadata(context, phase),
51
+ };
52
+ const project = await loadProjectHookConfig(context.workspaceRoot);
53
+ const handlers = [
54
+ ...(this.hooks[event] ?? []).map((rule) => ({ scope: "global", rule })),
55
+ ...(project.hooks[event] ?? []).map((rule) => ({ scope: "project", rule })),
56
+ ].flatMap(({ scope, rule }) => {
57
+ const matched = matchHookRule(rule.matcher, invocation);
58
+ if (!matched)
59
+ return [];
60
+ return rule.handlers.map((handler) => ({ scope, handler }));
61
+ });
62
+ let value = initialValue;
63
+ const transforms = [];
64
+ for (const [index, entry] of handlers.entries()) {
65
+ signal?.throwIfAborted();
66
+ const execution = await this.runHandler(event, phase, context, entry.handler, entry.scope, index, value, signal);
67
+ value = execution.value;
68
+ transforms.push({
69
+ phase,
70
+ name: execution.report.name,
71
+ scope: execution.report.scope,
72
+ status: "passed",
73
+ });
74
+ }
75
+ return { value, transforms };
76
+ }
77
+ async runHandler(event, phase, context, handler, scope, index, currentValue, signal) {
78
+ const startedAt = performance.now();
79
+ const name = handler.name ?? `${event} handler ${index + 1}`;
80
+ const shell = resolveShellCommandForRuntime(handler.command, this.commandShellRuntime);
81
+ const detached = process.platform !== "win32";
82
+ const metadata = transformMetadata(context, phase);
83
+ const env = {
84
+ ...this.baseEnv,
85
+ FORGERELAY_HOOK_EVENT: event,
86
+ FORGERELAY_HOOK_PAYLOAD: JSON.stringify(metadata),
87
+ FORGERELAY_WORKSPACE_ROOT: context.workspaceRoot,
88
+ FORGERELAY_WORKSPACE_ID: context.workspaceId,
89
+ FORGERELAY_WORKSPACE_MODE: context.workspaceMode,
90
+ FORGERELAY_TOOL_NAME: "capability",
91
+ FORGERELAY_CAPABILITY_NAME: "mcp.external",
92
+ FORGERELAY_EXTERNAL_MCP_SERVER: context.server,
93
+ FORGERELAY_EXTERNAL_MCP_TOOL: context.tool,
94
+ FORGERELAY_TRANSFORM_PHASE: phase,
95
+ };
96
+ const stdin = JSON.stringify(transformInput(phase, context, currentValue));
97
+ try {
98
+ const result = await executeHookCommand({
99
+ executable: shell.executable,
100
+ args: shell.args,
101
+ windowsVerbatimArguments: shell.windowsVerbatimArguments,
102
+ cwd: context.workspaceRoot,
103
+ env,
104
+ timeoutMs: handler.timeoutSeconds * 1_000,
105
+ detached,
106
+ signal,
107
+ stdin,
108
+ maxCaptureBytes: this.maxCaptureBytes,
109
+ });
110
+ const durationMs = Math.round(performance.now() - startedAt);
111
+ if (result.exitCode !== 0 || result.timedOut || result.signal) {
112
+ const reason = result.timedOut
113
+ ? `timed out after ${handler.timeoutSeconds}s`
114
+ : result.signal
115
+ ? `terminated by ${result.signal}`
116
+ : `exited with code ${result.exitCode ?? "unknown"}`;
117
+ this.logTransform(event, name, scope, context, false, durationMs, reason, handler.command);
118
+ throw this.error(context, name, `transform Hook ${name} ${reason}.`);
119
+ }
120
+ let value;
121
+ try {
122
+ value = parseTransformOutput(phase, result.stdout);
123
+ }
124
+ catch {
125
+ this.logTransform(event, name, scope, context, false, durationMs, "invalid structured transform output", handler.command);
126
+ throw this.error(context, name, `transform Hook ${name} returned invalid structured transform output.`);
127
+ }
128
+ this.logTransform(event, name, scope, context, true, durationMs, undefined, handler.command);
129
+ return {
130
+ value,
131
+ report: {
132
+ event,
133
+ name: boundedName(name),
134
+ scope,
135
+ status: "passed",
136
+ durationMs,
137
+ report: handler.report,
138
+ },
139
+ };
140
+ }
141
+ catch (error) {
142
+ if (signal?.aborted)
143
+ throw error;
144
+ if (error instanceof ExternalMcpTransformError)
145
+ throw error;
146
+ const durationMs = Math.round(performance.now() - startedAt);
147
+ this.logTransform(event, name, scope, context, false, durationMs, "failed to start", handler.command);
148
+ throw this.error(context, name, `transform Hook ${name} failed to start.`);
149
+ }
150
+ }
151
+ error(context, name, detail) {
152
+ return new ExternalMcpTransformError(context.server, context.tool, boundedName(name), `External MCP ${context.server} tool ${context.tool} ${detail}`);
153
+ }
154
+ logTransform(event, name, scope, context, success, durationMs, error, command) {
155
+ logEvent(this.logging, success ? "info" : "warn", "hook_call", {
156
+ hookEvent: event,
157
+ hookName: boundedName(name),
158
+ hookScope: scope,
159
+ workspaceId: context.workspaceId,
160
+ workspace: workspaceLogLabel(context.workspaceRoot, context.workspaceId),
161
+ capability: "mcp.external",
162
+ externalServer: context.server,
163
+ externalTool: context.tool,
164
+ success,
165
+ durationMs,
166
+ error,
167
+ commandPreview: this.logging.shellCommands ? commandPreview(command) : undefined,
168
+ });
169
+ }
170
+ }
171
+ function transformMetadata(context, phase) {
172
+ return {
173
+ tool: "capability",
174
+ capability: "mcp.external",
175
+ externalServer: context.server,
176
+ externalTool: context.tool,
177
+ transformPhase: phase,
178
+ };
179
+ }
180
+ function transformInput(phase, context, value) {
181
+ const base = {
182
+ version: TRANSFORM_PROTOCOL_VERSION,
183
+ phase,
184
+ capability: "mcp.external",
185
+ server: context.server,
186
+ tool: context.tool,
187
+ };
188
+ return phase === "request"
189
+ ? { ...base, request: { arguments: value } }
190
+ : { ...base, result: value };
191
+ }
192
+ function parseTransformOutput(phase, stdout) {
193
+ const text = stdout.trim();
194
+ if (!text)
195
+ throw new Error("empty transform output");
196
+ const value = JSON.parse(text);
197
+ if (!isRecord(value) || value.version !== TRANSFORM_PROTOCOL_VERSION) {
198
+ throw new Error("invalid transform envelope");
199
+ }
200
+ const allowedKeys = phase === "request"
201
+ ? new Set(["version", "request"])
202
+ : new Set(["version", "result"]);
203
+ if (Object.keys(value).some((key) => !allowedKeys.has(key))) {
204
+ throw new Error("unexpected transform envelope field");
205
+ }
206
+ if (phase === "request") {
207
+ if (!isRecord(value.request) || !isRecord(value.request.arguments)) {
208
+ throw new Error("invalid request transform output");
209
+ }
210
+ if (Object.keys(value.request).some((key) => key !== "arguments")) {
211
+ throw new Error("unexpected request transform field");
212
+ }
213
+ return value.request.arguments;
214
+ }
215
+ if (!isRecord(value.result))
216
+ throw new Error("invalid result transform output");
217
+ return value.result;
218
+ }
219
+ function boundedName(value) {
220
+ return value.length > 200 ? `${value.slice(0, 197)}...` : value;
221
+ }
222
+ function isRecord(value) {
223
+ return typeof value === "object" && value !== null && !Array.isArray(value);
224
+ }
@@ -98,6 +98,9 @@ function formatMatcher(matcher) {
98
98
  matcher.pathRegex ? `pathRegex=${matcher.pathRegex}` : undefined,
99
99
  matcher.provider ? `provider=${matcher.provider}` : undefined,
100
100
  matcher.workspaceMode ? `workspaceMode=${matcher.workspaceMode}` : undefined,
101
+ matcher.capability ? `capability=${matcher.capability}` : undefined,
102
+ matcher.externalServer ? `externalServer=${matcher.externalServer}` : undefined,
103
+ matcher.externalTool ? `externalTool=${matcher.externalTool}` : undefined,
101
104
  ].filter((value) => value !== undefined);
102
105
  return parts.length > 0 ? parts.join(" ") : "matcher=*";
103
106
  }