@akira-tl/forgerelay 1.0.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +4 -0
- package/capabilities/host-integration/external-mcp/GUIDE.md +29 -0
- package/capabilities/lifecycle-hooks/GUIDE.md +13 -3
- package/dist/activity/runtime/mcp-query-tools.js +16 -10
- package/dist/cli/mcp/external-mcp.js +481 -0
- package/dist/cli/mcp/status.js +295 -0
- package/dist/cli.js +14 -1
- package/dist/lsp/test-support/server-fixture.js +1 -2
- package/dist/mcp/filesystem/filesystem-tools.js +7 -3
- package/dist/mcp/hooks/command-runner.js +12 -7
- package/dist/mcp/hooks/external-mcp-transform.js +224 -0
- package/dist/mcp/hooks/hook-cli.js +3 -0
- package/dist/mcp/hooks/hooks.js +49 -3
- package/dist/mcp/oauth/auth-protocol.js +375 -0
- package/dist/mcp/oauth/oauth-provider.js +11 -12
- package/dist/mcp/oauth/oauth-store.js +2 -2
- package/dist/mcp/oauth/router.js +2 -6
- package/dist/mcp/operations/batch/executor.js +1 -1
- package/dist/mcp/operations/bulk-read.js +7 -6
- package/dist/mcp/operations/external-mcp/external-mcp-oauth.js +387 -0
- package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +59 -0
- package/dist/mcp/operations/external-mcp/external-mcp.js +382 -0
- package/dist/mcp/operations/media-content.js +28 -0
- package/dist/mcp/operations/native-bulk-mutations.js +1 -1
- package/dist/mcp/process/tools.js +19 -15
- package/dist/mcp/request-context.js +18 -0
- package/dist/mcp/request-meta.js +28 -3
- package/dist/mcp/server/core/activity-support.js +2 -2
- package/dist/mcp/server/core/capabilities/external-mcp.js +31 -0
- package/dist/mcp/server/core/capabilities.js +7 -0
- package/dist/mcp/server/core/capability-registry.js +2 -0
- package/dist/mcp/server/core/tool-support.js +29 -0
- package/dist/mcp/server/operations/runtime/filesystem-tools.js +91 -48
- package/dist/mcp/server/operations/runtime/operation-runtime.js +18 -11
- package/dist/mcp/server/transport/http-server.js +42 -21
- package/dist/mcp/server/workspace/runtime/workspace-open.js +13 -9
- package/dist/mcp/server/workspace/runtime/workspace-tools.js +29 -25
- package/dist/runtime/config/config.js +4 -0
- package/dist/runtime/config/external-mcp-auth-store.js +225 -0
- package/dist/runtime/config/external-mcp-config.js +146 -0
- package/dist/runtime/config/external-mcp-registry.js +165 -0
- package/dist/runtime/testing/server-fixture.js +10 -5
- package/dist/server.js +19 -10
- package/dist/subagents/sessions/mcp/audit.js +81 -0
- package/dist/ui/.vite/manifest.json +297 -298
- package/dist/ui/activity-panel-app.html +1 -1
- package/dist/ui/assets/activity-panel-app-Bw7ZFX2g.js +114 -0
- package/dist/ui/assets/angular-html-DeUNP12X.js +1 -0
- package/dist/ui/assets/{angular-ts-BearSZDb.js → angular-ts-B3v8FKi_.js} +1 -1
- package/dist/ui/assets/{apl-CYQtr-y6.js → apl-Bs3iQm4P.js} +1 -1
- package/dist/ui/assets/{astro-DDzqKIjK.js → astro-D6GHecBY.js} +1 -1
- package/dist/ui/assets/{blade-DkwEyHvQ.js → blade-BMNbVOHv.js} +1 -1
- package/dist/ui/assets/c-BquuA6bv.js +1 -0
- package/dist/ui/assets/{cobol-xBn5JxHE.js → cobol-BuZ6kfps.js} +1 -1
- package/dist/ui/assets/{coffee-BaXXvoJw.js → coffee-DgC5uBzL.js} +1 -1
- package/dist/ui/assets/cpp-BdvWqCaB.js +1 -0
- package/dist/ui/assets/{crystal-DjOpXVU1.js → crystal-0Q5qBvNI.js} +1 -1
- package/dist/ui/assets/css-Be_T8idh.js +1 -0
- package/dist/ui/assets/{edge-DNaRizoR.js → edge-DaGLdgh_.js} +1 -1
- package/dist/ui/assets/{elixir-CwBMwZmy.js → elixir-CMgiFIzB.js} +1 -1
- package/dist/ui/assets/{elm-gOUD2CW3.js → elm-B-ufMlMC.js} +1 -1
- package/dist/ui/assets/{erb-BcR-fjUp.js → erb-CaGiOZM-.js} +1 -1
- package/dist/ui/assets/{git-rebase-BAQPKHF6.js → git-rebase-CpwUWCzK.js} +1 -1
- package/dist/ui/assets/{glimmer-js-P_wOfSz_.js → glimmer-js-BRxccvnE.js} +1 -1
- package/dist/ui/assets/{glimmer-ts-B44aY1fZ.js → glimmer-ts-CgQbPzlT.js} +1 -1
- package/dist/ui/assets/glsl-D7JmHFr0.js +1 -0
- package/dist/ui/assets/graphql-C8n0eUBK.js +1 -0
- package/dist/ui/assets/{hack-ddU2k0nt.js → hack-lVN7KVQv.js} +1 -1
- package/dist/ui/assets/haml-h18lG0pS.js +1 -0
- package/dist/ui/assets/{handlebars-BpzaUpmz.js → handlebars-lRFjRPfj.js} +1 -1
- package/dist/ui/assets/heavy-payload-Cqx9MOkH.js +290 -0
- package/dist/ui/assets/html-DtILqTE1.js +1 -0
- package/dist/ui/assets/{html-derivative-B2beVdmA.js → html-derivative-CSTSmPup.js} +1 -1
- package/dist/ui/assets/{http-iEskZy8U.js → http-DNI8fsbY.js} +1 -1
- package/dist/ui/assets/{hurl-BuUsLSKv.js → hurl-DgRi60JG.js} +1 -1
- package/dist/ui/assets/java-CW9pZ5bh.js +1 -0
- package/dist/ui/assets/javascript-Cvg2UrWL.js +1 -0
- package/dist/ui/assets/{jinja-CRpkWwrH.js → jinja-CZw_S1FB.js} +1 -1
- package/dist/ui/assets/{jison-BYfSnuHj.js → jison-C56u_FZv.js} +1 -1
- package/dist/ui/assets/json-DW2UTv1q.js +1 -0
- package/dist/ui/assets/jsx-Zj5gobdD.js +1 -0
- package/dist/ui/assets/{julia-B8Rxy7vD.js → julia-Cvcrre-L.js} +1 -1
- package/dist/ui/assets/{just-DzhIWpFP.js → just-8CZ2u6Tj.js} +1 -1
- package/dist/ui/assets/{latex-CUNjUUL-.js → latex-D289AXWG.js} +1 -1
- package/dist/ui/assets/{liquid-hUg7nC61.js → liquid-CFEX3SkZ.js} +1 -1
- package/dist/ui/assets/lua-BKpJ0ybO.js +1 -0
- package/dist/ui/assets/{marko-CQmIkwMF.js → marko-D9maZ0a6.js} +1 -1
- package/dist/ui/assets/{mdc-CB-6dHYa.js → mdc-rM9l0qWR.js} +1 -1
- package/dist/ui/assets/{nginx-CQORo_Bt.js → nginx-C9w6p2C9.js} +1 -1
- package/dist/ui/assets/{nim-BNwBNzdn.js → nim-BRePHk0J.js} +1 -1
- package/dist/ui/assets/{perl-Cnz14fAJ.js → perl-CskTmiPQ.js} +1 -1
- package/dist/ui/assets/{php-YywDo63b.js → php-DCU_FvNI.js} +1 -1
- package/dist/ui/assets/{pug-BwbJp4cM.js → pug-XLaVxTvv.js} +1 -1
- package/dist/ui/assets/{qml-D2DY6dMH.js → qml-CaONKSl-.js} +1 -1
- package/dist/ui/assets/r-BCVR0ZE3.js +1 -0
- package/dist/ui/assets/{razor-pMTG7k41.js → razor-DDnGXxnU.js} +1 -1
- package/dist/ui/assets/regexp-DWGTYYVx.js +1 -0
- package/dist/ui/assets/{rst-CO_ndDm4.js → rst-Dz3GWvQm.js} +1 -1
- package/dist/ui/assets/{ruby-xzRn9gTh.js → ruby-DC3jGtMY.js} +1 -1
- package/dist/ui/assets/{sas-CBdtFKLA.js → sas-7mNpvsjZ.js} +1 -1
- package/dist/ui/assets/scss-uBsoCJVy.js +1 -0
- package/dist/ui/assets/shellscript-BCN9N_Jf.js +1 -0
- package/dist/ui/assets/{shellsession-BaZnm5rZ.js → shellsession-Hsw46VXV.js} +1 -1
- package/dist/ui/assets/{soy-IouG3xdB.js → soy-D6KV69HU.js} +1 -1
- package/dist/ui/assets/sql-CVzVQkJh.js +1 -0
- package/dist/ui/assets/{stata-dgbkIZHP.js → stata-BwUjXNhI.js} +1 -1
- package/dist/ui/assets/{surrealql-CBROnw_p.js → surrealql-CgloWqRa.js} +1 -1
- package/dist/ui/assets/{svelte-6r6BpX4-.js → svelte-DcC3n6IC.js} +1 -1
- package/dist/ui/assets/{templ-BKD_PsbL.js → templ-DOFfnWTJ.js} +1 -1
- package/dist/ui/assets/{tex-CKY1bwHb.js → tex-ln89VPaX.js} +1 -1
- package/dist/ui/assets/{ts-tags-BiYP5N_C.js → ts-tags-CWMzCstH.js} +1 -1
- package/dist/ui/assets/tsx-DgzfiGeK.js +1 -0
- package/dist/ui/assets/{twig-BTe1oPFW.js → twig-Bi2itZL9.js} +1 -1
- package/dist/ui/assets/typescript-CQ24f4zW.js +1 -0
- package/dist/ui/assets/{vue-CT0IiySh.js → vue-BS8jE5u6.js} +1 -1
- package/dist/ui/assets/{vue-html-B-tXcbat.js → vue-html-o38xqhyB.js} +1 -1
- package/dist/ui/assets/{vue-vine-C5ZyDCrs.js → vue-vine-D2NvKv50.js} +1 -1
- package/dist/ui/assets/xml-_44rx9_2.js +1 -0
- package/dist/ui/assets/{xsl-DvDSndNI.js → xsl-B5QWQPoi.js} +1 -1
- package/dist/ui/assets/yaml-CKL2pEWe.js +1 -0
- package/dist/workspaces/relay/auth/remote-auth.js +3 -5
- package/dist/workspaces/relay/result-support.js +19 -0
- package/dist/workspaces/relay/tests/test-support.js +10 -4
- package/dist/workspaces/relay/workspace-relay.js +20 -12
- package/docs/configuration.md +200 -2
- package/docs/debugging.md +8 -5
- package/docs/roadmap.md +64 -3
- package/docs/security.md +18 -0
- package/package.json +12 -4
- package/scripts/debug/accept/bootstrap.mjs +5 -0
- package/scripts/debug/accept/harness.mjs +10 -3
- package/scripts/debug/accept/media.mjs +335 -0
- package/scripts/debug/accept/modern-http.mjs +169 -0
- package/scripts/debug/accept/support.mjs +10 -6
- package/scripts/debug/accept.mjs +12 -1
- package/scripts/debug/relay-accept/support.mjs +40 -0
- package/scripts/debug/relay-accept.mjs +8 -9
- package/scripts/debug/runtime.mjs +4 -1
- package/scripts/debug/runtime.test.mjs +9 -0
- package/scripts/release/parity-sandbox.mjs +35 -0
- package/scripts/release/release-gate.test.mjs +42 -1
- package/scripts/release-parity.mjs +3 -22
- package/scripts/release-proof.mjs +24 -4
- package/scripts/release-proof.test.mjs +12 -4
- package/dist/ui/assets/activity-panel-app-XlqSanXT.js +0 -115
- package/dist/ui/assets/angular-html-tB9EchbM.js +0 -1
- package/dist/ui/assets/c-Dv-N5hjn.js +0 -1
- package/dist/ui/assets/cpp-C3unXX-3.js +0 -1
- package/dist/ui/assets/css-8Xr4kNNH.js +0 -1
- package/dist/ui/assets/glsl-BvCQg9MI.js +0 -1
- package/dist/ui/assets/graphql-C9S90gn_.js +0 -1
- package/dist/ui/assets/haml-Bb1w9rMG.js +0 -1
- package/dist/ui/assets/heavy-payload-CJKqi8Ac.js +0 -290
- package/dist/ui/assets/html-dUOs5pA9.js +0 -1
- package/dist/ui/assets/java-BFZCrORY.js +0 -1
- package/dist/ui/assets/javascript-DaIQ0Zqj.js +0 -1
- package/dist/ui/assets/json-BQC_Yp48.js +0 -1
- package/dist/ui/assets/jsx-Dd-CJRf9.js +0 -1
- package/dist/ui/assets/lua-BoZTiL2I.js +0 -1
- package/dist/ui/assets/r-B5nYsy-M.js +0 -1
- package/dist/ui/assets/regexp-oP0lbks_.js +0 -1
- package/dist/ui/assets/scss-CEqgKQNf.js +0 -1
- package/dist/ui/assets/shellscript-DzJJiWQC.js +0 -1
- package/dist/ui/assets/sql-DC2rdTzV.js +0 -1
- package/dist/ui/assets/tsx-D2OMiPNF.js +0 -1
- package/dist/ui/assets/typescript-BGIABksB.js +0 -1
- package/dist/ui/assets/xml-CfeBNlMP.js +0 -1
- package/dist/ui/assets/yaml-umbj2r1g.js +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
2
|
-
import {
|
|
2
|
+
import { mcpHandlerRequestContext } from "../../../request-context.js";
|
|
3
3
|
import { formatPathForPrompt } from "../../../../workspaces/resources/skills.js";
|
|
4
4
|
import { compositeCapabilityContext } from "../../core/capability-support.js";
|
|
5
5
|
import { compositeWorkspaceLogContext, logToolCall, textBlock } from "../../core/tool-support.js";
|
|
@@ -7,14 +7,13 @@ import { openWorkspaceToolDefinition } from "./workspace-open-schema.js";
|
|
|
7
7
|
import { presentLocalWorkspaceOpen, workspaceTaskUsageInstruction, } from "./workspace-open-presentation.js";
|
|
8
8
|
export function registerOpenWorkspaceTool(options) {
|
|
9
9
|
const { server } = options;
|
|
10
|
-
registerAppTool(server, "open_workspace", openWorkspaceToolDefinition, (args, extra) => handleOpenWorkspace(options, args, extra));
|
|
10
|
+
registerAppTool(server, "open_workspace", openWorkspaceToolDefinition, (args, extra) => handleOpenWorkspace(options, args, mcpHandlerRequestContext(extra)));
|
|
11
11
|
}
|
|
12
|
-
async function handleOpenWorkspace(options, input,
|
|
13
|
-
const { config, workspaces, remoteWorkspaces, compositeWorkspaces, workspaceTasks, processSessions, capabilityRegistry, compositeTaskGuides, loadCompositeMemberContext, rememberWorkspacePanelState, hostScopeIdFor, presentation, } = options;
|
|
12
|
+
async function handleOpenWorkspace(options, input, requestContext) {
|
|
13
|
+
const { config, workspaces, remoteWorkspaces, compositeWorkspaces, workspaceTasks, processSessions, capabilityRegistry, compositeTaskGuides, loadCompositeMemberContext, rememberWorkspacePanelState, hostScopeIdFor, contextDeliveryScopeIdFor, presentation, } = options;
|
|
14
14
|
const { action = "open", memberAction, member, kind, name, memberName, path, relay, workspaceId, mode, baseRef, newWorktree, newWorkspace, context, root, status, state, staleOnly, offset, limit, } = input;
|
|
15
|
-
const { _meta, sessionId } = extra;
|
|
16
15
|
const startedAt = performance.now();
|
|
17
|
-
const conversationScopeId =
|
|
16
|
+
const conversationScopeId = contextDeliveryScopeIdFor(requestContext);
|
|
18
17
|
const protectedWorkspaceIds = processSessions.activeWorkspaceIds();
|
|
19
18
|
const inspectTaskSummary = (targetWorkspaceId) => {
|
|
20
19
|
try {
|
|
@@ -469,7 +468,7 @@ async function handleOpenWorkspace(options, input, extra) {
|
|
|
469
468
|
newWorktree !== undefined || newWorkspace !== undefined) {
|
|
470
469
|
throw new Error("Resuming a relayed Workspace by workspaceId accepts context only.");
|
|
471
470
|
}
|
|
472
|
-
const resumed = await remoteWorkspaces.resumeWorkspace(workspaceId, context ?? "auto", hostScopeIdFor(
|
|
471
|
+
const resumed = await remoteWorkspaces.resumeWorkspace(workspaceId, context ?? "auto", hostScopeIdFor(requestContext));
|
|
473
472
|
rememberWorkspacePanelState(workspaceId, resumed);
|
|
474
473
|
return resumed;
|
|
475
474
|
}
|
|
@@ -483,7 +482,7 @@ async function handleOpenWorkspace(options, input, extra) {
|
|
|
483
482
|
newWorktree,
|
|
484
483
|
newWorkspace,
|
|
485
484
|
context,
|
|
486
|
-
}, hostScopeIdFor(
|
|
485
|
+
}, hostScopeIdFor(requestContext));
|
|
487
486
|
const relayedSkills = Array.isArray(opened.skills)
|
|
488
487
|
? opened.skills
|
|
489
488
|
: [];
|
|
@@ -568,5 +567,10 @@ async function handleOpenWorkspace(options, input, extra) {
|
|
|
568
567
|
rememberWorkspacePanelState(opened.workspaceId, response);
|
|
569
568
|
return response;
|
|
570
569
|
}
|
|
571
|
-
return presentLocalWorkspaceOpen(presentation, input, {
|
|
570
|
+
return presentLocalWorkspaceOpen(presentation, input, {
|
|
571
|
+
conversationScopeId,
|
|
572
|
+
protectedWorkspaceIds,
|
|
573
|
+
startedAt,
|
|
574
|
+
sessionId: requestContext.transportSessionId,
|
|
575
|
+
});
|
|
572
576
|
}
|
|
@@ -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 { CapabilityError } from "../../core/capability-registry.js";
|
|
4
5
|
import { attachHookReports, runToolWithHooks } from "../../../hooks/hooks.js";
|
|
5
6
|
import { toolNames } from "../../../server-instructions.js";
|
|
@@ -36,8 +37,9 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
36
37
|
openWorldHint: false,
|
|
37
38
|
},
|
|
38
39
|
}, async ({ workspaceId, path }, extra) => {
|
|
40
|
+
const requestContext = mcpHandlerRequestContext(extra);
|
|
39
41
|
if (remoteWorkspaces.has(workspaceId)) {
|
|
40
|
-
return remoteWorkspaces.workspaceInstruction(workspaceId, path, hostScopeIdFor(
|
|
42
|
+
return remoteWorkspaces.workspaceInstruction(workspaceId, path, hostScopeIdFor(requestContext));
|
|
41
43
|
}
|
|
42
44
|
if (compositeWorkspaces.has(workspaceId)) {
|
|
43
45
|
throw new Error("Composite Workspace instructions belong to a selected member; open that member context before viewing its instruction files.");
|
|
@@ -91,6 +93,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
91
93
|
openWorldHint: true,
|
|
92
94
|
},
|
|
93
95
|
}, async ({ workspaceId, member, name, action, arguments: capabilityArguments, file }, extra) => {
|
|
96
|
+
const requestContext = mcpHandlerRequestContext(extra);
|
|
94
97
|
if (name === "workspace.tasks" && compositeWorkspaces.has(workspaceId)) {
|
|
95
98
|
if (member !== undefined) {
|
|
96
99
|
throw new Error(`workspace.tasks belongs to Composite Workspace ${workspaceId} itself and does not accept member.`);
|
|
@@ -105,9 +108,9 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
105
108
|
if (action === "run") {
|
|
106
109
|
const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, context, {
|
|
107
110
|
nativeFile: file,
|
|
108
|
-
signal:
|
|
109
|
-
requestMeta:
|
|
110
|
-
sessionId:
|
|
111
|
+
signal: requestContext.signal,
|
|
112
|
+
requestMeta: requestContext.requestMeta,
|
|
113
|
+
sessionId: requestContext.transportSessionId,
|
|
111
114
|
});
|
|
112
115
|
const result = {
|
|
113
116
|
content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
|
|
@@ -146,7 +149,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
146
149
|
return result;
|
|
147
150
|
}
|
|
148
151
|
catch (error) {
|
|
149
|
-
if (
|
|
152
|
+
if (requestContext.signal.aborted)
|
|
150
153
|
throw error;
|
|
151
154
|
const capabilityError = error instanceof CapabilityError
|
|
152
155
|
? error
|
|
@@ -171,16 +174,16 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
171
174
|
}
|
|
172
175
|
const target = resolveExecutionTarget(workspaceId, member);
|
|
173
176
|
const executionWorkspaceId = target.executionWorkspaceId;
|
|
174
|
-
const executionContext = await prepareExecutionContext(target,
|
|
177
|
+
const executionContext = await prepareExecutionContext(target, requestContext);
|
|
175
178
|
if (remoteWorkspaces.has(executionWorkspaceId)) {
|
|
176
179
|
const response = await remoteWorkspaces.capability(executionWorkspaceId, {
|
|
177
180
|
name,
|
|
178
181
|
action,
|
|
179
182
|
...(capabilityArguments !== undefined ? { arguments: capabilityArguments } : {}),
|
|
180
183
|
...(file !== undefined ? { file } : {}),
|
|
181
|
-
}, hostScopeIdFor(
|
|
184
|
+
}, hostScopeIdFor(requestContext));
|
|
182
185
|
return action === "run" && name !== "workspace.tasks"
|
|
183
|
-
? presentSemanticWorkResult(response, target, hostScopeIdFor(
|
|
186
|
+
? presentSemanticWorkResult(response, target, hostScopeIdFor(requestContext))
|
|
184
187
|
: presentExecutionResult(response, target);
|
|
185
188
|
}
|
|
186
189
|
if (action === "run" && name === "batch.execute") {
|
|
@@ -189,9 +192,9 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
189
192
|
try {
|
|
190
193
|
const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, capabilityContextFor(workspace), {
|
|
191
194
|
nativeFile: file,
|
|
192
|
-
signal:
|
|
193
|
-
requestMeta:
|
|
194
|
-
sessionId:
|
|
195
|
+
signal: requestContext.signal,
|
|
196
|
+
requestMeta: requestContext.requestMeta,
|
|
197
|
+
sessionId: requestContext.transportSessionId,
|
|
195
198
|
});
|
|
196
199
|
const result = {
|
|
197
200
|
content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
|
|
@@ -199,16 +202,16 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
199
202
|
};
|
|
200
203
|
logToolCall(config, {
|
|
201
204
|
tool: toolNames.capability,
|
|
202
|
-
...workspaceLogContext(workspace,
|
|
205
|
+
...workspaceLogContext(workspace, requestContext.transportSessionId),
|
|
203
206
|
capability: name,
|
|
204
207
|
action,
|
|
205
208
|
success: true,
|
|
206
209
|
durationMs: Math.round(performance.now() - startedAt),
|
|
207
210
|
});
|
|
208
|
-
return presentSemanticWorkResult(result, target, hostScopeIdFor(
|
|
211
|
+
return presentSemanticWorkResult(result, target, hostScopeIdFor(requestContext));
|
|
209
212
|
}
|
|
210
213
|
catch (error) {
|
|
211
|
-
if (
|
|
214
|
+
if (requestContext.signal.aborted)
|
|
212
215
|
throw error;
|
|
213
216
|
const capabilityError = error instanceof CapabilityError
|
|
214
217
|
? error
|
|
@@ -224,7 +227,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
224
227
|
};
|
|
225
228
|
logFailedToolResponse(config, {
|
|
226
229
|
tool: toolNames.capability,
|
|
227
|
-
...workspaceLogContext(workspace,
|
|
230
|
+
...workspaceLogContext(workspace, requestContext.transportSessionId),
|
|
228
231
|
capability: name,
|
|
229
232
|
action,
|
|
230
233
|
}, result.content, startedAt);
|
|
@@ -235,11 +238,11 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
235
238
|
const response = await coreOperations.capabilityRun({ workspaceId: executionWorkspaceId, name, arguments: capabilityArguments, file }, executionContext);
|
|
236
239
|
return name === "workspace.tasks"
|
|
237
240
|
? presentExecutionResult(response, target)
|
|
238
|
-
: presentSemanticWorkResult(response, target, hostScopeIdFor(
|
|
241
|
+
: presentSemanticWorkResult(response, target, hostScopeIdFor(requestContext));
|
|
239
242
|
}
|
|
240
243
|
const workspace = workspaces.getWorkspace(executionWorkspaceId);
|
|
241
|
-
return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(
|
|
242
|
-
signal:
|
|
244
|
+
return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(requestContext), activityRequestFor({ workspaceId: executionWorkspaceId, name, action, arguments: capabilityArguments, file }, executionContext), {
|
|
245
|
+
signal: requestContext.signal,
|
|
243
246
|
tool: toolNames.capability,
|
|
244
247
|
invocation: workspaceHookInvocation(workspace),
|
|
245
248
|
payload: { name, action },
|
|
@@ -332,6 +335,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
332
335
|
_meta: {},
|
|
333
336
|
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
334
337
|
}, async ({ workspaceId, action = "close", commitMessage }, extra) => {
|
|
338
|
+
const requestContext = mcpHandlerRequestContext(extra);
|
|
335
339
|
if (compositeWorkspaces.has(workspaceId)) {
|
|
336
340
|
const startedAt = performance.now();
|
|
337
341
|
if (commitMessage !== undefined) {
|
|
@@ -392,7 +396,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
392
396
|
};
|
|
393
397
|
}
|
|
394
398
|
if (remoteWorkspaces.has(workspaceId)) {
|
|
395
|
-
const response = await remoteWorkspaces.closeWorkspace(workspaceId, { action, ...(commitMessage !== undefined ? { commitMessage } : {}) }, hostScopeIdFor(
|
|
399
|
+
const response = await remoteWorkspaces.closeWorkspace(workspaceId, { action, ...(commitMessage !== undefined ? { commitMessage } : {}) }, hostScopeIdFor(requestContext));
|
|
396
400
|
if (action === "delete") {
|
|
397
401
|
activityQueries.deleteWorkspaceHistory(config.stateDir, workspaceId);
|
|
398
402
|
}
|
|
@@ -409,7 +413,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
409
413
|
throw new Error(`Workspace ${session.id} still owns a running process. Poll, interrupt, or wait for it before deleting this Workspace.`);
|
|
410
414
|
}
|
|
411
415
|
const response = await runToolWithHooks(hooks, {
|
|
412
|
-
signal:
|
|
416
|
+
signal: requestContext.signal,
|
|
413
417
|
tool: toolNames.closeWorkspace,
|
|
414
418
|
invocation: {
|
|
415
419
|
workspaceId: session.id,
|
|
@@ -428,7 +432,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
428
432
|
const result = `Deleted ForgeRelay Workspace ${session.id}. Physical project files were not removed.`;
|
|
429
433
|
logToolCall(config, {
|
|
430
434
|
tool: toolNames.closeWorkspace,
|
|
431
|
-
...workspaceLogContext(session,
|
|
435
|
+
...workspaceLogContext(session, requestContext.transportSessionId),
|
|
432
436
|
action: "delete",
|
|
433
437
|
path: session.root,
|
|
434
438
|
success: true,
|
|
@@ -463,7 +467,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
463
467
|
}
|
|
464
468
|
const hookRoot = session.sourceRoot ?? session.root;
|
|
465
469
|
const response = await runToolWithHooks(hooks, {
|
|
466
|
-
signal:
|
|
470
|
+
signal: requestContext.signal,
|
|
467
471
|
tool: toolNames.closeWorkspace,
|
|
468
472
|
invocation: {
|
|
469
473
|
workspaceId: session.id,
|
|
@@ -509,7 +513,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
509
513
|
}
|
|
510
514
|
const workspace = workspaces.getWorkspace(session.id);
|
|
511
515
|
const response = await runToolWithHooks(hooks, {
|
|
512
|
-
signal:
|
|
516
|
+
signal: requestContext.signal,
|
|
513
517
|
tool: toolNames.closeWorkspace,
|
|
514
518
|
invocation: workspaceHookInvocation(workspace),
|
|
515
519
|
payload: { workspaceId: workspace.id, action, commitMessage, mode: workspace.mode },
|
|
@@ -558,7 +562,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
558
562
|
].join("\n");
|
|
559
563
|
logToolCall(config, {
|
|
560
564
|
tool: toolNames.closeWorkspace,
|
|
561
|
-
...workspaceLogContext(workspace,
|
|
565
|
+
...workspaceLogContext(workspace, requestContext.transportSessionId),
|
|
562
566
|
action,
|
|
563
567
|
path: closed.sourceRoot,
|
|
564
568
|
success: true,
|
|
@@ -610,7 +614,7 @@ export function registerWorkspaceAuxiliaryTools(options) {
|
|
|
610
614
|
const result = `Closed checkout-backed Workspace ${checkoutWorkspaceId}; its ForgeRelay identity was preserved for later reopen. Physical project files were not removed.`;
|
|
611
615
|
logToolCall(config, {
|
|
612
616
|
tool: toolNames.closeWorkspace,
|
|
613
|
-
...workspaceLogContext(workspace,
|
|
617
|
+
...workspaceLogContext(workspace, requestContext.transportSessionId),
|
|
614
618
|
action: "close",
|
|
615
619
|
path: workspace.root,
|
|
616
620
|
success: true,
|
|
@@ -4,6 +4,8 @@ import { join, resolve } from "node:path";
|
|
|
4
4
|
import { expandHomePath } from "../../mcp/filesystem/roots.js";
|
|
5
5
|
import { mergeHookConfigs, parseHookConfig } from "../../mcp/hooks/hooks.js";
|
|
6
6
|
import { forgerelayAgentsDir, forgerelaySkillsDir, generateInstanceId, loadForgeRelayFiles, } from "./user-config.js";
|
|
7
|
+
import { parseExternalMcpServers, } from "./external-mcp-config.js";
|
|
8
|
+
import { DEFAULT_MEDIA_MAX_BYTES } from "../../mcp/operations/media-content.js";
|
|
7
9
|
import { shellInstructionPath } from "../instructions/shell-instructions.js";
|
|
8
10
|
import { resolveConfiguredCommandShellRuntime, } from "../shell/command-shell-runtime.js";
|
|
9
11
|
const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
@@ -303,6 +305,7 @@ export function loadConfig(env = process.env) {
|
|
|
303
305
|
? files.config.artifactsEnabled === true
|
|
304
306
|
: parseBoolean(productEnv(env, "ARTIFACTS")),
|
|
305
307
|
artifactMaxFileBytes: parsePositiveInteger(productEnv(env, "ARTIFACT_MAX_FILE_BYTES") ?? numberConfigValue(files.config.artifactMaxFileBytes), DEFAULT_ARTIFACT_MAX_FILE_BYTES, "FORGERELAY_ARTIFACT_MAX_FILE_BYTES"),
|
|
308
|
+
mediaMaxBytes: parsePositiveInteger(productEnv(env, "MEDIA_MAX_BYTES") ?? numberConfigValue(files.config.mediaMaxBytes), DEFAULT_MEDIA_MAX_BYTES, "FORGERELAY_MEDIA_MAX_BYTES"),
|
|
306
309
|
taskReminderInterval: parseNonNegativeInteger(productEnv(env, "TASK_REMINDER_INTERVAL") ?? numberConfigValue(files.config.taskReminderInterval), DEFAULT_TASK_REMINDER_INTERVAL, "FORGERELAY_TASK_REMINDER_INTERVAL"),
|
|
307
310
|
skillsEnabled: productEnv(env, "SKILLS") === undefined ? true : parseBoolean(productEnv(env, "SKILLS")),
|
|
308
311
|
skillPaths: parsePathList(productEnv(env, "SKILL_PATHS")),
|
|
@@ -313,6 +316,7 @@ export function loadConfig(env = process.env) {
|
|
|
313
316
|
: parseBoolean(productEnv(env, "SUBAGENTS")),
|
|
314
317
|
languageServers: files.config.languageServers ?? {},
|
|
315
318
|
allowAgentLanguageServerInstall: files.config.allowAgentLanguageServerInstall === true,
|
|
319
|
+
mcpServers: parseExternalMcpServers(files.config.mcpServers),
|
|
316
320
|
agentDir: resolve(expandHomePath(productEnv(env, "AGENT_DIR") ?? files.config.agentDir ?? defaultAgentDir())),
|
|
317
321
|
systemInstructionsPath: parseSystemInstructionsPath(productEnv(env, "SYSTEM_INSTRUCTIONS_PATH") ?? files.config.systemInstructionsPath),
|
|
318
322
|
hooks: mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles),
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const MCP_SERVER_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
2
|
+
const MAX_MCP_SERVERS = 32;
|
|
3
|
+
export function parseExternalMcpServers(value) {
|
|
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)
|
|
16
|
+
return {};
|
|
17
|
+
if (!isRecord(value))
|
|
18
|
+
throw new Error(`${label} must be an object keyed by server name.`);
|
|
19
|
+
const entries = Object.entries(value);
|
|
20
|
+
if (entries.length > MAX_MCP_SERVERS) {
|
|
21
|
+
throw new Error(`${label} may contain at most ${MAX_MCP_SERVERS} configured servers.`);
|
|
22
|
+
}
|
|
23
|
+
return Object.fromEntries(entries.map(([name, raw]) => {
|
|
24
|
+
if (!MCP_SERVER_NAME_PATTERN.test(name)) {
|
|
25
|
+
throw new Error(`Invalid ${label} name '${name}'. Use a lowercase stable name.`);
|
|
26
|
+
}
|
|
27
|
+
if (!isRecord(raw))
|
|
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];
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
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}`);
|
|
52
|
+
return {
|
|
53
|
+
transport: "stdio",
|
|
54
|
+
command,
|
|
55
|
+
...(args ? { args } : {}),
|
|
56
|
+
...(env ? { env } : {}),
|
|
57
|
+
...(cwd ? { cwd } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function parseHttp(name, value, label) {
|
|
61
|
+
const url = requiredString(value.url, `${label}.${name}.url`);
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = new URL(url);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new Error(`${label}.${name}.url must be a valid HTTP(S) URL.`);
|
|
68
|
+
}
|
|
69
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
70
|
+
throw new Error(`${label}.${name}.url must use http or https.`);
|
|
71
|
+
}
|
|
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}`);
|
|
75
|
+
return {
|
|
76
|
+
transport: "streamable-http",
|
|
77
|
+
url: parsed.toString(),
|
|
78
|
+
...(headers ? { headers } : {}),
|
|
79
|
+
...(oauth ? { oauth } : {}),
|
|
80
|
+
};
|
|
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
|
+
}
|
|
113
|
+
function requiredString(value, label) {
|
|
114
|
+
if (typeof value !== "string" || !value.trim())
|
|
115
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
116
|
+
return value.trim();
|
|
117
|
+
}
|
|
118
|
+
function optionalString(value, label) {
|
|
119
|
+
if (value === undefined)
|
|
120
|
+
return undefined;
|
|
121
|
+
return requiredString(value, label);
|
|
122
|
+
}
|
|
123
|
+
function optionalStringArray(value, label) {
|
|
124
|
+
if (value === undefined)
|
|
125
|
+
return undefined;
|
|
126
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
127
|
+
throw new Error(`${label} must be an array of strings.`);
|
|
128
|
+
}
|
|
129
|
+
return [...value];
|
|
130
|
+
}
|
|
131
|
+
function optionalStringRecord(value, label) {
|
|
132
|
+
if (value === undefined)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (!isRecord(value) || Object.values(value).some((entry) => typeof entry !== "string")) {
|
|
135
|
+
throw new Error(`${label} must be an object of string values.`);
|
|
136
|
+
}
|
|
137
|
+
return { ...value };
|
|
138
|
+
}
|
|
139
|
+
function rejectUnknownKeys(value, allowed, label) {
|
|
140
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
141
|
+
if (unknown.length > 0)
|
|
142
|
+
throw new Error(`${label} contains unsupported fields: ${unknown.join(", ")}.`);
|
|
143
|
+
}
|
|
144
|
+
function isRecord(value) {
|
|
145
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
146
|
+
}
|