@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,382 @@
1
+ import { createHash } from "node:crypto";
2
+ import { AuthorizationServerMismatchError, Client, InsufficientScopeError, SdkHttpError, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
3
+ import { StdioClientTransport, getDefaultEnvironment, } from "@modelcontextprotocol/client/stdio";
4
+ import { CallToolResultSchema } from "@modelcontextprotocol/core";
5
+ import { externalMcpCredentialIdentity, } from "../../../runtime/config/external-mcp-auth-store.js";
6
+ import { claimMediaBytes, createMediaBudget, isSupportedImageMimeType, strictBase64ByteLength, } from "../media-content.js";
7
+ import { ExternalMcpTransformError, } from "../../hooks/external-mcp-transform.js";
8
+ import { ExternalMcpOAuthError, createExternalMcpRuntimeAuth, externalMcpAuthError, markExternalMcpAuthorizationRequired, markExternalMcpReauthorization, } from "./external-mcp-oauth.js";
9
+ const MAX_DISCOVERED_TOOLS = 100;
10
+ const MAX_TOOL_DESCRIPTION_CHARS = 2_000;
11
+ const MAX_TOOL_SCHEMA_BYTES = 64 * 1024;
12
+ const MAX_TOOL_DISCOVERY_PAGES = 16;
13
+ const LEGACY_NEGOTIATION_TTL_MS = 5 * 60 * 1_000;
14
+ export class ExternalMcpError extends Error {
15
+ code;
16
+ detail;
17
+ constructor(code, message, detail) {
18
+ super(message);
19
+ this.code = code;
20
+ this.detail = detail;
21
+ this.name = "ExternalMcpError";
22
+ }
23
+ }
24
+ export class ExternalMcpGateway {
25
+ mediaMaxBytes;
26
+ credentialStore;
27
+ negotiationCache = new Map();
28
+ constructor(mediaMaxBytes, credentialStore) {
29
+ this.mediaMaxBytes = mediaMaxBytes;
30
+ this.credentialStore = credentialStore;
31
+ }
32
+ async run(servers, input, signal, transforms, authContext) {
33
+ signal?.throwIfAborted();
34
+ switch (input.operation) {
35
+ case "servers":
36
+ return {
37
+ value: {
38
+ operation: "servers",
39
+ servers: Object.entries(servers)
40
+ .sort(([left], [right]) => left.localeCompare(right))
41
+ .map(([name, server]) => ({ name, transport: server.transport })),
42
+ },
43
+ };
44
+ case "tools":
45
+ return this.withClient(servers, input.server, signal, authContext, async (client) => {
46
+ const discovery = await discoverTools(client, signal);
47
+ return {
48
+ value: {
49
+ operation: "tools",
50
+ server: input.server,
51
+ tools: discovery.tools.map(summarizeTool),
52
+ ...(discovery.truncated ? { truncated: true } : {}),
53
+ },
54
+ };
55
+ });
56
+ case "call":
57
+ return this.withClient(servers, input.server, signal, authContext, async (client) => {
58
+ await assertRegisteredTool(client, input.server, input.tool, signal);
59
+ const appliedTransforms = [];
60
+ let callArguments = input.arguments ?? {};
61
+ if (transforms?.request) {
62
+ const transformed = await transforms.request(input.server, input.tool, callArguments);
63
+ callArguments = transformed.value;
64
+ appliedTransforms.push(...transformed.transforms);
65
+ }
66
+ let result = await client.callTool({ name: input.tool, arguments: callArguments }, { signal });
67
+ assertCallToolResult(input.server, input.tool, result);
68
+ if (result.isError) {
69
+ throw new ExternalMcpError("tool_failed", `External MCP ${input.server} tool ${input.tool} returned an upstream tool error.`);
70
+ }
71
+ if (transforms?.result) {
72
+ const transformed = await transforms.result(input.server, input.tool, result);
73
+ result = transformed.value;
74
+ appliedTransforms.push(...transformed.transforms);
75
+ assertCallToolResult(input.server, input.tool, result);
76
+ if (result.isError) {
77
+ throw new ExternalMcpError("tool_failed", `External MCP ${input.server} tool ${input.tool} returned an error after result transformation.`);
78
+ }
79
+ }
80
+ const projection = projectExternalMcpContent(input.server, input.tool, result, this.mediaMaxBytes);
81
+ return {
82
+ content: result.content,
83
+ value: {
84
+ operation: "call",
85
+ server: input.server,
86
+ tool: input.tool,
87
+ content: projection.content,
88
+ ...(!projection.hasMedia && isRecord(result.structuredContent)
89
+ ? { structuredContent: result.structuredContent }
90
+ : {}),
91
+ ...(appliedTransforms.length > 0 ? { transforms: appliedTransforms } : {}),
92
+ },
93
+ };
94
+ });
95
+ }
96
+ }
97
+ async probe(servers, name, signal, authContext) {
98
+ return this.withClient(servers, name, signal, authContext, async (client) => {
99
+ let discovery;
100
+ try {
101
+ discovery = await discoverTools(client, signal);
102
+ }
103
+ catch (error) {
104
+ if (isExternalMcpAuthTransportError(error))
105
+ throw error;
106
+ throw new ExternalMcpError("tool_discovery_failed", `External MCP ${name} tool discovery failed.`, externalMcpFailureDetail(error));
107
+ }
108
+ return {
109
+ protocolEra: client.getProtocolEra() ?? "unknown",
110
+ ...(client.getNegotiatedProtocolVersion()
111
+ ? { protocolVersion: client.getNegotiatedProtocolVersion() }
112
+ : {}),
113
+ toolCount: discovery.tools.length,
114
+ truncated: discovery.truncated,
115
+ };
116
+ });
117
+ }
118
+ async withClient(servers, name, signal, authContext, operation) {
119
+ const config = servers[name];
120
+ if (!config)
121
+ throw new ExternalMcpError("unknown_server", `Unknown configured external MCP server: ${name}.`);
122
+ const fingerprint = externalMcpServerFingerprint(config);
123
+ const runtimeAuth = this.resolveRuntimeAuth(name, config, authContext);
124
+ const client = new Client({ name: "forgerelay-external-mcp", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } });
125
+ const transport = createTransport(config, runtimeAuth?.authProvider);
126
+ const prior = this.cachedPrior(name, fingerprint);
127
+ try {
128
+ signal?.throwIfAborted();
129
+ await client.connect(transport, prior ? { prior } : undefined);
130
+ this.rememberNegotiation(name, fingerprint, client);
131
+ signal?.throwIfAborted();
132
+ return await operation(client);
133
+ }
134
+ catch (error) {
135
+ if (error instanceof ExternalMcpError || error instanceof ExternalMcpTransformError)
136
+ throw error;
137
+ this.invalidateNegotiation(name, fingerprint);
138
+ if (signal?.aborted)
139
+ signal.throwIfAborted();
140
+ if (error instanceof ExternalMcpOAuthError) {
141
+ throw new ExternalMcpError(error.code, error.message);
142
+ }
143
+ const authError = externalMcpAuthError(name, error, runtimeAuth);
144
+ if (authError) {
145
+ if (this.credentialStore) {
146
+ if (authError.code === "auth_required") {
147
+ await markExternalMcpAuthorizationRequired(this.credentialStore, runtimeAuth).catch(() => undefined);
148
+ }
149
+ else if (error instanceof InsufficientScopeError) {
150
+ await markExternalMcpReauthorization(this.credentialStore, runtimeAuth, "insufficient_scope", { ...(error.requiredScope ? { scope: error.requiredScope } : {}) }).catch(() => undefined);
151
+ }
152
+ else if (runtimeAuth?.bindingMismatch || error instanceof AuthorizationServerMismatchError) {
153
+ await markExternalMcpReauthorization(this.credentialStore, runtimeAuth, "binding_changed").catch(() => undefined);
154
+ }
155
+ }
156
+ throw new ExternalMcpError(authError.code, authError.message);
157
+ }
158
+ throw new ExternalMcpError("transport_failed", `External MCP ${name} request failed.`, externalMcpFailureDetail(error));
159
+ }
160
+ finally {
161
+ await client.close().catch(() => undefined);
162
+ }
163
+ }
164
+ resolveRuntimeAuth(name, config, context) {
165
+ if (!this.credentialStore || !context || config.transport !== "streamable-http")
166
+ return undefined;
167
+ if (hasStaticAuthorizationHeader(config.headers))
168
+ return undefined;
169
+ const source = context.origins[name];
170
+ if (!source)
171
+ return undefined;
172
+ const identity = externalMcpCredentialIdentity(source, name, context.workspaceRoot);
173
+ return createExternalMcpRuntimeAuth(this.credentialStore, identity, config.url);
174
+ }
175
+ cachedPrior(name, fingerprint) {
176
+ const cached = this.negotiationCache.get(name);
177
+ if (!cached || cached.fingerprint !== fingerprint) {
178
+ if (cached)
179
+ this.negotiationCache.delete(name);
180
+ return undefined;
181
+ }
182
+ if (cached.prior.kind === "legacy" &&
183
+ Date.now() - cached.cachedAt >= LEGACY_NEGOTIATION_TTL_MS) {
184
+ this.negotiationCache.delete(name);
185
+ return undefined;
186
+ }
187
+ return cached.prior;
188
+ }
189
+ rememberNegotiation(name, fingerprint, client) {
190
+ const era = client.getProtocolEra();
191
+ if (era === "legacy") {
192
+ this.negotiationCache.set(name, {
193
+ fingerprint,
194
+ prior: { kind: "legacy" },
195
+ cachedAt: Date.now(),
196
+ });
197
+ return;
198
+ }
199
+ if (era !== "modern")
200
+ return;
201
+ const discover = client.getDiscoverResult();
202
+ if (!discover)
203
+ return;
204
+ this.negotiationCache.set(name, {
205
+ fingerprint,
206
+ prior: { kind: "modern", discover },
207
+ cachedAt: Date.now(),
208
+ });
209
+ }
210
+ invalidateNegotiation(name, fingerprint) {
211
+ const cached = this.negotiationCache.get(name);
212
+ if (cached?.fingerprint === fingerprint)
213
+ this.negotiationCache.delete(name);
214
+ }
215
+ }
216
+ function externalMcpServerFingerprint(config) {
217
+ const normalized = config.transport === "stdio"
218
+ ? {
219
+ transport: config.transport,
220
+ command: config.command,
221
+ args: config.args ?? [],
222
+ cwd: config.cwd ?? null,
223
+ env: sortedStringRecord(config.env),
224
+ }
225
+ : {
226
+ transport: config.transport,
227
+ url: config.url,
228
+ headers: sortedStringRecord(config.headers),
229
+ oauth: config.oauth ?? null,
230
+ };
231
+ return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
232
+ }
233
+ function sortedStringRecord(value) {
234
+ return Object.fromEntries(Object.entries(value ?? {}).sort(([left], [right]) => left.localeCompare(right)));
235
+ }
236
+ function createTransport(config, authProvider) {
237
+ if (config.transport === "stdio") {
238
+ return new StdioClientTransport({
239
+ command: config.command,
240
+ ...(config.args ? { args: config.args } : {}),
241
+ ...(config.cwd ? { cwd: config.cwd } : {}),
242
+ ...(config.env
243
+ ? { env: { ...getDefaultEnvironment(), ...config.env } }
244
+ : {}),
245
+ stderr: "ignore",
246
+ });
247
+ }
248
+ return new StreamableHTTPClientTransport(new URL(config.url), {
249
+ ...(config.headers ? { requestInit: { headers: config.headers } } : {}),
250
+ ...(authProvider ? { authProvider } : {}),
251
+ onInsufficientScope: "throw",
252
+ });
253
+ }
254
+ function hasStaticAuthorizationHeader(headers) {
255
+ return Object.keys(headers ?? {}).some((name) => name.toLowerCase() === "authorization");
256
+ }
257
+ function isExternalMcpAuthTransportError(error) {
258
+ return error instanceof ExternalMcpOAuthError
259
+ || error instanceof InsufficientScopeError
260
+ || error instanceof AuthorizationServerMismatchError
261
+ || error instanceof UnauthorizedError
262
+ || (error instanceof SdkHttpError && error.status === 401);
263
+ }
264
+ function externalMcpFailureDetail(error) {
265
+ if (error instanceof SdkHttpError)
266
+ return `HTTP ${error.status}`;
267
+ const code = nestedErrorCode(error);
268
+ if (code && code !== "ERA_NEGOTIATION_FAILED")
269
+ return code;
270
+ if (error instanceof Error && /tim(?:e|ed)\s*out|timeout/i.test(error.message))
271
+ return "timeout";
272
+ return undefined;
273
+ }
274
+ function nestedErrorCode(error, depth = 0) {
275
+ if (depth > 4 || typeof error !== "object" || error === null)
276
+ return undefined;
277
+ if ("cause" in error) {
278
+ const nested = nestedErrorCode(error.cause, depth + 1);
279
+ if (nested)
280
+ return nested;
281
+ }
282
+ if ("code" in error) {
283
+ const code = error.code;
284
+ if (typeof code === "string" && /^[A-Z][A-Z0-9_]{1,39}$/.test(code))
285
+ return code;
286
+ }
287
+ return undefined;
288
+ }
289
+ async function discoverTools(client, signal) {
290
+ const tools = [];
291
+ let cursor;
292
+ for (let pageIndex = 0; pageIndex < MAX_TOOL_DISCOVERY_PAGES; pageIndex += 1) {
293
+ signal?.throwIfAborted();
294
+ const page = await client.listTools(cursor ? { cursor } : undefined, { signal });
295
+ const remaining = MAX_DISCOVERED_TOOLS - tools.length;
296
+ tools.push(...page.tools.slice(0, remaining));
297
+ if (page.tools.length > remaining)
298
+ return { tools, truncated: true };
299
+ cursor = page.nextCursor;
300
+ if (!cursor)
301
+ return { tools, truncated: false };
302
+ if (tools.length >= MAX_DISCOVERED_TOOLS)
303
+ return { tools, truncated: true };
304
+ }
305
+ return { tools, truncated: cursor !== undefined };
306
+ }
307
+ async function assertRegisteredTool(client, server, tool, signal) {
308
+ let cursor;
309
+ for (let pageIndex = 0; pageIndex < MAX_TOOL_DISCOVERY_PAGES; pageIndex += 1) {
310
+ signal?.throwIfAborted();
311
+ const page = await client.listTools(cursor ? { cursor } : undefined, { signal });
312
+ if (page.tools.some((candidate) => candidate.name === tool))
313
+ return;
314
+ cursor = page.nextCursor;
315
+ if (!cursor)
316
+ break;
317
+ }
318
+ throw new ExternalMcpError("unknown_tool", `External MCP ${server} does not advertise tool ${tool}.`);
319
+ }
320
+ function summarizeTool(tool) {
321
+ const serialized = safeJson(tool.inputSchema);
322
+ const schemaTruncated = Buffer.byteLength(serialized, "utf8") > MAX_TOOL_SCHEMA_BYTES;
323
+ return {
324
+ name: tool.name,
325
+ ...(tool.description
326
+ ? { description: tool.description.slice(0, MAX_TOOL_DESCRIPTION_CHARS) }
327
+ : {}),
328
+ inputSchema: schemaTruncated ? { type: "object" } : tool.inputSchema,
329
+ ...(schemaTruncated ? { schemaTruncated: true } : {}),
330
+ };
331
+ }
332
+ function projectExternalMcpContent(server, tool, result, mediaMaxBytes) {
333
+ const budget = createMediaBudget(mediaMaxBytes);
334
+ const content = [];
335
+ let hasMedia = false;
336
+ for (const entry of result.content ?? []) {
337
+ if (entry.type === "image") {
338
+ hasMedia = true;
339
+ if (!isSupportedImageMimeType(entry.mimeType)) {
340
+ throw new ExternalMcpError("media_unsupported", `External MCP ${server} tool ${tool} returned unsupported image MIME type ${entry.mimeType}.`);
341
+ }
342
+ const bytes = strictBase64ByteLength(entry.data);
343
+ if (bytes === undefined) {
344
+ throw new ExternalMcpError("media_malformed", `External MCP ${server} tool ${tool} returned malformed base64 image content.`);
345
+ }
346
+ try {
347
+ claimMediaBytes(budget, bytes);
348
+ }
349
+ catch {
350
+ throw new ExternalMcpError("media_too_large", `External MCP ${server} tool ${tool} returned media exceeding the configured ${mediaMaxBytes}-byte aggregate limit.`);
351
+ }
352
+ content.push({ type: "image", mimeType: entry.mimeType, bytes });
353
+ continue;
354
+ }
355
+ if (entry.type === "audio") {
356
+ throw new ExternalMcpError("media_unsupported", `External MCP ${server} tool ${tool} returned unsupported audio media content.`);
357
+ }
358
+ if (entry.type === "resource" && "blob" in entry.resource && typeof entry.resource.blob === "string") {
359
+ throw new ExternalMcpError("media_unsupported", `External MCP ${server} tool ${tool} returned unsupported binary resource content.`);
360
+ }
361
+ content.push(entry);
362
+ }
363
+ return { content, hasMedia };
364
+ }
365
+ function assertCallToolResult(server, tool, value) {
366
+ if (!isRecord(value)
367
+ || !Array.isArray(value.content)
368
+ || !CallToolResultSchema.safeParse(value).success) {
369
+ throw new ExternalMcpError("unsupported_result", `External MCP ${server} tool ${tool} returned an unsupported result shape.`);
370
+ }
371
+ }
372
+ function safeJson(value) {
373
+ try {
374
+ return JSON.stringify(value) ?? "";
375
+ }
376
+ catch {
377
+ return "";
378
+ }
379
+ }
380
+ function isRecord(value) {
381
+ return typeof value === "object" && value !== null && !Array.isArray(value);
382
+ }
@@ -0,0 +1,28 @@
1
+ export const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
2
+ export const SUPPORTED_IMAGE_MIME_TYPES = [
3
+ "image/png",
4
+ "image/jpeg",
5
+ "image/gif",
6
+ "image/webp",
7
+ ];
8
+ export function createMediaBudget(maxBytes) {
9
+ return { maxBytes, remainingBytes: maxBytes };
10
+ }
11
+ export function isSupportedImageMimeType(value) {
12
+ return SUPPORTED_IMAGE_MIME_TYPES.includes(value);
13
+ }
14
+ export function strictBase64ByteLength(value) {
15
+ if (value.length === 0 || value.length % 4 !== 0)
16
+ return undefined;
17
+ if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
18
+ return undefined;
19
+ }
20
+ return Buffer.byteLength(value, "base64");
21
+ }
22
+ export function claimMediaBytes(budget, bytes) {
23
+ if (bytes > budget.remainingBytes) {
24
+ throw new Error(`Media content exceeds the configured per-result limit of ${budget.maxBytes} bytes ` +
25
+ `(${bytes} bytes requested, ${budget.remainingBytes} bytes remaining).`);
26
+ }
27
+ budget.remainingBytes -= bytes;
28
+ }
@@ -60,7 +60,7 @@ export class NativeBulkMutationExecutor {
60
60
  return this.dependencies.lifecycle.run({
61
61
  tool,
62
62
  workspace: workspaceSnapshot(workspace),
63
- conversationScopeId: openAiConversationScopeId(context.requestMeta),
63
+ conversationScopeId: context.conversationScopeId ?? openAiConversationScopeId(context.requestMeta),
64
64
  request,
65
65
  operation,
66
66
  outcome,
@@ -1,5 +1,6 @@
1
1
  import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
2
2
  import * as z from "zod/v4";
3
+ import { mcpHandlerRequestContext } from "../request-context.js";
3
4
  import { runToolWithHooks } from "../hooks/hooks.js";
4
5
  import { commandPreview, logEvent, workspaceLogLabel } from "../../runtime/logging/logger.js";
5
6
  import { resolveProcessId } from "./process-sessions.js";
@@ -50,9 +51,10 @@ function registerBashTool(options) {
50
51
  _meta: {},
51
52
  annotations: SHELL_TOOL_ANNOTATIONS,
52
53
  }, async ({ workspaceId, member, action = "run", command, processId, outputId, input, interrupt, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens, }, extra) => {
54
+ const requestContext = mcpHandlerRequestContext(extra);
53
55
  const target = routing.resolve(workspaceId, member);
54
56
  const executionWorkspaceId = target.executionWorkspaceId;
55
- const executionContext = await routing.prepare(target, extra._meta, extra.signal, extra.sessionId);
57
+ const executionContext = await routing.prepare(target, requestContext);
56
58
  if (routing.isRemote(executionWorkspaceId)) {
57
59
  const response = await routing.bashRemote(executionWorkspaceId, {
58
60
  action,
@@ -68,9 +70,9 @@ function registerBashTool(options) {
68
70
  ...(yieldTimeMs !== undefined ? { yieldTimeMs } : {}),
69
71
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
70
72
  ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
71
- }, routing.hostScopeIdFor(extra._meta, extra.sessionId));
73
+ }, routing.hostScopeIdFor(requestContext));
72
74
  return action === "run"
73
- ? routing.presentSemantic(response, target, routing.hostScopeIdFor(extra._meta, extra.sessionId))
75
+ ? routing.presentSemantic(response, target, routing.hostScopeIdFor(requestContext))
74
76
  : routing.present(response, target);
75
77
  }
76
78
  const workspace = workspaces.getWorkspace(executionWorkspaceId);
@@ -91,7 +93,7 @@ function registerBashTool(options) {
91
93
  yieldTimeMs,
92
94
  timeoutMs,
93
95
  maxOutputTokens,
94
- }, executionContext), target, routing.hostScopeIdFor(extra._meta, extra.sessionId));
96
+ }, executionContext), target, routing.hostScopeIdFor(requestContext));
95
97
  }
96
98
  if (action === "output") {
97
99
  if (!outputId)
@@ -102,7 +104,7 @@ function registerBashTool(options) {
102
104
  throw new Error("bash action=output accepts only workspaceId and outputId.");
103
105
  }
104
106
  return runToolWithHooks(hooks, {
105
- signal: extra.signal,
107
+ signal: requestContext.signal,
106
108
  tool: toolNames.shell,
107
109
  invocation: workspaceHookInvocation(workspace),
108
110
  payload: { action, outputId },
@@ -120,7 +122,7 @@ function registerBashTool(options) {
120
122
  throw new Error("bash action=process cannot combine interrupt with input.");
121
123
  }
122
124
  return runToolWithHooks(hooks, {
123
- signal: extra.signal,
125
+ signal: requestContext.signal,
124
126
  tool: toolNames.shell,
125
127
  invocation: workspaceHookInvocation(workspace),
126
128
  payload: {
@@ -142,7 +144,7 @@ function registerBashTool(options) {
142
144
  rows,
143
145
  yieldTimeMs,
144
146
  maxOutputTokens,
145
- signal: extra.signal,
147
+ signal: requestContext.signal,
146
148
  });
147
149
  logProcessToolCall(config, workspace, {
148
150
  tool: toolNames.shell,
@@ -190,8 +192,9 @@ function registerCodexProcessTools(options) {
190
192
  _meta: {},
191
193
  annotations: SHELL_TOOL_ANNOTATIONS,
192
194
  }, async ({ workspaceId, member, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens }, extra) => {
195
+ const requestContext = mcpHandlerRequestContext(extra);
193
196
  const target = routing.resolve(workspaceId, member);
194
- const context = await routing.prepare(target, extra._meta, extra.signal, extra.sessionId);
197
+ const context = await routing.prepare(target, requestContext);
195
198
  if (routing.isRemote(target.executionWorkspaceId)) {
196
199
  return routing.presentSemantic(await routing.execCommandRemote(target.executionWorkspaceId, {
197
200
  cmd,
@@ -202,7 +205,7 @@ function registerCodexProcessTools(options) {
202
205
  ...(yieldTimeMs !== undefined ? { yieldTimeMs } : {}),
203
206
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
204
207
  ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
205
- }, routing.hostScopeIdFor(extra._meta, extra.sessionId)), target, routing.hostScopeIdFor(extra._meta, extra.sessionId));
208
+ }, routing.hostScopeIdFor(requestContext)), target, routing.hostScopeIdFor(requestContext));
206
209
  }
207
210
  return routing.presentSemantic(await shellRun({
208
211
  workspaceId: target.executionWorkspaceId,
@@ -215,7 +218,7 @@ function registerCodexProcessTools(options) {
215
218
  yieldTimeMs,
216
219
  timeoutMs,
217
220
  maxOutputTokens,
218
- }, context), target, routing.hostScopeIdFor(extra._meta, extra.sessionId));
221
+ }, context), target, routing.hostScopeIdFor(requestContext));
219
222
  });
220
223
  registerAppTool(server, "write_stdin", {
221
224
  title: "Write to process",
@@ -236,8 +239,9 @@ function registerCodexProcessTools(options) {
236
239
  _meta: {},
237
240
  annotations: SHELL_TOOL_ANNOTATIONS,
238
241
  }, async ({ workspaceId, member, processId, sessionId, outputId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
242
+ const requestContext = mcpHandlerRequestContext(extra);
239
243
  const target = routing.resolve(workspaceId, member);
240
- await routing.prepare(target, extra._meta, extra.signal, extra.sessionId);
244
+ await routing.prepare(target, requestContext);
241
245
  if (routing.isRemote(target.executionWorkspaceId)) {
242
246
  return routing.present(await routing.writeStdinRemote(target.executionWorkspaceId, {
243
247
  ...(processId !== undefined ? { processId } : {}),
@@ -248,7 +252,7 @@ function registerCodexProcessTools(options) {
248
252
  ...(rows !== undefined ? { rows } : {}),
249
253
  ...(yieldTimeMs !== undefined ? { yieldTimeMs } : {}),
250
254
  ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
251
- }, routing.hostScopeIdFor(extra._meta, extra.sessionId)), target);
255
+ }, routing.hostScopeIdFor(requestContext)), target);
252
256
  }
253
257
  const executionWorkspaceId = target.executionWorkspaceId;
254
258
  const workspace = workspaces.getWorkspace(executionWorkspaceId);
@@ -258,7 +262,7 @@ function registerCodexProcessTools(options) {
258
262
  throw new Error("write_stdin outputId lookup cannot be combined with process control fields.");
259
263
  }
260
264
  return runToolWithHooks(hooks, {
261
- signal: extra.signal,
265
+ signal: requestContext.signal,
262
266
  tool: "write_stdin",
263
267
  invocation: workspaceHookInvocation(workspace),
264
268
  payload: { outputId },
@@ -267,7 +271,7 @@ function registerCodexProcessTools(options) {
267
271
  }
268
272
  const resolvedProcessId = resolveProcessId(processId, sessionId);
269
273
  return runToolWithHooks(hooks, {
270
- signal: extra.signal,
274
+ signal: requestContext.signal,
271
275
  tool: "write_stdin",
272
276
  invocation: workspaceHookInvocation(workspace),
273
277
  payload: {
@@ -286,7 +290,7 @@ function registerCodexProcessTools(options) {
286
290
  rows,
287
291
  yieldTimeMs,
288
292
  maxOutputTokens,
289
- signal: extra.signal,
293
+ signal: requestContext.signal,
290
294
  });
291
295
  logProcessToolCall(config, workspace, {
292
296
  tool: "write_stdin",
@@ -0,0 +1,18 @@
1
+ export function mcpHandlerRequestContext(extra) {
2
+ if ("mcpReq" in extra) {
3
+ return {
4
+ requestMeta: extra.mcpReq._meta,
5
+ signal: extra.mcpReq.signal,
6
+ transportSessionId: extra.sessionId,
7
+ requestId: extra.mcpReq.id,
8
+ protocolEra: extra.mcpReq.envelope === undefined ? "legacy" : "modern",
9
+ };
10
+ }
11
+ return {
12
+ requestMeta: extra._meta,
13
+ signal: extra.signal,
14
+ transportSessionId: extra.sessionId,
15
+ requestId: extra.requestId,
16
+ protocolEra: "legacy",
17
+ };
18
+ }
@@ -1,3 +1,4 @@
1
+ export const FORGERELAY_CONVERSATION_META_KEY = "dev.forgerelay/conversation";
1
2
  function metadataString(meta, key) {
2
3
  if (typeof meta !== "object" || meta === null)
3
4
  return undefined;
@@ -7,7 +8,31 @@ function metadataString(meta, key) {
7
8
  export function openAiConversationScopeId(meta) {
8
9
  return metadataString(meta, "openai/session");
9
10
  }
10
- export function hostConversationScopeId(meta, transportSessionId, connectionScopeId) {
11
- return openAiConversationScopeId(meta)
12
- ?? (transportSessionId ? `mcp-session:${transportSessionId}` : connectionScopeId);
11
+ export function forgeRelayConversationScopeId(meta) {
12
+ const value = metadataString(meta, FORGERELAY_CONVERSATION_META_KEY);
13
+ return value ? `forgerelay-conversation:${value}` : undefined;
14
+ }
15
+ function stableHostConversationScopeId(context) {
16
+ return openAiConversationScopeId(context.requestMeta)
17
+ ?? forgeRelayConversationScopeId(context.requestMeta);
18
+ }
19
+ function modernRequestScopeId(context, connectionScopeId) {
20
+ return `mcp-request:${connectionScopeId}:${String(context.requestId ?? "unknown")}`;
21
+ }
22
+ export function hostConversationScopeId(context, connectionScopeId) {
23
+ const stableScope = stableHostConversationScopeId(context);
24
+ if (stableScope)
25
+ return stableScope;
26
+ if (context.protocolEra === "modern")
27
+ return modernRequestScopeId(context, connectionScopeId);
28
+ const transportSessionId = context.transportSessionId ?? context.sessionId;
29
+ return transportSessionId ? `mcp-session:${transportSessionId}` : connectionScopeId;
30
+ }
31
+ export function hostContextDeliveryScopeId(context, connectionScopeId) {
32
+ const stableScope = stableHostConversationScopeId(context);
33
+ if (stableScope)
34
+ return stableScope;
35
+ return context.protocolEra === "modern"
36
+ ? modernRequestScopeId(context, connectionScopeId)
37
+ : undefined;
13
38
  }
@@ -65,6 +65,6 @@ export function runActivityTool(lifecycle, workspace, conversationScopeId, tool,
65
65
  ...relation,
66
66
  });
67
67
  }
68
- export function runActivityToolWithHooks(lifecycle, hooks, workspace, conversationScopeId, request, hookOptions, relation = {}) {
69
- return runActivityTool(lifecycle, workspace, conversationScopeId, hookOptions.tool, request, () => runToolWithHooks(hooks, hookOptions), standardActivityOutcome, relation);
68
+ export function runActivityToolWithHooks(lifecycle, hooks, workspace, conversationScopeId, request, hookOptions, relation = {}, auditResult) {
69
+ return runActivityTool(lifecycle, workspace, conversationScopeId, hookOptions.tool, request, () => runToolWithHooks(hooks, hookOptions), standardActivityOutcome, relation, auditResult);
70
70
  }
@@ -0,0 +1,31 @@
1
+ import { z } from "zod";
2
+ const externalMcpInputSchema = z.discriminatedUnion("operation", [
3
+ z.object({ operation: z.literal("servers") }).strict(),
4
+ z.object({
5
+ operation: z.literal("tools"),
6
+ server: z.string().min(1),
7
+ }).strict(),
8
+ z.object({
9
+ operation: z.literal("call"),
10
+ server: z.string().min(1),
11
+ tool: z.string().min(1),
12
+ arguments: z.record(z.string(), z.unknown()).optional(),
13
+ }).strict(),
14
+ ]);
15
+ export function externalMcpCapabilityDefinitions(dependency) {
16
+ if (!dependency)
17
+ return [];
18
+ return [{
19
+ name: "mcp.external",
20
+ description: "Discover and call tools from user-configured external MCP servers through the ForgeRelay Capability gateway.",
21
+ guideName: "external-mcp",
22
+ readGuideBeforeFirstUse: true,
23
+ batchPolicy: "unsupported",
24
+ inputSchema: externalMcpInputSchema,
25
+ availability: () => ({
26
+ available: dependency.available,
27
+ reason: dependency.unavailableReason,
28
+ }),
29
+ run: async (input, context, options) => dependency.run(input, context, options),
30
+ }];
31
+ }