@akira-tl/forgerelay 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -2
  3. package/dist/activity/runtime/mcp-query-tools.js +16 -10
  4. package/dist/cli/mcp/external-mcp.js +481 -0
  5. package/dist/cli/mcp/status.js +295 -0
  6. package/dist/cli.js +14 -1
  7. package/dist/lsp/test-support/server-fixture.js +1 -2
  8. package/dist/mcp/oauth/auth-protocol.js +375 -0
  9. package/dist/mcp/oauth/oauth-provider.js +11 -12
  10. package/dist/mcp/oauth/oauth-store.js +2 -2
  11. package/dist/mcp/oauth/router.js +2 -6
  12. package/dist/mcp/operations/batch/executor.js +1 -1
  13. package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +387 -0
  14. package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +23 -6
  15. package/dist/mcp/operations/external-mcp/external-mcp.js +178 -23
  16. package/dist/mcp/operations/native-bulk-mutations.js +1 -1
  17. package/dist/mcp/process/tools.js +19 -15
  18. package/dist/mcp/request-context.js +18 -0
  19. package/dist/mcp/request-meta.js +28 -3
  20. package/dist/mcp/server/core/capabilities.js +1 -4
  21. package/dist/mcp/server/operations/runtime/filesystem-tools.js +35 -28
  22. package/dist/mcp/server/operations/runtime/operation-runtime.js +7 -7
  23. package/dist/mcp/server/transport/http-server.js +41 -20
  24. package/dist/mcp/server/workspace/runtime/workspace-open.js +13 -9
  25. package/dist/mcp/server/workspace/runtime/workspace-tools.js +29 -25
  26. package/dist/runtime/config/external-mcp-auth-store.js +225 -0
  27. package/dist/runtime/config/external-mcp-config.js +76 -22
  28. package/dist/runtime/config/external-mcp-registry.js +165 -0
  29. package/dist/runtime/testing/server-fixture.js +4 -4
  30. package/dist/server.js +15 -9
  31. package/dist/ui/.vite/manifest.json +297 -298
  32. package/dist/ui/activity-panel-app.html +1 -1
  33. package/dist/ui/assets/activity-panel-app-Bw7ZFX2g.js +114 -0
  34. package/dist/ui/assets/angular-html-DeUNP12X.js +1 -0
  35. package/dist/ui/assets/{angular-ts-BearSZDb.js → angular-ts-B3v8FKi_.js} +1 -1
  36. package/dist/ui/assets/{apl-CYQtr-y6.js → apl-Bs3iQm4P.js} +1 -1
  37. package/dist/ui/assets/{astro-DDzqKIjK.js → astro-D6GHecBY.js} +1 -1
  38. package/dist/ui/assets/{blade-DkwEyHvQ.js → blade-BMNbVOHv.js} +1 -1
  39. package/dist/ui/assets/c-BquuA6bv.js +1 -0
  40. package/dist/ui/assets/{cobol-xBn5JxHE.js → cobol-BuZ6kfps.js} +1 -1
  41. package/dist/ui/assets/{coffee-BaXXvoJw.js → coffee-DgC5uBzL.js} +1 -1
  42. package/dist/ui/assets/cpp-BdvWqCaB.js +1 -0
  43. package/dist/ui/assets/{crystal-DjOpXVU1.js → crystal-0Q5qBvNI.js} +1 -1
  44. package/dist/ui/assets/css-Be_T8idh.js +1 -0
  45. package/dist/ui/assets/{edge-DNaRizoR.js → edge-DaGLdgh_.js} +1 -1
  46. package/dist/ui/assets/{elixir-CwBMwZmy.js → elixir-CMgiFIzB.js} +1 -1
  47. package/dist/ui/assets/{elm-gOUD2CW3.js → elm-B-ufMlMC.js} +1 -1
  48. package/dist/ui/assets/{erb-BcR-fjUp.js → erb-CaGiOZM-.js} +1 -1
  49. package/dist/ui/assets/{git-rebase-BAQPKHF6.js → git-rebase-CpwUWCzK.js} +1 -1
  50. package/dist/ui/assets/{glimmer-js-P_wOfSz_.js → glimmer-js-BRxccvnE.js} +1 -1
  51. package/dist/ui/assets/{glimmer-ts-B44aY1fZ.js → glimmer-ts-CgQbPzlT.js} +1 -1
  52. package/dist/ui/assets/glsl-D7JmHFr0.js +1 -0
  53. package/dist/ui/assets/graphql-C8n0eUBK.js +1 -0
  54. package/dist/ui/assets/{hack-ddU2k0nt.js → hack-lVN7KVQv.js} +1 -1
  55. package/dist/ui/assets/haml-h18lG0pS.js +1 -0
  56. package/dist/ui/assets/{handlebars-BpzaUpmz.js → handlebars-lRFjRPfj.js} +1 -1
  57. package/dist/ui/assets/heavy-payload-Cqx9MOkH.js +290 -0
  58. package/dist/ui/assets/html-DtILqTE1.js +1 -0
  59. package/dist/ui/assets/{html-derivative-B2beVdmA.js → html-derivative-CSTSmPup.js} +1 -1
  60. package/dist/ui/assets/{http-iEskZy8U.js → http-DNI8fsbY.js} +1 -1
  61. package/dist/ui/assets/{hurl-BuUsLSKv.js → hurl-DgRi60JG.js} +1 -1
  62. package/dist/ui/assets/java-CW9pZ5bh.js +1 -0
  63. package/dist/ui/assets/javascript-Cvg2UrWL.js +1 -0
  64. package/dist/ui/assets/{jinja-CRpkWwrH.js → jinja-CZw_S1FB.js} +1 -1
  65. package/dist/ui/assets/{jison-BYfSnuHj.js → jison-C56u_FZv.js} +1 -1
  66. package/dist/ui/assets/json-DW2UTv1q.js +1 -0
  67. package/dist/ui/assets/jsx-Zj5gobdD.js +1 -0
  68. package/dist/ui/assets/{julia-B8Rxy7vD.js → julia-Cvcrre-L.js} +1 -1
  69. package/dist/ui/assets/{just-DzhIWpFP.js → just-8CZ2u6Tj.js} +1 -1
  70. package/dist/ui/assets/{latex-CUNjUUL-.js → latex-D289AXWG.js} +1 -1
  71. package/dist/ui/assets/{liquid-hUg7nC61.js → liquid-CFEX3SkZ.js} +1 -1
  72. package/dist/ui/assets/lua-BKpJ0ybO.js +1 -0
  73. package/dist/ui/assets/{marko-CQmIkwMF.js → marko-D9maZ0a6.js} +1 -1
  74. package/dist/ui/assets/{mdc-CB-6dHYa.js → mdc-rM9l0qWR.js} +1 -1
  75. package/dist/ui/assets/{nginx-CQORo_Bt.js → nginx-C9w6p2C9.js} +1 -1
  76. package/dist/ui/assets/{nim-BNwBNzdn.js → nim-BRePHk0J.js} +1 -1
  77. package/dist/ui/assets/{perl-Cnz14fAJ.js → perl-CskTmiPQ.js} +1 -1
  78. package/dist/ui/assets/{php-YywDo63b.js → php-DCU_FvNI.js} +1 -1
  79. package/dist/ui/assets/{pug-BwbJp4cM.js → pug-XLaVxTvv.js} +1 -1
  80. package/dist/ui/assets/{qml-D2DY6dMH.js → qml-CaONKSl-.js} +1 -1
  81. package/dist/ui/assets/r-BCVR0ZE3.js +1 -0
  82. package/dist/ui/assets/{razor-pMTG7k41.js → razor-DDnGXxnU.js} +1 -1
  83. package/dist/ui/assets/regexp-DWGTYYVx.js +1 -0
  84. package/dist/ui/assets/{rst-CO_ndDm4.js → rst-Dz3GWvQm.js} +1 -1
  85. package/dist/ui/assets/{ruby-xzRn9gTh.js → ruby-DC3jGtMY.js} +1 -1
  86. package/dist/ui/assets/{sas-CBdtFKLA.js → sas-7mNpvsjZ.js} +1 -1
  87. package/dist/ui/assets/scss-uBsoCJVy.js +1 -0
  88. package/dist/ui/assets/shellscript-BCN9N_Jf.js +1 -0
  89. package/dist/ui/assets/{shellsession-BaZnm5rZ.js → shellsession-Hsw46VXV.js} +1 -1
  90. package/dist/ui/assets/{soy-IouG3xdB.js → soy-D6KV69HU.js} +1 -1
  91. package/dist/ui/assets/sql-CVzVQkJh.js +1 -0
  92. package/dist/ui/assets/{stata-dgbkIZHP.js → stata-BwUjXNhI.js} +1 -1
  93. package/dist/ui/assets/{surrealql-CBROnw_p.js → surrealql-CgloWqRa.js} +1 -1
  94. package/dist/ui/assets/{svelte-6r6BpX4-.js → svelte-DcC3n6IC.js} +1 -1
  95. package/dist/ui/assets/{templ-BKD_PsbL.js → templ-DOFfnWTJ.js} +1 -1
  96. package/dist/ui/assets/{tex-CKY1bwHb.js → tex-ln89VPaX.js} +1 -1
  97. package/dist/ui/assets/{ts-tags-BiYP5N_C.js → ts-tags-CWMzCstH.js} +1 -1
  98. package/dist/ui/assets/tsx-DgzfiGeK.js +1 -0
  99. package/dist/ui/assets/{twig-BTe1oPFW.js → twig-Bi2itZL9.js} +1 -1
  100. package/dist/ui/assets/typescript-CQ24f4zW.js +1 -0
  101. package/dist/ui/assets/{vue-CT0IiySh.js → vue-BS8jE5u6.js} +1 -1
  102. package/dist/ui/assets/{vue-html-B-tXcbat.js → vue-html-o38xqhyB.js} +1 -1
  103. package/dist/ui/assets/{vue-vine-C5ZyDCrs.js → vue-vine-D2NvKv50.js} +1 -1
  104. package/dist/ui/assets/xml-_44rx9_2.js +1 -0
  105. package/dist/ui/assets/{xsl-DvDSndNI.js → xsl-B5QWQPoi.js} +1 -1
  106. package/dist/ui/assets/yaml-CKL2pEWe.js +1 -0
  107. package/dist/workspaces/relay/auth/remote-auth.js +3 -5
  108. package/dist/workspaces/relay/tests/test-support.js +7 -4
  109. package/dist/workspaces/relay/workspace-relay.js +14 -8
  110. package/docs/configuration.md +154 -4
  111. package/docs/debugging.md +8 -5
  112. package/docs/roadmap.md +64 -3
  113. package/docs/security.md +18 -0
  114. package/package.json +11 -4
  115. package/scripts/debug/accept/bootstrap.mjs +2 -0
  116. package/scripts/debug/accept/harness.mjs +9 -2
  117. package/scripts/debug/accept/modern-http.mjs +169 -0
  118. package/scripts/debug/accept/support.mjs +10 -6
  119. package/scripts/debug/accept.mjs +1 -0
  120. package/scripts/debug/runtime.mjs +4 -1
  121. package/scripts/debug/runtime.test.mjs +9 -0
  122. package/scripts/release/parity-sandbox.mjs +35 -0
  123. package/scripts/release/release-gate.test.mjs +42 -1
  124. package/scripts/release-parity.mjs +3 -22
  125. package/scripts/release-proof.mjs +24 -4
  126. package/scripts/release-proof.test.mjs +12 -4
  127. package/dist/ui/assets/activity-panel-app-XlqSanXT.js +0 -115
  128. package/dist/ui/assets/angular-html-tB9EchbM.js +0 -1
  129. package/dist/ui/assets/c-Dv-N5hjn.js +0 -1
  130. package/dist/ui/assets/cpp-C3unXX-3.js +0 -1
  131. package/dist/ui/assets/css-8Xr4kNNH.js +0 -1
  132. package/dist/ui/assets/glsl-BvCQg9MI.js +0 -1
  133. package/dist/ui/assets/graphql-C9S90gn_.js +0 -1
  134. package/dist/ui/assets/haml-Bb1w9rMG.js +0 -1
  135. package/dist/ui/assets/heavy-payload-CJKqi8Ac.js +0 -290
  136. package/dist/ui/assets/html-dUOs5pA9.js +0 -1
  137. package/dist/ui/assets/java-BFZCrORY.js +0 -1
  138. package/dist/ui/assets/javascript-DaIQ0Zqj.js +0 -1
  139. package/dist/ui/assets/json-BQC_Yp48.js +0 -1
  140. package/dist/ui/assets/jsx-Dd-CJRf9.js +0 -1
  141. package/dist/ui/assets/lua-BoZTiL2I.js +0 -1
  142. package/dist/ui/assets/r-B5nYsy-M.js +0 -1
  143. package/dist/ui/assets/regexp-oP0lbks_.js +0 -1
  144. package/dist/ui/assets/scss-CEqgKQNf.js +0 -1
  145. package/dist/ui/assets/shellscript-DzJJiWQC.js +0 -1
  146. package/dist/ui/assets/sql-DC2rdTzV.js +0 -1
  147. package/dist/ui/assets/tsx-D2OMiPNF.js +0 -1
  148. package/dist/ui/assets/typescript-BGIABksB.js +0 -1
  149. package/dist/ui/assets/xml-CfeBNlMP.js +0 -1
  150. package/dist/ui/assets/yaml-umbj2r1g.js +0 -1
@@ -1,45 +1,48 @@
1
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
- import { StdioClientTransport, getDefaultEnvironment, } from "@modelcontextprotocol/sdk/client/stdio.js";
3
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
- import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
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";
5
6
  import { claimMediaBytes, createMediaBudget, isSupportedImageMimeType, strictBase64ByteLength, } from "../media-content.js";
6
7
  import { ExternalMcpTransformError, } from "../../hooks/external-mcp-transform.js";
8
+ import { ExternalMcpOAuthError, createExternalMcpRuntimeAuth, externalMcpAuthError, markExternalMcpAuthorizationRequired, markExternalMcpReauthorization, } from "./external-mcp-oauth.js";
7
9
  const MAX_DISCOVERED_TOOLS = 100;
8
10
  const MAX_TOOL_DESCRIPTION_CHARS = 2_000;
9
11
  const MAX_TOOL_SCHEMA_BYTES = 64 * 1024;
10
12
  const MAX_TOOL_DISCOVERY_PAGES = 16;
13
+ const LEGACY_NEGOTIATION_TTL_MS = 5 * 60 * 1_000;
11
14
  export class ExternalMcpError extends Error {
12
15
  code;
13
- constructor(code, message) {
16
+ detail;
17
+ constructor(code, message, detail) {
14
18
  super(message);
15
19
  this.code = code;
20
+ this.detail = detail;
16
21
  this.name = "ExternalMcpError";
17
22
  }
18
23
  }
19
24
  export class ExternalMcpGateway {
20
- servers;
21
25
  mediaMaxBytes;
22
- constructor(servers, mediaMaxBytes) {
23
- this.servers = servers;
26
+ credentialStore;
27
+ negotiationCache = new Map();
28
+ constructor(mediaMaxBytes, credentialStore) {
24
29
  this.mediaMaxBytes = mediaMaxBytes;
30
+ this.credentialStore = credentialStore;
25
31
  }
26
- get available() {
27
- return Object.keys(this.servers).length > 0;
28
- }
29
- async run(input, signal, transforms) {
32
+ async run(servers, input, signal, transforms, authContext) {
30
33
  signal?.throwIfAborted();
31
34
  switch (input.operation) {
32
35
  case "servers":
33
36
  return {
34
37
  value: {
35
38
  operation: "servers",
36
- servers: Object.entries(this.servers)
39
+ servers: Object.entries(servers)
37
40
  .sort(([left], [right]) => left.localeCompare(right))
38
41
  .map(([name, server]) => ({ name, transport: server.transport })),
39
42
  },
40
43
  };
41
44
  case "tools":
42
- return this.withClient(input.server, signal, async (client) => {
45
+ return this.withClient(servers, input.server, signal, authContext, async (client) => {
43
46
  const discovery = await discoverTools(client, signal);
44
47
  return {
45
48
  value: {
@@ -51,7 +54,7 @@ export class ExternalMcpGateway {
51
54
  };
52
55
  });
53
56
  case "call":
54
- return this.withClient(input.server, signal, async (client) => {
57
+ return this.withClient(servers, input.server, signal, authContext, async (client) => {
55
58
  await assertRegisteredTool(client, input.server, input.tool, signal);
56
59
  const appliedTransforms = [];
57
60
  let callArguments = input.arguments ?? {};
@@ -60,7 +63,7 @@ export class ExternalMcpGateway {
60
63
  callArguments = transformed.value;
61
64
  appliedTransforms.push(...transformed.transforms);
62
65
  }
63
- let result = await client.callTool({ name: input.tool, arguments: callArguments }, undefined, { signal });
66
+ let result = await client.callTool({ name: input.tool, arguments: callArguments }, { signal });
64
67
  assertCallToolResult(input.server, input.tool, result);
65
68
  if (result.isError) {
66
69
  throw new ExternalMcpError("tool_failed", `External MCP ${input.server} tool ${input.tool} returned an upstream tool error.`);
@@ -91,31 +94,146 @@ export class ExternalMcpGateway {
91
94
  });
92
95
  }
93
96
  }
94
- async withClient(name, signal, operation) {
95
- const config = this.servers[name];
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];
96
120
  if (!config)
97
121
  throw new ExternalMcpError("unknown_server", `Unknown configured external MCP server: ${name}.`);
98
- const client = new Client({ name: "forgerelay-external-mcp", version: "1.0.0" });
99
- const transport = createTransport(config);
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);
100
127
  try {
101
128
  signal?.throwIfAborted();
102
- await client.connect(transport);
129
+ await client.connect(transport, prior ? { prior } : undefined);
130
+ this.rememberNegotiation(name, fingerprint, client);
103
131
  signal?.throwIfAborted();
104
132
  return await operation(client);
105
133
  }
106
134
  catch (error) {
107
135
  if (error instanceof ExternalMcpError || error instanceof ExternalMcpTransformError)
108
136
  throw error;
137
+ this.invalidateNegotiation(name, fingerprint);
109
138
  if (signal?.aborted)
110
139
  signal.throwIfAborted();
111
- throw new ExternalMcpError("transport_failed", `External MCP ${name} request failed.`);
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));
112
159
  }
113
160
  finally {
114
161
  await client.close().catch(() => undefined);
115
162
  }
116
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)));
117
235
  }
118
- function createTransport(config) {
236
+ function createTransport(config, authProvider) {
119
237
  if (config.transport === "stdio") {
120
238
  return new StdioClientTransport({
121
239
  command: config.command,
@@ -129,8 +247,45 @@ function createTransport(config) {
129
247
  }
130
248
  return new StreamableHTTPClientTransport(new URL(config.url), {
131
249
  ...(config.headers ? { requestInit: { headers: config.headers } } : {}),
250
+ ...(authProvider ? { authProvider } : {}),
251
+ onInsufficientScope: "throw",
132
252
  });
133
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
+ }
134
289
  async function discoverTools(client, signal) {
135
290
  const tools = [];
136
291
  let cursor;
@@ -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
  }
@@ -45,7 +45,6 @@ const CAPABILITY_GUIDE_DEFINITIONS = [
45
45
  directory: "host-integration/external-mcp",
46
46
  description: "User-configured external MCP tool discovery and explicit forwarding through capability.",
47
47
  whenToRead: "Read before discovering or calling a configured external MCP server.",
48
- enabled: (config) => Object.keys(config.mcpServers).length > 0,
49
48
  },
50
49
  {
51
50
  name: "workspace-tasks",
@@ -117,9 +116,7 @@ export function buildCapabilityFingerprint(config, version, context = {}) {
117
116
  if (config.subagents) {
118
117
  capabilities.push("subagent.session");
119
118
  }
120
- if (Object.keys(config.mcpServers).length > 0) {
121
- capabilities.push("mcp.external");
122
- }
119
+ capabilities.push("mcp.external");
123
120
  if (config.artifactsEnabled && context.artifactDownloadSupported) {
124
121
  capabilities.push("artifact.native-download");
125
122
  }