@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
@@ -0,0 +1,225 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { withFileLock } from "../state/lock/file-lock.js";
5
+ import { forgerelayConfigDir } from "./user-config.js";
6
+ const AUTH_FILE_VERSION = 1;
7
+ const AUTH_FILE_NAME = "mcp-auth.json";
8
+ export class ExternalMcpCredentialStore {
9
+ configDir;
10
+ filePath;
11
+ constructor(options = {}) {
12
+ this.configDir = resolve(options.configDir ?? forgerelayConfigDir(options.env));
13
+ this.filePath = join(this.configDir, AUTH_FILE_NAME);
14
+ }
15
+ read(identity) {
16
+ const record = this.readFile().credentials[credentialKey(identity)];
17
+ if (!record || !sameIdentity(record.identity, identity))
18
+ return undefined;
19
+ return cloneRecord(record);
20
+ }
21
+ inspect() {
22
+ const exists = existsSync(this.filePath);
23
+ const file = this.readFile();
24
+ return { exists, credentialCount: Object.keys(file.credentials).length };
25
+ }
26
+ async replace(identity, serverUrl, record) {
27
+ let written;
28
+ await this.updateFile((file) => {
29
+ written = {
30
+ ...cloneRecord(record),
31
+ identity: normalizeIdentity(identity),
32
+ serverUrl,
33
+ revision: randomUUID(),
34
+ updatedAt: new Date().toISOString(),
35
+ };
36
+ file.credentials[credentialKey(identity)] = written;
37
+ });
38
+ return cloneRecord(written);
39
+ }
40
+ async update(identity, serverUrl, update) {
41
+ let written;
42
+ await this.updateFile((file) => {
43
+ const key = credentialKey(identity);
44
+ const existing = file.credentials[key];
45
+ const current = existing && sameIdentity(existing.identity, identity) ? cloneRecord(existing) : undefined;
46
+ const next = update(current);
47
+ if (!next) {
48
+ delete file.credentials[key];
49
+ written = undefined;
50
+ return;
51
+ }
52
+ written = {
53
+ ...cloneRecord(next),
54
+ identity: normalizeIdentity(identity),
55
+ serverUrl,
56
+ revision: randomUUID(),
57
+ updatedAt: new Date().toISOString(),
58
+ };
59
+ file.credentials[key] = written;
60
+ });
61
+ return written ? cloneRecord(written) : undefined;
62
+ }
63
+ async remove(identity) {
64
+ let removed = false;
65
+ await this.updateFile((file) => {
66
+ const key = credentialKey(identity);
67
+ const existing = file.credentials[key];
68
+ if (!existing || !sameIdentity(existing.identity, identity))
69
+ return;
70
+ delete file.credentials[key];
71
+ removed = true;
72
+ });
73
+ return removed;
74
+ }
75
+ withIdentityLock(identity, operation) {
76
+ mkdirSync(this.configDir, { recursive: true });
77
+ return withFileLock(join(this.configDir, `.mcp-auth-${credentialKeyHash(identity)}.lock`), operation, {
78
+ timeoutMs: 15_000,
79
+ staleMs: 60_000,
80
+ mode: 0o600,
81
+ });
82
+ }
83
+ readFile() {
84
+ if (!existsSync(this.filePath))
85
+ return emptyAuthFile();
86
+ let raw;
87
+ try {
88
+ raw = JSON.parse(readFileSync(this.filePath, "utf8"));
89
+ }
90
+ catch {
91
+ throw new Error(`Unable to read External MCP credential store ${this.filePath}: invalid JSON.`);
92
+ }
93
+ return parseAuthFile(raw, this.filePath);
94
+ }
95
+ async updateFile(update) {
96
+ mkdirSync(this.configDir, { recursive: true });
97
+ await withFileLock(`${this.filePath}.lock`, () => {
98
+ const file = this.readFile();
99
+ update(file);
100
+ writePrivateJsonFile(this.filePath, file);
101
+ }, { mode: 0o600 });
102
+ }
103
+ }
104
+ export function externalMcpCredentialIdentity(source, server, projectRoot) {
105
+ return source === "project"
106
+ ? { kind: "project", server, projectRoot: resolve(projectRoot) }
107
+ : { kind: "global", server };
108
+ }
109
+ export function credentialKey(identity) {
110
+ const normalized = normalizeIdentity(identity);
111
+ return normalized.kind === "global"
112
+ ? `global:${normalized.server}`
113
+ : `project:${credentialKeyHash(normalized)}:${normalized.server}`;
114
+ }
115
+ function credentialKeyHash(identity) {
116
+ const normalized = normalizeIdentity(identity);
117
+ const material = normalized.kind === "global"
118
+ ? `global\0${normalized.server}`
119
+ : `project\0${normalized.projectRoot}\0${normalized.server}`;
120
+ return createHash("sha256").update(material).digest("hex");
121
+ }
122
+ function normalizeIdentity(identity) {
123
+ return identity.kind === "project"
124
+ ? { kind: "project", server: identity.server, projectRoot: resolve(identity.projectRoot) }
125
+ : { kind: "global", server: identity.server };
126
+ }
127
+ function sameIdentity(left, right) {
128
+ const a = normalizeIdentity(left);
129
+ const b = normalizeIdentity(right);
130
+ return a.kind === b.kind
131
+ && a.server === b.server
132
+ && (a.kind === "global" || (b.kind === "project" && a.projectRoot === b.projectRoot));
133
+ }
134
+ function emptyAuthFile() {
135
+ return { version: AUTH_FILE_VERSION, credentials: {} };
136
+ }
137
+ function parseAuthFile(value, filePath) {
138
+ if (!isRecord(value) || value.version !== AUTH_FILE_VERSION || !isRecord(value.credentials)) {
139
+ throw new Error(`Unable to read External MCP credential store ${filePath}: unsupported format.`);
140
+ }
141
+ const credentials = {};
142
+ for (const [key, raw] of Object.entries(value.credentials)) {
143
+ credentials[key] = parseCredentialRecord(raw, filePath);
144
+ }
145
+ return { version: AUTH_FILE_VERSION, credentials };
146
+ }
147
+ function parseCredentialRecord(value, filePath) {
148
+ if (!isRecord(value) || !isCredentialIdentity(value.identity)) {
149
+ throw new Error(`Unable to read External MCP credential store ${filePath}: invalid credential identity.`);
150
+ }
151
+ if (typeof value.serverUrl !== "string"
152
+ || typeof value.revision !== "string"
153
+ || typeof value.updatedAt !== "string") {
154
+ throw new Error(`Unable to read External MCP credential store ${filePath}: invalid credential metadata.`);
155
+ }
156
+ if (value.tokens !== undefined
157
+ && (!isRecord(value.tokens) || typeof value.tokens.access_token !== "string")) {
158
+ throw new Error(`Unable to read External MCP credential store ${filePath}: invalid token record.`);
159
+ }
160
+ if (value.clientInformation !== undefined
161
+ && (!isRecord(value.clientInformation) || typeof value.clientInformation.client_id !== "string")) {
162
+ throw new Error(`Unable to read External MCP credential store ${filePath}: invalid client record.`);
163
+ }
164
+ if (value.discoveryState !== undefined
165
+ && (!isRecord(value.discoveryState) || typeof value.discoveryState.authorizationServerUrl !== "string")) {
166
+ throw new Error(`Unable to read External MCP credential store ${filePath}: invalid discovery record.`);
167
+ }
168
+ const reauthorization = parseReauthorization(value.reauthorization, filePath);
169
+ return {
170
+ identity: normalizeIdentity(value.identity),
171
+ serverUrl: value.serverUrl,
172
+ revision: value.revision,
173
+ updatedAt: value.updatedAt,
174
+ ...(value.tokens ? { tokens: structuredClone(value.tokens) } : {}),
175
+ ...(value.clientInformation
176
+ ? { clientInformation: structuredClone(value.clientInformation) }
177
+ : {}),
178
+ ...(value.discoveryState
179
+ ? { discoveryState: structuredClone(value.discoveryState) }
180
+ : {}),
181
+ ...(typeof value.authorizationServerUrl === "string"
182
+ ? { authorizationServerUrl: value.authorizationServerUrl }
183
+ : {}),
184
+ ...(typeof value.resourceUrl === "string" ? { resourceUrl: value.resourceUrl } : {}),
185
+ ...(reauthorization ? { reauthorization } : {}),
186
+ };
187
+ }
188
+ function parseReauthorization(value, filePath) {
189
+ if (value === undefined)
190
+ return undefined;
191
+ if (!isRecord(value)
192
+ || !["authorization_required", "invalid_grant", "insufficient_scope", "binding_changed"].includes(String(value.reason))
193
+ || typeof value.observedAt !== "string"
194
+ || (value.scope !== undefined && typeof value.scope !== "string")) {
195
+ throw new Error(`Unable to read External MCP credential store ${filePath}: invalid reauthorization state.`);
196
+ }
197
+ return {
198
+ reason: value.reason,
199
+ observedAt: value.observedAt,
200
+ ...(typeof value.scope === "string" && value.scope.trim() ? { scope: value.scope.trim() } : {}),
201
+ };
202
+ }
203
+ function isCredentialIdentity(value) {
204
+ if (!isRecord(value) || typeof value.server !== "string" || !value.server)
205
+ return false;
206
+ if (value.kind === "global")
207
+ return true;
208
+ return value.kind === "project" && typeof value.projectRoot === "string" && Boolean(value.projectRoot);
209
+ }
210
+ function cloneRecord(value) {
211
+ return structuredClone(value);
212
+ }
213
+ function writePrivateJsonFile(filePath, value) {
214
+ const temporary = `${filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
215
+ try {
216
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
217
+ renameSync(temporary, filePath);
218
+ }
219
+ finally {
220
+ rmSync(temporary, { force: true });
221
+ }
222
+ }
223
+ function isRecord(value) {
224
+ return typeof value === "object" && value !== null && !Array.isArray(value);
225
+ }
@@ -1,33 +1,54 @@
1
1
  const MCP_SERVER_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
2
2
  const MAX_MCP_SERVERS = 32;
3
3
  export function parseExternalMcpServers(value) {
4
- if (value === undefined)
4
+ return parseServerRegistry(value, "mcpServers", false);
5
+ }
6
+ export function parseExternalMcpStandaloneConfig(value) {
7
+ if (!isRecord(value))
8
+ throw new Error("External MCP configuration must be a JSON object.");
9
+ rejectUnknownKeys(value, new Set(["servers"]), "External MCP configuration");
10
+ if (!("servers" in value))
11
+ throw new Error("External MCP configuration must contain a servers object.");
12
+ return parseServerRegistry(value.servers, "servers", true);
13
+ }
14
+ function parseServerRegistry(value, label, allowDisabled) {
15
+ if (value === undefined && !allowDisabled)
5
16
  return {};
6
17
  if (!isRecord(value))
7
- throw new Error("mcpServers must be an object keyed by server name.");
18
+ throw new Error(`${label} must be an object keyed by server name.`);
8
19
  const entries = Object.entries(value);
9
20
  if (entries.length > MAX_MCP_SERVERS) {
10
- throw new Error(`mcpServers may contain at most ${MAX_MCP_SERVERS} configured servers.`);
21
+ throw new Error(`${label} may contain at most ${MAX_MCP_SERVERS} configured servers.`);
11
22
  }
12
23
  return Object.fromEntries(entries.map(([name, raw]) => {
13
24
  if (!MCP_SERVER_NAME_PATTERN.test(name)) {
14
- throw new Error(`Invalid mcpServers name '${name}'. Use a lowercase stable name.`);
25
+ throw new Error(`Invalid ${label} name '${name}'. Use a lowercase stable name.`);
15
26
  }
16
27
  if (!isRecord(raw))
17
- throw new Error(`mcpServers.${name} must be an object.`);
18
- if (raw.transport === "stdio")
19
- return [name, parseStdio(name, raw)];
20
- if (raw.transport === "streamable-http")
21
- return [name, parseHttp(name, raw)];
22
- throw new Error(`mcpServers.${name}.transport must be 'stdio' or 'streamable-http'.`);
28
+ throw new Error(`${label}.${name} must be an object.`);
29
+ const disabled = allowDisabled && "disabled" in raw ? parseDisabled(raw.disabled, `${label}.${name}.disabled`) : false;
30
+ const serverValue = allowDisabled && "disabled" in raw
31
+ ? Object.fromEntries(Object.entries(raw).filter(([key]) => key !== "disabled"))
32
+ : raw;
33
+ if (disabled && Object.keys(serverValue).length === 0) {
34
+ return [name, { disabled: true }];
35
+ }
36
+ const server = serverValue.transport === "stdio"
37
+ ? parseStdio(name, serverValue, label)
38
+ : serverValue.transport === "streamable-http"
39
+ ? parseHttp(name, serverValue, label)
40
+ : undefined;
41
+ if (!server)
42
+ throw new Error(`${label}.${name}.transport must be 'stdio' or 'streamable-http'.`);
43
+ return [name, disabled ? { disabled: true } : server];
23
44
  }));
24
45
  }
25
- function parseStdio(name, value) {
26
- const command = requiredString(value.command, `mcpServers.${name}.command`);
27
- const args = optionalStringArray(value.args, `mcpServers.${name}.args`);
28
- const env = optionalStringRecord(value.env, `mcpServers.${name}.env`);
29
- const cwd = optionalString(value.cwd, `mcpServers.${name}.cwd`);
30
- rejectUnknownKeys(value, new Set(["transport", "command", "args", "env", "cwd"]), `mcpServers.${name}`);
46
+ function parseStdio(name, value, label) {
47
+ const command = requiredString(value.command, `${label}.${name}.command`);
48
+ const args = optionalStringArray(value.args, `${label}.${name}.args`);
49
+ const env = optionalStringRecord(value.env, `${label}.${name}.env`);
50
+ const cwd = optionalString(value.cwd, `${label}.${name}.cwd`);
51
+ rejectUnknownKeys(value, new Set(["transport", "command", "args", "env", "cwd"]), `${label}.${name}`);
31
52
  return {
32
53
  transport: "stdio",
33
54
  command,
@@ -36,26 +57,59 @@ function parseStdio(name, value) {
36
57
  ...(cwd ? { cwd } : {}),
37
58
  };
38
59
  }
39
- function parseHttp(name, value) {
40
- const url = requiredString(value.url, `mcpServers.${name}.url`);
60
+ function parseHttp(name, value, label) {
61
+ const url = requiredString(value.url, `${label}.${name}.url`);
41
62
  let parsed;
42
63
  try {
43
64
  parsed = new URL(url);
44
65
  }
45
66
  catch {
46
- throw new Error(`mcpServers.${name}.url must be a valid HTTP(S) URL.`);
67
+ throw new Error(`${label}.${name}.url must be a valid HTTP(S) URL.`);
47
68
  }
48
69
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
49
- throw new Error(`mcpServers.${name}.url must use http or https.`);
70
+ throw new Error(`${label}.${name}.url must use http or https.`);
50
71
  }
51
- const headers = optionalStringRecord(value.headers, `mcpServers.${name}.headers`);
52
- rejectUnknownKeys(value, new Set(["transport", "url", "headers"]), `mcpServers.${name}`);
72
+ const headers = optionalStringRecord(value.headers, `${label}.${name}.headers`);
73
+ const oauth = parseOAuthClientConfig(value.oauth, `${label}.${name}.oauth`);
74
+ rejectUnknownKeys(value, new Set(["transport", "url", "headers", "oauth"]), `${label}.${name}`);
53
75
  return {
54
76
  transport: "streamable-http",
55
77
  url: parsed.toString(),
56
78
  ...(headers ? { headers } : {}),
79
+ ...(oauth ? { oauth } : {}),
57
80
  };
58
81
  }
82
+ function parseOAuthClientConfig(value, label) {
83
+ if (value === undefined)
84
+ return undefined;
85
+ if (!isRecord(value))
86
+ throw new Error(`${label} must be an object.`);
87
+ rejectUnknownKeys(value, new Set(["clientMetadataUrl", "callbackPort"]), label);
88
+ const clientMetadataUrl = requiredString(value.clientMetadataUrl, `${label}.clientMetadataUrl`);
89
+ let parsed;
90
+ try {
91
+ parsed = new URL(clientMetadataUrl);
92
+ }
93
+ catch {
94
+ throw new Error(`${label}.clientMetadataUrl must be a valid HTTPS URL.`);
95
+ }
96
+ if (parsed.protocol !== "https:" || parsed.pathname === "/") {
97
+ throw new Error(`${label}.clientMetadataUrl must use https and contain a non-root path.`);
98
+ }
99
+ const callbackPort = value.callbackPort;
100
+ if (!Number.isInteger(callbackPort) || Number(callbackPort) < 1024 || Number(callbackPort) > 65535) {
101
+ throw new Error(`${label}.callbackPort must be an integer from 1024 to 65535.`);
102
+ }
103
+ return {
104
+ clientMetadataUrl: parsed.toString(),
105
+ callbackPort: Number(callbackPort),
106
+ };
107
+ }
108
+ function parseDisabled(value, label) {
109
+ if (typeof value !== "boolean")
110
+ throw new Error(`${label} must be a boolean.`);
111
+ return value;
112
+ }
59
113
  function requiredString(value, label) {
60
114
  if (typeof value !== "string" || !value.trim())
61
115
  throw new Error(`${label} must be a non-empty string.`);
@@ -0,0 +1,165 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { parseExternalMcpStandaloneConfig, } from "./external-mcp-config.js";
5
+ const MISSING_FINGERPRINT = "missing";
6
+ const MAX_DIAGNOSTIC_LENGTH = 320;
7
+ export class ExternalMcpConfigRegistry {
8
+ configDir;
9
+ legacyServers;
10
+ onDiagnostic;
11
+ global;
12
+ projects = new Map();
13
+ constructor(options) {
14
+ this.configDir = resolve(options.configDir);
15
+ this.legacyServers = { ...(options.legacyServers ?? {}) };
16
+ this.onDiagnostic = options.onDiagnostic;
17
+ }
18
+ resolve(workspaceRoot) {
19
+ const projectRoot = resolve(workspaceRoot);
20
+ const global = this.loadGlobalSource();
21
+ const projectPath = join(projectRoot, ".forgerelay", "mcp.json");
22
+ const project = this.loadDynamicSource("project", projectPath, this.projects.get(projectRoot));
23
+ this.projects.set(projectRoot, project);
24
+ return this.composeSnapshot(global, project);
25
+ }
26
+ resolveGlobal() {
27
+ return this.composeSnapshot(this.loadGlobalSource());
28
+ }
29
+ loadGlobalSource() {
30
+ const globalPath = join(this.configDir, "mcp.json");
31
+ this.global = this.loadDynamicSource("global", globalPath, this.global);
32
+ return this.global;
33
+ }
34
+ composeSnapshot(global, project) {
35
+ const servers = { ...this.legacyServers };
36
+ const origins = Object.fromEntries(Object.keys(this.legacyServers).map((name) => [name, "legacy"]));
37
+ const masked = {};
38
+ applySource(servers, origins, masked, global.entries, "global");
39
+ if (project)
40
+ applySource(servers, origins, masked, project.entries, "project");
41
+ const diagnostics = [global.diagnostic, project?.diagnostic]
42
+ .filter((entry) => entry !== undefined);
43
+ return {
44
+ servers,
45
+ origins,
46
+ masked,
47
+ sources: [
48
+ {
49
+ source: "legacy",
50
+ path: join(this.configDir, "config.json"),
51
+ state: "valid",
52
+ usingLastKnownGood: false,
53
+ },
54
+ global.status,
55
+ ...(project ? [project.status] : []),
56
+ ],
57
+ diagnostics,
58
+ };
59
+ }
60
+ loadDynamicSource(source, path, previous) {
61
+ let content;
62
+ try {
63
+ content = readFileSync(path, "utf8");
64
+ }
65
+ catch (error) {
66
+ if (isMissingFileError(error)) {
67
+ if (previous?.observedFingerprint === MISSING_FINGERPRINT)
68
+ return previous;
69
+ return {
70
+ observedFingerprint: MISSING_FINGERPRINT,
71
+ entries: {},
72
+ hasLastKnownGood: true,
73
+ status: {
74
+ source,
75
+ path,
76
+ state: "missing",
77
+ usingLastKnownGood: false,
78
+ },
79
+ };
80
+ }
81
+ const fingerprint = `read-error:${errorCode(error)}`;
82
+ if (previous?.observedFingerprint === fingerprint)
83
+ return previous;
84
+ return this.invalidSource(source, path, fingerprint, previous, error);
85
+ }
86
+ const fingerprint = createHash("sha256").update(content).digest("base64url");
87
+ if (previous?.observedFingerprint === fingerprint)
88
+ return previous;
89
+ let parsed;
90
+ try {
91
+ parsed = JSON.parse(content);
92
+ }
93
+ catch {
94
+ return this.invalidSource(source, path, fingerprint, previous, new Error("Invalid JSON syntax."));
95
+ }
96
+ try {
97
+ const entries = parseExternalMcpStandaloneConfig(parsed);
98
+ return {
99
+ observedFingerprint: fingerprint,
100
+ entries,
101
+ hasLastKnownGood: true,
102
+ status: {
103
+ source,
104
+ path,
105
+ state: "valid",
106
+ usingLastKnownGood: false,
107
+ },
108
+ };
109
+ }
110
+ catch (error) {
111
+ return this.invalidSource(source, path, fingerprint, previous, error);
112
+ }
113
+ }
114
+ invalidSource(source, path, observedFingerprint, previous, error) {
115
+ const diagnostic = {
116
+ source,
117
+ path,
118
+ message: boundedDiagnosticMessage(error),
119
+ };
120
+ this.onDiagnostic?.(diagnostic);
121
+ return {
122
+ observedFingerprint,
123
+ entries: previous?.entries ?? {},
124
+ hasLastKnownGood: previous?.hasLastKnownGood ?? false,
125
+ status: {
126
+ source,
127
+ path,
128
+ state: "invalid",
129
+ usingLastKnownGood: previous?.hasLastKnownGood === true,
130
+ },
131
+ diagnostic,
132
+ };
133
+ }
134
+ }
135
+ function applySource(servers, origins, masked, entries, source) {
136
+ for (const [name, entry] of Object.entries(entries)) {
137
+ if ("disabled" in entry && entry.disabled === true) {
138
+ delete servers[name];
139
+ delete origins[name];
140
+ masked[name] = source;
141
+ continue;
142
+ }
143
+ servers[name] = entry;
144
+ origins[name] = source;
145
+ delete masked[name];
146
+ }
147
+ }
148
+ function boundedDiagnosticMessage(error) {
149
+ const raw = error instanceof Error ? error.message : String(error);
150
+ const normalized = raw.replace(/\s+/g, " ").trim() || "Invalid External MCP configuration.";
151
+ return normalized.length <= MAX_DIAGNOSTIC_LENGTH
152
+ ? normalized
153
+ : `${normalized.slice(0, MAX_DIAGNOSTIC_LENGTH - 3)}...`;
154
+ }
155
+ function isMissingFileError(error) {
156
+ return errorCode(error) === "ENOENT";
157
+ }
158
+ function errorCode(error) {
159
+ if (typeof error === "object" && error !== null && "code" in error) {
160
+ const code = error.code;
161
+ if (typeof code === "string" && code)
162
+ return code;
163
+ }
164
+ return "UNKNOWN";
165
+ }
@@ -4,8 +4,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { promisify } from "node:util";
7
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
8
- import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
7
+ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
9
8
  import { ActivityAuditStore } from "../../activity/history/audit-store.js";
10
9
  import { BashOutputStore } from "../../activity/history/bash-output-store.js";
11
10
  import { HostTurnStore } from "../../activity/history/host-turn-store.js";
@@ -148,8 +147,9 @@ export async function callOpen(client, path, conversationScopeId, mode, newWorkt
148
147
  return client.callTool(params);
149
148
  }
150
149
  export function structuredContent(result) {
151
- assert.ok(result.structuredContent);
152
- return result.structuredContent;
150
+ const structured = result.structuredContent;
151
+ assert.ok(structured);
152
+ return structured;
153
153
  }
154
154
  export function responseText(result) {
155
155
  const content = result.content;
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { realpath } from "node:fs/promises";
3
3
  import { fileURLToPath } from "node:url";
4
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { McpServer } from "@modelcontextprotocol/server";
5
5
  import { registerAppResource } from "@modelcontextprotocol/ext-apps/server";
6
6
  import { registerActivityQueryTools } from "./activity/runtime/mcp-query-tools.js";
7
7
  import { buildCapabilityFingerprint, loadCapabilityGuides } from "./mcp/server/core/capabilities.js";
@@ -21,7 +21,8 @@ import { attachCompletedProcessNotices, recordBashCompletion } from "./mcp/proce
21
21
  import { CompositeActivityCoordinator } from "./workspaces/composite/composite-activity.js";
22
22
  import { CompositeWorkspaceRegistry } from "./workspaces/composite/composite-workspaces.js";
23
23
  import { RemoteWorkspaceRelay } from "./workspaces/relay/workspace-relay.js";
24
- import { hostConversationScopeId } from "./mcp/request-meta.js";
24
+ import { hostContextDeliveryScopeId, hostConversationScopeId } from "./mcp/request-meta.js";
25
+ import { mcpHandlerRequestContext } from "./mcp/request-context.js";
25
26
  import { createActivityPanelApp } from "./mcp/panel/app.js";
26
27
  import { shutdownHttpServer } from "./mcp/server/transport/server-shutdown.js";
27
28
  import { formatPathForPrompt } from "./workspaces/resources/skills.js";
@@ -110,16 +111,20 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
110
111
  const reminderWorkspaceId = taskReminderWorkspaceIdFor(target);
111
112
  return attachWorkspaceTaskReminder(presented, reminderWorkspaceId ? taskReminders.recordWork(reminderWorkspaceId) : undefined);
112
113
  };
113
- const hostScopeIdFor = (requestMeta, transportSessionId) => hostConversationScopeId(requestMeta, transportSessionId, connectionScopeId);
114
- const prepareExecutionContext = async (target, requestMeta, signal, sessionId) => {
115
- const conversationScopeId = hostScopeIdFor(requestMeta, sessionId);
114
+ const hostScopeIdFor = (requestContext) => hostConversationScopeId(requestContext, connectionScopeId);
115
+ const contextDeliveryScopeIdFor = (requestContext) => hostContextDeliveryScopeId(requestContext, connectionScopeId);
116
+ const prepareExecutionContext = async (target, requestContext) => {
117
+ const conversationScopeId = hostScopeIdFor(requestContext);
116
118
  const turnId = target.compositeWorkspaceId && target.memberName
117
119
  ? await compositeActivity.prepareMember(target.compositeWorkspaceId, target.memberName, target.executionWorkspaceId, conversationScopeId)
118
120
  : undefined;
119
121
  return {
120
- requestMeta,
121
- signal,
122
- sessionId,
122
+ requestMeta: requestContext.requestMeta,
123
+ signal: requestContext.signal,
124
+ sessionId: requestContext.transportSessionId,
125
+ requestId: requestContext.requestId,
126
+ protocolEra: requestContext.protocolEra,
127
+ conversationScopeId,
123
128
  ...(turnId ? { turnId } : {}),
124
129
  ...(target.memberName ? { activityMember: target.memberName } : {}),
125
130
  };
@@ -460,10 +465,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
460
465
  ...compact,
461
466
  });
462
467
  };
463
- registerAppResource(server, "ForgeRelay Activity Panel", activityPanelApp.uri, activityPanelApp.resourceMetadata, async (uri, extra) => activityPanelApp.readResource(uri.toString(), extra.sessionId));
468
+ registerAppResource(server, "ForgeRelay Activity Panel", activityPanelApp.uri, activityPanelApp.resourceMetadata, async (uri, extra) => activityPanelApp.readResource(uri.toString(), mcpHandlerRequestContext(extra).transportSessionId));
464
469
  registerOpenWorkspaceTool({
465
470
  server, config, workspaces, remoteWorkspaces, compositeWorkspaces, workspaceTasks, processSessions,
466
471
  capabilityRegistry, compositeTaskGuides, loadCompositeMemberContext, rememberWorkspacePanelState, hostScopeIdFor,
472
+ contextDeliveryScopeIdFor,
467
473
  presentation: {
468
474
  config, forgerelayVersion: FORGERELAY_VERSION, workspaces, workspaceTasks, reviewCheckpoints,
469
475
  capabilityRegistry, subagentProviders, hooks, rememberWorkspacePanelState,