@akira-tl/forgerelay 1.1.2 → 1.2.0

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 (170) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +14 -0
  3. package/dist/activity/runtime/mcp-query-tools.js +15 -5
  4. package/dist/cli/config/inspect.js +331 -0
  5. package/dist/cli/config/migrate.js +351 -0
  6. package/dist/cli/init/setup-config.js +41 -0
  7. package/dist/cli/init.js +128 -130
  8. package/dist/cli/maintenance.js +15 -5
  9. package/dist/cli/mcp/external-mcp.js +14 -10
  10. package/dist/cli/mcp/status.js +11 -6
  11. package/dist/cli.js +40 -7
  12. package/dist/lsp/language-server-config.js +66 -144
  13. package/dist/lsp/runtime/manager.js +35 -4
  14. package/dist/mcp/hooks/config.js +141 -0
  15. package/dist/mcp/hooks/external-mcp-transform.js +22 -13
  16. package/dist/mcp/hooks/hook-cli.js +62 -52
  17. package/dist/mcp/hooks/hooks.js +111 -85
  18. package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +8 -5
  19. package/dist/mcp/operations/external-mcp/external-mcp.js +9 -2
  20. package/dist/mcp/server/core/capability-support.js +7 -0
  21. package/dist/runtime/config/config.js +142 -64
  22. package/dist/runtime/config/definition/catalog.js +12 -0
  23. package/dist/runtime/config/definition/definition.js +115 -0
  24. package/dist/runtime/config/definition/external-mcp.js +58 -0
  25. package/dist/runtime/config/definition/general-config.js +258 -0
  26. package/dist/runtime/config/definition/language-servers.js +116 -0
  27. package/dist/runtime/config/definition/schema.js +113 -0
  28. package/dist/runtime/config/definition/types.js +2 -0
  29. package/dist/runtime/config/external-mcp-auth-store.js +105 -29
  30. package/dist/runtime/config/external-mcp-config.js +39 -0
  31. package/dist/runtime/config/external-mcp-registry.js +284 -106
  32. package/dist/runtime/config/inspection/live.js +77 -0
  33. package/dist/runtime/config/resolution/general.js +93 -0
  34. package/dist/runtime/config/resolution/hooks.js +315 -0
  35. package/dist/runtime/config/resolution/language-servers.js +237 -0
  36. package/dist/runtime/config/resolution/project-sources.js +159 -0
  37. package/dist/runtime/config/resolution/resolver.js +485 -0
  38. package/dist/runtime/config/resolution/types.js +1 -0
  39. package/dist/runtime/config/runtime/config-runtime.js +74 -0
  40. package/dist/runtime/config/runtime/source-refresh.js +263 -0
  41. package/dist/runtime/config/user-config.js +38 -31
  42. package/dist/runtime/security/project-execution-trust.js +34 -0
  43. package/dist/runtime/testing/server-fixture.js +9 -8
  44. package/dist/server.js +10 -6
  45. package/dist/subagents/profiles.js +336 -53
  46. package/dist/subagents/sessions/execution.js +6 -35
  47. package/dist/subagents/sessions/manager.js +14 -2
  48. package/dist/ui/.vite/manifest.json +275 -275
  49. package/dist/ui/activity-panel-app.html +1 -1
  50. package/dist/ui/assets/{activity-panel-app-Bw7ZFX2g.js → activity-panel-app-BeDiwd7D.js} +3 -3
  51. package/dist/ui/assets/{angular-html-DeUNP12X.js → angular-html-DuoCE7nn.js} +1 -1
  52. package/dist/ui/assets/{angular-ts-B3v8FKi_.js → angular-ts-D5tGk0hL.js} +1 -1
  53. package/dist/ui/assets/{apl-Bs3iQm4P.js → apl-lciOTPbw.js} +1 -1
  54. package/dist/ui/assets/{astro-D6GHecBY.js → astro-vJbqfNnJ.js} +1 -1
  55. package/dist/ui/assets/{blade-BMNbVOHv.js → blade-C7655Q4K.js} +1 -1
  56. package/dist/ui/assets/{c-BquuA6bv.js → c-DdHp1Jrw.js} +1 -1
  57. package/dist/ui/assets/{cobol-BuZ6kfps.js → cobol-DgvzQxZP.js} +1 -1
  58. package/dist/ui/assets/{coffee-DgC5uBzL.js → coffee-BIjVUOUd.js} +1 -1
  59. package/dist/ui/assets/{cpp-BdvWqCaB.js → cpp-CU8tGzvI.js} +1 -1
  60. package/dist/ui/assets/{crystal-0Q5qBvNI.js → crystal-CUQAwsg9.js} +1 -1
  61. package/dist/ui/assets/{css-Be_T8idh.js → css-DtMTqD3F.js} +1 -1
  62. package/dist/ui/assets/{edge-DaGLdgh_.js → edge-DoCX9RnL.js} +1 -1
  63. package/dist/ui/assets/{elixir-CMgiFIzB.js → elixir-DJxV9_U6.js} +1 -1
  64. package/dist/ui/assets/{elm-B-ufMlMC.js → elm-DIgWqK4r.js} +1 -1
  65. package/dist/ui/assets/{erb-CaGiOZM-.js → erb-BHxP0c_e.js} +1 -1
  66. package/dist/ui/assets/{git-rebase-CpwUWCzK.js → git-rebase-HbI_Vo8d.js} +1 -1
  67. package/dist/ui/assets/{glimmer-js-BRxccvnE.js → glimmer-js-CthY4Ty_.js} +1 -1
  68. package/dist/ui/assets/{glimmer-ts-CgQbPzlT.js → glimmer-ts-B2-_A1Xz.js} +1 -1
  69. package/dist/ui/assets/{glsl-D7JmHFr0.js → glsl-D9V9gq98.js} +1 -1
  70. package/dist/ui/assets/{graphql-C8n0eUBK.js → graphql-CddeAaS3.js} +1 -1
  71. package/dist/ui/assets/{hack-lVN7KVQv.js → hack-D_loGkOT.js} +1 -1
  72. package/dist/ui/assets/{haml-h18lG0pS.js → haml-BoWDczd0.js} +1 -1
  73. package/dist/ui/assets/{handlebars-lRFjRPfj.js → handlebars-ZoSOvw9y.js} +1 -1
  74. package/dist/ui/assets/{heavy-payload-Cqx9MOkH.js → heavy-payload-DO34LwoZ.js} +3 -3
  75. package/dist/ui/assets/{html-DtILqTE1.js → html-CE-obXVo.js} +1 -1
  76. package/dist/ui/assets/{html-derivative-CSTSmPup.js → html-derivative-Cd-smAbW.js} +1 -1
  77. package/dist/ui/assets/{http-DNI8fsbY.js → http-CZrkbrSl.js} +1 -1
  78. package/dist/ui/assets/{hurl-DgRi60JG.js → hurl-CxTuxBt1.js} +1 -1
  79. package/dist/ui/assets/{java-CW9pZ5bh.js → java-BshLjCat.js} +1 -1
  80. package/dist/ui/assets/{javascript-Cvg2UrWL.js → javascript-D2pxQTiX.js} +1 -1
  81. package/dist/ui/assets/{jinja-CZw_S1FB.js → jinja-kbfPP7-F.js} +1 -1
  82. package/dist/ui/assets/{jison-C56u_FZv.js → jison-C2mt9l1M.js} +1 -1
  83. package/dist/ui/assets/{json-DW2UTv1q.js → json-DU_rg_AU.js} +1 -1
  84. package/dist/ui/assets/{jsx-Zj5gobdD.js → jsx-D_8aQMlx.js} +1 -1
  85. package/dist/ui/assets/{julia-Cvcrre-L.js → julia-BmefTaVV.js} +1 -1
  86. package/dist/ui/assets/{just-8CZ2u6Tj.js → just-DXV8vk1S.js} +1 -1
  87. package/dist/ui/assets/{latex-D289AXWG.js → latex-Cjm2MvGW.js} +1 -1
  88. package/dist/ui/assets/{liquid-CFEX3SkZ.js → liquid-DyQDlo0u.js} +1 -1
  89. package/dist/ui/assets/{lua-BKpJ0ybO.js → lua-B2A1vzzy.js} +1 -1
  90. package/dist/ui/assets/{marko-D9maZ0a6.js → marko-B9vOkXOU.js} +1 -1
  91. package/dist/ui/assets/{mdc-rM9l0qWR.js → mdc-D42m6spU.js} +1 -1
  92. package/dist/ui/assets/{nginx-C9w6p2C9.js → nginx-TpfVxSUY.js} +1 -1
  93. package/dist/ui/assets/{nim-BRePHk0J.js → nim-CJr5T1FK.js} +1 -1
  94. package/dist/ui/assets/{perl-CskTmiPQ.js → perl-cJZcsdm9.js} +1 -1
  95. package/dist/ui/assets/{php-DCU_FvNI.js → php-CSZL5KRT.js} +1 -1
  96. package/dist/ui/assets/{pug-XLaVxTvv.js → pug-B1eMKzuA.js} +1 -1
  97. package/dist/ui/assets/{qml-CaONKSl-.js → qml-Dl2_7zc9.js} +1 -1
  98. package/dist/ui/assets/{r-BCVR0ZE3.js → r-F0aaO9gG.js} +1 -1
  99. package/dist/ui/assets/{razor-DDnGXxnU.js → razor-BPnkSMCw.js} +1 -1
  100. package/dist/ui/assets/{regexp-DWGTYYVx.js → regexp-BnGHMFbv.js} +1 -1
  101. package/dist/ui/assets/{rst-Dz3GWvQm.js → rst-B3CO7WYO.js} +1 -1
  102. package/dist/ui/assets/{ruby-DC3jGtMY.js → ruby-B6VMzT1T.js} +1 -1
  103. package/dist/ui/assets/{sas-7mNpvsjZ.js → sas-Df2lHVuU.js} +1 -1
  104. package/dist/ui/assets/{scss-uBsoCJVy.js → scss-4XxI6sgu.js} +1 -1
  105. package/dist/ui/assets/{shellscript-BCN9N_Jf.js → shellscript-DYASCN4r.js} +1 -1
  106. package/dist/ui/assets/{shellsession-Hsw46VXV.js → shellsession-Br6AGJO_.js} +1 -1
  107. package/dist/ui/assets/{soy-D6KV69HU.js → soy-p3ZNq1We.js} +1 -1
  108. package/dist/ui/assets/{sql-CVzVQkJh.js → sql-CAdv2OXL.js} +1 -1
  109. package/dist/ui/assets/{stata-BwUjXNhI.js → stata-Dx7yUbG_.js} +1 -1
  110. package/dist/ui/assets/{surrealql-CgloWqRa.js → surrealql-6YsE8HQa.js} +1 -1
  111. package/dist/ui/assets/{svelte-DcC3n6IC.js → svelte-YgzedwpD.js} +1 -1
  112. package/dist/ui/assets/{templ-DOFfnWTJ.js → templ-Bahqx3uD.js} +1 -1
  113. package/dist/ui/assets/{tex-ln89VPaX.js → tex-o6fTQjkH.js} +1 -1
  114. package/dist/ui/assets/{ts-tags-CWMzCstH.js → ts-tags-BO-Co0Ju.js} +1 -1
  115. package/dist/ui/assets/{tsx-DgzfiGeK.js → tsx-DtQe5AoH.js} +1 -1
  116. package/dist/ui/assets/{twig-Bi2itZL9.js → twig-BcUs6Kqc.js} +1 -1
  117. package/dist/ui/assets/{typescript-CQ24f4zW.js → typescript-BPtM8h4u.js} +1 -1
  118. package/dist/ui/assets/{vue-BS8jE5u6.js → vue-5KkGUzJJ.js} +1 -1
  119. package/dist/ui/assets/{vue-html-o38xqhyB.js → vue-html-Df_Xfsz9.js} +1 -1
  120. package/dist/ui/assets/{vue-vine-D2NvKv50.js → vue-vine-hUIe_u_Y.js} +1 -1
  121. package/dist/ui/assets/{xml-_44rx9_2.js → xml-C3Jy7as5.js} +1 -1
  122. package/dist/ui/assets/{xsl-B5QWQPoi.js → xsl-BnF9OuFo.js} +1 -1
  123. package/dist/ui/assets/{yaml-CKL2pEWe.js → yaml-DyrFgCou.js} +1 -1
  124. package/dist/workspaces/conversation-test-support.js +3 -11
  125. package/dist/workspaces/sessions.js +2 -0
  126. package/dist/workspaces/state/project-context.js +221 -0
  127. package/dist/workspaces.js +3 -1
  128. package/docs/chatgpt-coding-workflow.md +8 -3
  129. package/docs/configuration.md +99 -41
  130. package/docs/gotchas.md +24 -5
  131. package/docs/security.md +14 -3
  132. package/docs/setup.md +12 -7
  133. package/package.json +12 -3
  134. package/schemas/v1/config.project-local.schema.json +14 -0
  135. package/schemas/v1/config.project.schema.json +14 -0
  136. package/schemas/v1/config.user.schema.json +538 -0
  137. package/schemas/v1/hooks.project-local.schema.json +118 -0
  138. package/schemas/v1/hooks.project.schema.json +118 -0
  139. package/schemas/v1/hooks.user.schema.json +118 -0
  140. package/schemas/v1/language-servers.project-local.schema.json +230 -0
  141. package/schemas/v1/language-servers.project.schema.json +230 -0
  142. package/schemas/v1/language-servers.user.schema.json +230 -0
  143. package/schemas/v1/mcp.project-local.schema.json +143 -0
  144. package/schemas/v1/mcp.project.schema.json +143 -0
  145. package/schemas/v1/mcp.user.schema.json +143 -0
  146. package/scripts/ci/architecture.mjs +54 -4
  147. package/scripts/ci/cmd-acceptance.mjs +9 -9
  148. package/scripts/ci/config-v2-product-acceptance.mjs +639 -0
  149. package/scripts/ci/gates/acceptance-artifact.mjs +66 -0
  150. package/scripts/ci/gates/core-contract.mjs +27 -0
  151. package/scripts/ci/gates/platform-tests.mjs +38 -0
  152. package/scripts/ci/gates/prepare-windows-product.mjs +57 -0
  153. package/scripts/ci/gates/test-shard.mjs +71 -0
  154. package/scripts/ci/powershell51-acceptance.mjs +9 -9
  155. package/scripts/ci/pwsh-acceptance.mjs +9 -9
  156. package/scripts/ci/verify.mjs +2 -0
  157. package/scripts/ci/windows-product-acceptance.mjs +9 -10
  158. package/scripts/config/schema-text.mjs +4 -0
  159. package/scripts/config/schema-text.test.mjs +18 -0
  160. package/scripts/config/schema.mjs +74 -0
  161. package/scripts/lsp/interop.mjs +4 -1
  162. package/scripts/release/pack.mjs +64 -4
  163. package/scripts/release/release-gate.test.mjs +93 -27
  164. package/scripts/release-parity.mjs +19 -2
  165. package/templates/instructions/cmd.md +12 -0
  166. package/templates/instructions/fish.md +11 -0
  167. package/templates/instructions/powershell.md +14 -0
  168. package/templates/instructions/pwsh.md +14 -0
  169. package/templates/instructions/zsh.md +11 -0
  170. package/dist/runtime/instructions/powershell-skill.js +0 -65
@@ -13,10 +13,7 @@ export class ExternalMcpCredentialStore {
13
13
  this.filePath = join(this.configDir, AUTH_FILE_NAME);
14
14
  }
15
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);
16
+ return findCredential(this.readFile(), identity);
20
17
  }
21
18
  inspect() {
22
19
  const exists = existsSync(this.filePath);
@@ -33,7 +30,7 @@ export class ExternalMcpCredentialStore {
33
30
  revision: randomUUID(),
34
31
  updatedAt: new Date().toISOString(),
35
32
  };
36
- file.credentials[credentialKey(identity)] = written;
33
+ file.credentials[credentialKey(identity)] = persistedRecord(written);
37
34
  });
38
35
  return cloneRecord(written);
39
36
  }
@@ -41,11 +38,10 @@ export class ExternalMcpCredentialStore {
41
38
  let written;
42
39
  await this.updateFile((file) => {
43
40
  const key = credentialKey(identity);
44
- const existing = file.credentials[key];
45
- const current = existing && sameIdentity(existing.identity, identity) ? cloneRecord(existing) : undefined;
41
+ const current = findCredential(file, identity);
46
42
  const next = update(current);
47
43
  if (!next) {
48
- delete file.credentials[key];
44
+ deleteMatchingCredentials(file, identity);
49
45
  written = undefined;
50
46
  return;
51
47
  }
@@ -56,19 +52,14 @@ export class ExternalMcpCredentialStore {
56
52
  revision: randomUUID(),
57
53
  updatedAt: new Date().toISOString(),
58
54
  };
59
- file.credentials[key] = written;
55
+ file.credentials[key] = persistedRecord(written);
60
56
  });
61
57
  return written ? cloneRecord(written) : undefined;
62
58
  }
63
59
  async remove(identity) {
64
60
  let removed = false;
65
61
  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;
62
+ removed = deleteMatchingCredentials(file, identity);
72
63
  });
73
64
  return removed;
74
65
  }
@@ -101,10 +92,18 @@ export class ExternalMcpCredentialStore {
101
92
  }, { mode: 0o600 });
102
93
  }
103
94
  }
104
- export function externalMcpCredentialIdentity(source, server, projectRoot) {
105
- return source === "project"
106
- ? { kind: "project", server, projectRoot: resolve(projectRoot) }
107
- : { kind: "global", server };
95
+ export function externalMcpCredentialIdentity(source, server, project) {
96
+ if (source === "legacy" || source === "global")
97
+ return { kind: "global", server };
98
+ if (!project || typeof project === "string" || !project.id) {
99
+ throw new Error(`External MCP ${source} credential identity requires a Project ID.`);
100
+ }
101
+ return {
102
+ kind: "project",
103
+ server,
104
+ projectId: project.id,
105
+ ...(project.projectRoot ? { legacyProjectRoot: resolve(project.projectRoot) } : {}),
106
+ };
108
107
  }
109
108
  export function credentialKey(identity) {
110
109
  const normalized = normalizeIdentity(identity);
@@ -116,20 +115,85 @@ function credentialKeyHash(identity) {
116
115
  const normalized = normalizeIdentity(identity);
117
116
  const material = normalized.kind === "global"
118
117
  ? `global\0${normalized.server}`
119
- : `project\0${normalized.projectRoot}\0${normalized.server}`;
118
+ : `project-id\0${normalized.projectId}\0${normalized.server}`;
120
119
  return createHash("sha256").update(material).digest("hex");
121
120
  }
121
+ function legacyCredentialKey(server, projectRoot) {
122
+ const normalizedRoot = resolve(projectRoot);
123
+ const hash = createHash("sha256").update(`project\0${normalizedRoot}\0${server}`).digest("hex");
124
+ return `project:${hash}:${server}`;
125
+ }
122
126
  function normalizeIdentity(identity) {
123
127
  return identity.kind === "project"
124
- ? { kind: "project", server: identity.server, projectRoot: resolve(identity.projectRoot) }
128
+ ? {
129
+ kind: "project",
130
+ server: identity.server,
131
+ projectId: identity.projectId,
132
+ ...(identity.legacyProjectRoot ? { legacyProjectRoot: resolve(identity.legacyProjectRoot) } : {}),
133
+ }
125
134
  : { kind: "global", server: identity.server };
126
135
  }
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));
136
+ function storedIdentity(identity) {
137
+ const normalized = normalizeIdentity(identity);
138
+ return normalized.kind === "project"
139
+ ? { kind: "project", server: normalized.server, projectId: normalized.projectId }
140
+ : normalized;
141
+ }
142
+ function persistedIdentityMatches(persisted, requested) {
143
+ const normalized = normalizeIdentity(requested);
144
+ if (normalized.kind === "global") {
145
+ return persisted.kind === "global" && persisted.server === normalized.server;
146
+ }
147
+ return persisted.kind === "project"
148
+ && "projectId" in persisted
149
+ && persisted.server === normalized.server
150
+ && persisted.projectId === normalized.projectId;
151
+ }
152
+ function legacyIdentityMatches(persisted, requested) {
153
+ return persisted.kind === "project"
154
+ && "projectRoot" in persisted
155
+ && persisted.server === requested.server
156
+ && requested.legacyProjectRoot !== undefined
157
+ && resolve(persisted.projectRoot) === resolve(requested.legacyProjectRoot);
158
+ }
159
+ function findCredential(file, identity) {
160
+ const normalized = normalizeIdentity(identity);
161
+ const primary = file.credentials[credentialKey(normalized)];
162
+ if (primary && persistedIdentityMatches(primary.identity, normalized)) {
163
+ return publicRecord(primary, normalized);
164
+ }
165
+ if (normalized.kind !== "project" || !normalized.legacyProjectRoot)
166
+ return undefined;
167
+ const legacy = file.credentials[legacyCredentialKey(normalized.server, normalized.legacyProjectRoot)];
168
+ if (!legacy || !legacyIdentityMatches(legacy.identity, normalized))
169
+ return undefined;
170
+ return publicRecord(legacy, normalized);
171
+ }
172
+ function deleteMatchingCredentials(file, identity) {
173
+ const normalized = normalizeIdentity(identity);
174
+ let removed = false;
175
+ const key = credentialKey(normalized);
176
+ const primary = file.credentials[key];
177
+ if (primary && persistedIdentityMatches(primary.identity, normalized)) {
178
+ delete file.credentials[key];
179
+ removed = true;
180
+ }
181
+ if (normalized.kind === "project" && normalized.legacyProjectRoot) {
182
+ const legacyKey = legacyCredentialKey(normalized.server, normalized.legacyProjectRoot);
183
+ const legacy = file.credentials[legacyKey];
184
+ if (legacy && legacyIdentityMatches(legacy.identity, normalized)) {
185
+ delete file.credentials[legacyKey];
186
+ removed = true;
187
+ }
188
+ }
189
+ return removed;
190
+ }
191
+ function persistedRecord(record) {
192
+ return { ...cloneRecord(record), identity: storedIdentity(record.identity) };
193
+ }
194
+ function publicRecord(record, identity) {
195
+ const { identity: _persistedIdentity, ...rest } = cloneRecord(record);
196
+ return { ...rest, identity: normalizeIdentity(identity) };
133
197
  }
134
198
  function emptyAuthFile() {
135
199
  return { version: AUTH_FILE_VERSION, credentials: {} };
@@ -167,7 +231,7 @@ function parseCredentialRecord(value, filePath) {
167
231
  }
168
232
  const reauthorization = parseReauthorization(value.reauthorization, filePath);
169
233
  return {
170
- identity: normalizeIdentity(value.identity),
234
+ identity: normalizePersistedIdentity(value.identity),
171
235
  serverUrl: value.serverUrl,
172
236
  revision: value.revision,
173
237
  updatedAt: value.updatedAt,
@@ -205,7 +269,19 @@ function isCredentialIdentity(value) {
205
269
  return false;
206
270
  if (value.kind === "global")
207
271
  return true;
208
- return value.kind === "project" && typeof value.projectRoot === "string" && Boolean(value.projectRoot);
272
+ if (value.kind !== "project")
273
+ return false;
274
+ const hasProjectId = typeof value.projectId === "string" && Boolean(value.projectId);
275
+ const hasProjectRoot = typeof value.projectRoot === "string" && Boolean(value.projectRoot);
276
+ return hasProjectId !== hasProjectRoot;
277
+ }
278
+ function normalizePersistedIdentity(identity) {
279
+ if (identity.kind === "global")
280
+ return { kind: "global", server: identity.server };
281
+ if ("projectId" in identity) {
282
+ return { kind: "project", server: identity.server, projectId: identity.projectId };
283
+ }
284
+ return { kind: "project", server: identity.server, projectRoot: resolve(identity.projectRoot) };
209
285
  }
210
286
  function cloneRecord(value) {
211
287
  return structuredClone(value);
@@ -1,5 +1,44 @@
1
+ import * as z from "zod/v4";
1
2
  const MCP_SERVER_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
2
3
  const MAX_MCP_SERVERS = 32;
4
+ const nonEmptyStringSchema = z.string().trim().min(1);
5
+ const stringRecordSchema = z.record(z.string(), z.string());
6
+ const httpUrlSchema = z.string().url().refine((value) => {
7
+ const protocol = new URL(value).protocol;
8
+ return protocol === "http:" || protocol === "https:";
9
+ }, "URL must use http or https.");
10
+ const clientMetadataUrlSchema = z.string().url().refine((value) => {
11
+ const parsed = new URL(value);
12
+ return parsed.protocol === "https:" && parsed.pathname !== "/";
13
+ }, "clientMetadataUrl must use https and contain a non-root path.");
14
+ const oauthSourceSchema = z.object({
15
+ clientMetadataUrl: clientMetadataUrlSchema,
16
+ callbackPort: z.number().int().min(1024).max(65535),
17
+ }).strict();
18
+ const stdioSourceSchema = z.object({
19
+ transport: z.literal("stdio"),
20
+ command: nonEmptyStringSchema,
21
+ args: z.array(z.string()).optional(),
22
+ env: stringRecordSchema.optional(),
23
+ cwd: nonEmptyStringSchema.optional(),
24
+ disabled: z.literal(false).optional(),
25
+ }).strict();
26
+ const httpSourceSchema = z.object({
27
+ transport: z.literal("streamable-http"),
28
+ url: httpUrlSchema,
29
+ headers: stringRecordSchema.optional(),
30
+ oauth: oauthSourceSchema.optional(),
31
+ disabled: z.literal(false).optional(),
32
+ }).strict();
33
+ const disabledSourceSchema = z.object({ disabled: z.literal(true) }).strict();
34
+ export const externalMcpStandaloneServerSchema = z.union([
35
+ disabledSourceSchema,
36
+ stdioSourceSchema,
37
+ httpSourceSchema,
38
+ ]);
39
+ export const externalMcpStandaloneServersSchema = z.record(z.string().regex(MCP_SERVER_NAME_PATTERN, "Use a lowercase stable server name."), externalMcpStandaloneServerSchema).refine((value) => Object.keys(value).length <= MAX_MCP_SERVERS, {
40
+ message: `servers may contain at most ${MAX_MCP_SERVERS} configured servers.`,
41
+ });
3
42
  export function parseExternalMcpServers(value) {
4
43
  return parseServerRegistry(value, "mcpServers", false);
5
44
  }
@@ -1,48 +1,122 @@
1
- import { createHash } from "node:crypto";
2
- import { readFileSync } from "node:fs";
3
1
  import { join, resolve } from "node:path";
4
- import { parseExternalMcpStandaloneConfig, } from "./external-mcp-config.js";
5
- const MISSING_FINGERPRINT = "missing";
2
+ import { externalMcpConfigDefinition } from "./definition/external-mcp.js";
3
+ import { parseExternalMcpServers, parseExternalMcpStandaloneConfig, } from "./external-mcp-config.js";
4
+ import { refreshLegacyUserConfigField } from "./resolution/project-sources.js";
5
+ import { resolveConfigDomain } from "./resolution/resolver.js";
6
+ import { ConfigSourceRuntime } from "./runtime/source-refresh.js";
7
+ import { projectExecutionRequirement } from "../security/project-execution-trust.js";
8
+ const CANONICAL_PRIORITY = 100;
9
+ const LEGACY_PRIORITY = 0;
6
10
  const MAX_DIAGNOSTIC_LENGTH = 320;
11
+ const LEGACY_DEPRECATION = {
12
+ since: "1.2.0",
13
+ removeIn: "1.4.0",
14
+ replacement: "mcp.json",
15
+ };
7
16
  export class ExternalMcpConfigRegistry {
8
17
  configDir;
9
- legacyServers;
18
+ environment;
19
+ sourceRuntime;
10
20
  onDiagnostic;
11
- global;
12
- projects = new Map();
13
21
  constructor(options) {
14
22
  this.configDir = resolve(options.configDir);
15
- this.legacyServers = { ...(options.legacyServers ?? {}) };
23
+ this.environment = options.environment ?? process.env;
24
+ this.sourceRuntime = options.sourceRuntime ?? new ConfigSourceRuntime();
16
25
  this.onDiagnostic = options.onDiagnostic;
17
26
  }
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);
27
+ resolve(project) {
28
+ const dynamicSources = this.projectSources(project.sharedConfigDir, project.localConfigDir);
29
+ return this.composeSnapshot(dynamicSources, this.composeResolution(dynamicSources), project.id);
25
30
  }
26
31
  resolveGlobal() {
27
- return this.composeSnapshot(this.loadGlobalSource());
32
+ const dynamicSources = this.projectSources();
33
+ return this.composeSnapshot(dynamicSources, this.composeResolution(dynamicSources));
28
34
  }
29
- loadGlobalSource() {
30
- const globalPath = join(this.configDir, "mcp.json");
31
- this.global = this.loadDynamicSource("global", globalPath, this.global);
32
- return this.global;
35
+ /** Internal Config v2 inspection seam. Resolved values may contain secrets; callers must use provenance for display. */
36
+ resolveConfiguration(input = {}) {
37
+ const dynamicSources = this.projectSources(input.projectSharedConfigDir, input.projectLocalConfigDir);
38
+ return this.composeResolution(dynamicSources);
33
39
  }
34
- composeSnapshot(global, project) {
35
- const servers = { ...this.legacyServers };
36
- const origins = Object.fromEntries(Object.keys(this.legacyServers).map((name) => [name, "legacy"]));
40
+ projectSources(projectSharedConfigDir, projectLocalConfigDir) {
41
+ return [
42
+ this.loadCanonicalSource("global", "user", join(this.configDir, "mcp.json")),
43
+ ...(projectSharedConfigDir
44
+ ? [this.loadCanonicalSource("project", "project", join(projectSharedConfigDir, "mcp.json"))]
45
+ : []),
46
+ ...(projectLocalConfigDir
47
+ ? [this.loadCanonicalSource("project-local", "project-local", join(projectLocalConfigDir, "mcp.json"))]
48
+ : []),
49
+ ];
50
+ }
51
+ composeResolution(dynamicSources) {
52
+ const sourceInputs = [];
53
+ const legacy = this.loadLegacyInlineSource();
54
+ if (legacy.source)
55
+ sourceInputs.push(legacy.source);
56
+ for (const dynamic of dynamicSources) {
57
+ const input = dynamicSourceInput(dynamic);
58
+ if (input)
59
+ sourceInputs.push(input);
60
+ }
61
+ const resolution = resolveConfigDomain({
62
+ definition: externalMcpConfigDefinition,
63
+ sources: sourceInputs,
64
+ environment: this.environment,
65
+ });
66
+ if (legacy.diagnostic)
67
+ resolution.diagnostics.push(legacy.diagnostic);
68
+ for (const dynamic of dynamicSources) {
69
+ if (!dynamic.diagnostic || !dynamic.status.usingLastKnownGood)
70
+ continue;
71
+ const source = dynamic.status.source;
72
+ if (source === "legacy")
73
+ continue;
74
+ resolution.diagnostics.push({
75
+ severity: "error",
76
+ code: dynamic.diagnostic.code,
77
+ source: {
78
+ id: sourceId(source),
79
+ scope: scopeForSource(source),
80
+ kind: "file",
81
+ location: dynamic.status.path,
82
+ priority: CANONICAL_PRIORITY,
83
+ },
84
+ message: dynamic.diagnostic.message,
85
+ usingLastKnownGood: true,
86
+ diagnosticChanged: dynamic.diagnosticChanged,
87
+ });
88
+ }
89
+ return resolution;
90
+ }
91
+ composeSnapshot(dynamicSources, resolution, projectId) {
92
+ const normalized = normalizeResolvedServers(resolution);
93
+ const origins = {};
94
+ const executionRequirements = {};
37
95
  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);
96
+ for (const [entryName, entry] of Object.entries(resolution.entries)) {
97
+ if (!entryName.startsWith("servers."))
98
+ continue;
99
+ const name = entryName.slice("servers.".length);
100
+ const source = sourceFromId(entry.effective.source.id);
101
+ if (entry.tombstone) {
102
+ if (source !== "legacy")
103
+ masked[name] = source;
104
+ }
105
+ else if (name in normalized) {
106
+ origins[name] = source;
107
+ const requirement = projectId ? projectExecutionRequirement({
108
+ projectId, resolution, entryKey: entryName,
109
+ display: { kind: "external-mcp", name },
110
+ }) : undefined;
111
+ if (requirement)
112
+ executionRequirements[name] = requirement;
113
+ }
114
+ }
115
+ const diagnostics = mergeDiagnostics(resolution.diagnostics.map(configDiagnostic), []);
43
116
  return {
44
- servers,
117
+ servers: normalized,
45
118
  origins,
119
+ executionRequirements,
46
120
  masked,
47
121
  sources: [
48
122
  {
@@ -51,99 +125,203 @@ export class ExternalMcpConfigRegistry {
51
125
  state: "valid",
52
126
  usingLastKnownGood: false,
53
127
  },
54
- global.status,
55
- ...(project ? [project.status] : []),
128
+ ...dynamicSources.map((source) => source.status),
56
129
  ],
57
130
  diagnostics,
58
131
  };
59
132
  }
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;
133
+ loadLegacyInlineSource() {
134
+ const path = join(this.configDir, "config.json");
135
+ const refreshed = refreshLegacyUserConfigField({
136
+ sourceRuntime: this.sourceRuntime,
137
+ configDir: this.configDir,
138
+ field: "mcpServers",
139
+ parse: (value) => value === undefined ? undefined : parseExternalMcpServers(value),
140
+ invalidMessage: "Legacy inline External MCP configuration is invalid.",
141
+ });
142
+ if (refreshed.status.state === "missing")
143
+ return {};
144
+ const source = refreshed.value === undefined ? undefined : {
145
+ id: "legacy:user:mcpServers",
146
+ scope: "user",
147
+ kind: "file",
148
+ location: path,
149
+ priority: LEGACY_PRIORITY,
150
+ value: { servers: refreshed.value },
151
+ deprecation: LEGACY_DEPRECATION,
152
+ };
153
+ if (refreshed.status.state !== "invalid" || !refreshed.issue)
154
+ return source ? { source } : {};
155
+ if (refreshed.issueScope === "container")
156
+ return source ? { source } : {};
157
+ const diagnostic = {
158
+ severity: "error",
159
+ code: refreshed.issue.code,
160
+ source: source ? {
161
+ id: source.id,
162
+ scope: source.scope,
163
+ kind: source.kind,
164
+ ...(source.location ? { location: source.location } : {}),
165
+ priority: source.priority,
166
+ } : {
167
+ id: "legacy:user:mcpServers",
168
+ scope: "user",
169
+ kind: "file",
170
+ location: path,
171
+ priority: LEGACY_PRIORITY,
172
+ },
173
+ message: refreshed.issue.message,
174
+ usingLastKnownGood: refreshed.status.usingLastKnownGood,
175
+ diagnosticChanged: refreshed.status.diagnosticChanged,
176
+ };
177
+ if (refreshed.status.diagnosticChanged)
178
+ this.onDiagnostic?.(configDiagnostic(diagnostic));
179
+ if (source)
180
+ return { source, diagnostic };
181
+ return {
182
+ source: {
183
+ id: "legacy:user:mcpServers",
184
+ scope: "user",
185
+ kind: "file",
186
+ location: path,
187
+ priority: LEGACY_PRIORITY,
188
+ deprecation: LEGACY_DEPRECATION,
189
+ error: { code: refreshed.issue.code, message: refreshed.issue.message },
190
+ },
191
+ };
192
+ }
193
+ loadCanonicalSource(source, scope, path) {
194
+ const refreshed = this.sourceRuntime.refreshFile({
195
+ key: `external-mcp:${source}:${path}`,
196
+ path,
197
+ parse: (content) => JSON.parse(content),
198
+ parseIssue: {
199
+ code: "invalid_source",
200
+ message: "Configuration source is not valid JSON.",
201
+ },
202
+ readIssue: {
203
+ code: "invalid_source",
204
+ message: "Configuration source could not be read.",
205
+ },
206
+ validate: (value) => {
207
+ const validation = resolveConfigDomain({
208
+ definition: externalMcpConfigDefinition,
209
+ sources: [canonicalSourceInput(source, scope, path, value)],
210
+ environment: this.environment,
211
+ });
212
+ const error = validation.diagnostics.find((diagnostic) => diagnostic.severity === "error");
213
+ if (!error)
214
+ return undefined;
69
215
  return {
70
- observedFingerprint: MISSING_FINGERPRINT,
71
- entries: {},
72
- hasLastKnownGood: true,
73
- status: {
74
- source,
75
- path,
76
- state: "missing",
77
- usingLastKnownGood: false,
78
- },
216
+ code: error.code === "missing_environment" ? "missing_environment" : "invalid_source",
217
+ message: error.message,
79
218
  };
219
+ },
220
+ });
221
+ const diagnostic = refreshed.status.state === "invalid" && refreshed.issue
222
+ ? {
223
+ source,
224
+ path,
225
+ severity: "error",
226
+ code: refreshed.issue.code,
227
+ message: boundedDiagnosticMessage(refreshed.issue.message),
80
228
  }
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);
229
+ : undefined;
230
+ if (diagnostic && refreshed.status.diagnosticChanged)
231
+ this.onDiagnostic?.(diagnostic);
121
232
  return {
122
- observedFingerprint,
123
- entries: previous?.entries ?? {},
124
- hasLastKnownGood: previous?.hasLastKnownGood ?? false,
233
+ ...(refreshed.value !== undefined ? { value: refreshed.value } : {}),
234
+ hasLastKnownGood: refreshed.status.usingLastKnownGood || refreshed.status.state === "valid",
125
235
  status: {
126
236
  source,
127
237
  path,
128
- state: "invalid",
129
- usingLastKnownGood: previous?.hasLastKnownGood === true,
238
+ state: refreshed.status.state,
239
+ usingLastKnownGood: refreshed.status.usingLastKnownGood,
130
240
  },
131
- diagnostic,
241
+ diagnosticChanged: refreshed.status.diagnosticChanged,
242
+ ...(diagnostic ? { diagnostic } : {}),
132
243
  };
133
244
  }
134
245
  }
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];
246
+ function dynamicSourceInput(source) {
247
+ if (source.status.state === "missing")
248
+ return undefined;
249
+ const dynamicSource = source.status.source;
250
+ if (dynamicSource === "legacy")
251
+ throw new Error("Legacy External MCP configuration cannot be a dynamic source.");
252
+ const scope = scopeForSource(dynamicSource);
253
+ if (source.value) {
254
+ return canonicalSourceInput(dynamicSource, scope, source.status.path, source.value);
146
255
  }
256
+ return {
257
+ id: sourceId(dynamicSource),
258
+ scope,
259
+ kind: "file",
260
+ location: source.status.path,
261
+ priority: CANONICAL_PRIORITY,
262
+ shadowsLowerPriority: true,
263
+ error: {
264
+ code: source.diagnostic?.code === "missing_environment" ? "missing_environment" : "invalid_source",
265
+ message: source.diagnostic?.message ?? "External MCP configuration is invalid.",
266
+ },
267
+ };
268
+ }
269
+ function canonicalSourceInput(source, scope, path, value) {
270
+ return {
271
+ id: sourceId(source),
272
+ scope,
273
+ kind: "file",
274
+ location: path,
275
+ priority: CANONICAL_PRIORITY,
276
+ shadowsLowerPriority: true,
277
+ value,
278
+ };
279
+ }
280
+ function sourceId(source) {
281
+ return source === "global" ? "canonical:user:mcp" : `canonical:${source}:mcp`;
282
+ }
283
+ function sourceFromId(id) {
284
+ if (id === "legacy:user:mcpServers")
285
+ return "legacy";
286
+ if (id === "canonical:user:mcp")
287
+ return "global";
288
+ if (id === "canonical:project:mcp")
289
+ return "project";
290
+ if (id === "canonical:project-local:mcp")
291
+ return "project-local";
292
+ throw new Error(`Unknown External MCP resolver source: ${id}`);
293
+ }
294
+ function scopeForSource(source) {
295
+ if (source === "global")
296
+ return "user";
297
+ return source;
298
+ }
299
+ function normalizeResolvedServers(resolution) {
300
+ const raw = resolution.values.servers;
301
+ if (raw === undefined)
302
+ return {};
303
+ const parsed = parseExternalMcpStandaloneConfig({ servers: raw });
304
+ return Object.fromEntries(Object.entries(parsed)
305
+ .filter((entry) => !("disabled" in entry[1] && entry[1].disabled === true)));
306
+ }
307
+ function configDiagnostic(diagnostic) {
308
+ return {
309
+ source: sourceFromId(diagnostic.source.id),
310
+ path: diagnostic.source.location ?? diagnostic.source.id,
311
+ severity: diagnostic.severity === "error" ? "error" : "warning",
312
+ code: diagnostic.code,
313
+ message: boundedDiagnosticMessage(diagnostic.message),
314
+ };
315
+ }
316
+ function mergeDiagnostics(left, right) {
317
+ const seen = new Set();
318
+ return [...left, ...right].filter((diagnostic) => {
319
+ const key = `${diagnostic.source}\0${diagnostic.path}\0${diagnostic.code}\0${diagnostic.message}`;
320
+ if (seen.has(key))
321
+ return false;
322
+ seen.add(key);
323
+ return true;
324
+ });
147
325
  }
148
326
  function boundedDiagnosticMessage(error) {
149
327
  const raw = error instanceof Error ? error.message : String(error);