@akira-tl/forgerelay 1.0.0 → 1.1.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.
- package/CHANGELOG.md +25 -0
- package/README.md +2 -0
- package/capabilities/host-integration/external-mcp/GUIDE.md +29 -0
- package/capabilities/lifecycle-hooks/GUIDE.md +13 -3
- 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/operations/bulk-read.js +7 -6
- package/dist/mcp/operations/external-mcp/external-mcp-runtime.js +42 -0
- package/dist/mcp/operations/external-mcp/external-mcp.js +227 -0
- package/dist/mcp/operations/media-content.js +28 -0
- 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 +10 -0
- package/dist/mcp/server/core/capability-registry.js +2 -0
- package/dist/mcp/server/core/tool-support.js +35 -0
- package/dist/mcp/server/operations/runtime/filesystem-tools.js +57 -21
- package/dist/mcp/server/operations/runtime/operation-runtime.js +11 -4
- package/dist/mcp/server/transport/http-server.js +1 -1
- package/dist/mcp/server/workspace/runtime/workspace-open.js +11 -1
- package/dist/mcp/server/workspace/runtime/workspace-tools.js +40 -2
- package/dist/runtime/config/config.js +4 -0
- package/dist/runtime/config/external-mcp-config.js +92 -0
- package/dist/runtime/logging/logger.js +11 -0
- package/dist/runtime/testing/server-fixture.js +6 -1
- package/dist/server.js +4 -1
- package/dist/subagents/sessions/mcp/audit.js +81 -0
- package/dist/workspaces/relay/result-support.js +19 -0
- package/dist/workspaces/relay/tests/test-support.js +3 -0
- package/dist/workspaces/relay/workspace-relay.js +6 -4
- package/docs/configuration.md +50 -2
- package/package.json +2 -1
- package/scripts/debug/accept/bootstrap.mjs +3 -0
- package/scripts/debug/accept/harness.mjs +1 -1
- package/scripts/debug/accept/media.mjs +335 -0
- package/scripts/debug/accept.mjs +11 -1
- package/scripts/debug/relay-accept/support.mjs +40 -0
- package/scripts/debug/relay-accept.mjs +8 -9
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.1.0] - 2026-09-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added first-class image transport to the existing `read` tool for PNG, JPEG, WebP, and GIF, with magic-byte detection, unchanged image bytes, mixed bulk Read support, and explicit rejection of text ranges on image targets.
|
|
12
|
+
- Added registered external MCP servers through the existing workspace-scoped `capability` surface, including direct upstream `ImageContent` forwarding without expanding the always-visible core tool list.
|
|
13
|
+
- Added opt-in `ExternalMcpBeforeForward` and `ExternalMcpAfterForward` transform Hooks for server/tool-specific request or result adaptation while keeping path/URL/resource references pass-through by default.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Workspace Relay now preserves MCP image content opaquely through Execution → Gateway routing and re-applies the Gateway media budget before Host delivery.
|
|
18
|
+
- Media payloads are transient MCP content rather than Artifacts: the default decoded-media budget is 20 MiB per tool result, aggregate media is bounded, and structured output, Activity/Audit, UI state, and logs retain metadata instead of image base64.
|
|
19
|
+
|
|
20
|
+
### Security
|
|
21
|
+
|
|
22
|
+
- External MCP errors, Hook transforms, Relay ingress, and image Reads now enforce bounded media validation without persisting raw image payloads or credentials, and external path/URL/resource results are never implicitly dereferenced by ForgeRelay.
|
|
23
|
+
|
|
24
|
+
## [1.0.1] - 2026-09-09
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Preserved Capability names/actions and Workspace lifecycle actions in default pretty tool-call logs instead of collapsing multiplexed operations to generic `ok` lines.
|
|
29
|
+
- Completed attributable tool-call logging for Composite member mutations, checkout/Composite close and delete paths, and Composite-owned `workspace.tasks` success/failure results without logging arbitrary capability arguments.
|
|
30
|
+
- Preserved deletion of closed checkout Workspaces through historical aliases while adding lifecycle logging, avoiding a regression in the existing close/reopen/delete contract.
|
|
31
|
+
|
|
7
32
|
## [1.0.0] - 2026-09-07
|
|
8
33
|
|
|
9
34
|
### Changed
|
package/README.md
CHANGED
|
@@ -108,6 +108,7 @@ ForgeRelay 不会默认为每个任务创建 worktree。只有你明确要求隔
|
|
|
108
108
|
- 项目里的 `AGENTS.md`、`CLAUDE.md` 和 Agent Skills 按需加载,不会每次都把整套说明重新塞进上下文。
|
|
109
109
|
- 需要并行开发时可以创建真实 Git worktree;集成回主分支时只接受安全的 fast-forward,不自动制造 merge conflict。
|
|
110
110
|
- Workspace Relay 可以把执行放到另一台 ForgeRelay;Composite Workspace 可以同时协调几个独立环境。
|
|
111
|
+
- `read` 可以直接把 PNG、JPEG、WebP 和 GIF 作为临时 MCP Media content 返回;配置好的外部 MCP 也通过 `mcp.external` Capability 转发图片,而不会自动打开它返回的路径或 URL。
|
|
111
112
|
|
|
112
113
|
Lifecycle Hooks、Workspace Tasks、本地 Subagent、Activity/Audit、Checkpoint 和 Recovery 也已经包含在项目里,但第一次安装时不需要先学这些。需要哪个,再去 [Wiki](https://github.com/Akira-TL/forgerelay/wiki) 查哪个。
|
|
113
114
|
|
|
@@ -220,6 +221,7 @@ Long commands do not require tight polling either. Once the current wait window
|
|
|
220
221
|
- `AGENTS.md`, `CLAUDE.md`, and Agent Skills are loaded as needed instead of being resent in full on every open.
|
|
221
222
|
- Managed worktrees provide real Git isolation when you ask for parallel work, with fast-forward-only finalization.
|
|
222
223
|
- Workspace Relay runs work on another ForgeRelay instance; Composite Workspaces coordinate several independent environments from one Host.
|
|
224
|
+
- `read` can return PNG, JPEG, WebP, and GIF directly as transient MCP Media content; configured external MCP servers can forward images through the `mcp.external` Capability without ForgeRelay automatically opening returned paths or URLs.
|
|
223
225
|
|
|
224
226
|
Lifecycle Hooks, Workspace Tasks, local Subagents, Activity/Audit, checkpoints, and recovery are included too. They are optional parts of the workflow; the [Wiki](https://github.com/Akira-TL/forgerelay/wiki) documents them when you need them.
|
|
225
227
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# External MCP
|
|
2
|
+
|
|
3
|
+
Use `mcp.external` only for MCP servers the user has already configured in ForgeRelay. The Host remains the orchestrator: discover the configured server/tool surface, then explicitly choose the server, tool, and arguments for each call.
|
|
4
|
+
|
|
5
|
+
## Operations
|
|
6
|
+
|
|
7
|
+
- `servers` — list configured server names and transport kinds. Connection details and credentials are not returned.
|
|
8
|
+
- `tools` — list the tools advertised by one configured server.
|
|
9
|
+
- `call` — invoke one tool that the selected configured server currently advertises.
|
|
10
|
+
|
|
11
|
+
## Boundaries
|
|
12
|
+
|
|
13
|
+
- A capability call cannot supply a new MCP command, URL, credential, or connection target. Those belong to user configuration.
|
|
14
|
+
- ForgeRelay forwards the upstream MCP result. A path, URL, resource identifier, or textual file reference stays a reference; ForgeRelay does not automatically fetch it, call `read`, infer that it is an image, or create an Artifact.
|
|
15
|
+
- When an upstream result names an accessible Workspace file and you need its contents, make an explicit ForgeRelay `read` call.
|
|
16
|
+
- Direct upstream `ImageContent` for PNG, JPEG, WebP, and GIF is forwarded as transient Media content after MIME/base64 validation and the configured aggregate media budget. Image base64 exists only in the live MCP result; structured/Activity state keeps bounded MIME/byte metadata.
|
|
17
|
+
- Audio and arbitrary binary-resource forwarding are outside this Media contract. A path, URL, `resource_link`, or other non-media reference is not upgraded into an image automatically.
|
|
18
|
+
- ForgeRelay does not autonomously chain external MCP tools or retry through another configured server.
|
|
19
|
+
- External MCP processes/services keep the operating-system and network authority with which the user configured them. Routing through ForgeRelay does not make them a ForgeRelay filesystem sandbox.
|
|
20
|
+
- Hook policy may inspect or block Capability calls. Server/tool-specific transforms use the explicit `ExternalMcpBeforeForward` / `ExternalMcpAfterForward` contract; ordinary Hook stdout never rewrites MCP data.
|
|
21
|
+
|
|
22
|
+
## Optional transform Hooks
|
|
23
|
+
|
|
24
|
+
Transform Hooks are opt-in user policy for one configured external MCP server/tool. Match them with `tool: "capability"`, `capability: "mcp.external"`, `externalServer`, and `externalTool`. ForgeRelay passes the current transform value over stdin as versioned JSON and accepts one structured stdout envelope only.
|
|
25
|
+
|
|
26
|
+
- Before forward: stdout must be `{"version":1,"request":{"arguments":{...}}}`. Only arguments can change; the configured server/tool target cannot.
|
|
27
|
+
- After forward: stdout must be `{"version":1,"result":{...}}`. The transformed result is revalidated as an MCP result and any ImageContent is rechecked against the normal MIME/base64/media-budget rules.
|
|
28
|
+
- A transform command may deliberately read a renderer-owned path or perform other work using the command's own OS authority. That is explicit Hook behavior, not an implicit ForgeRelay `read`, fetch, or Artifact operation.
|
|
29
|
+
- Activity/log state records only bounded transform identity/status metadata. Transform stdin/stdout, arbitrary upstream payloads, credentials, and image base64 are not persisted by ForgeRelay.
|
|
@@ -22,17 +22,27 @@
|
|
|
22
22
|
- 可选 timeout;
|
|
23
23
|
- 可选 `report`,默认 `true`。
|
|
24
24
|
|
|
25
|
-
当前事件包括:`WorkspaceOpen`、`BeforeTool`、`AfterTool`、`AfterToolFailure`、`AfterFileChange`、`BeforeWorktreeClose`、`AfterWorktreeClose`、`SubagentStart`、`SubagentStop`。
|
|
25
|
+
当前事件包括:`WorkspaceOpen`、`BeforeTool`、`AfterTool`、`AfterToolFailure`、`ExternalMcpBeforeForward`、`ExternalMcpAfterForward`、`AfterFileChange`、`BeforeWorktreeClose`、`AfterWorktreeClose`、`SubagentStart`、`SubagentStop`。
|
|
26
|
+
|
|
27
|
+
External MCP transform 事件只用于 `mcp.external`。matcher 可额外使用 `capability`、`externalServer`、`externalTool`,从而绑定到已配置的 Capability/server/tool;这些字段只是现有目标的匹配条件,不能动态指定新的连接目标。
|
|
26
28
|
|
|
27
29
|
## 阻断与报告
|
|
28
30
|
|
|
29
|
-
`BeforeTool`
|
|
31
|
+
`BeforeTool`、`BeforeWorktreeClose` 以及两个 External MCP transform 事件都会在失败时令当前操作失败。Before-forward transform 失败时 upstream MCP call 不会发生;After-forward transform 失败时 upstream call 已经发生,只会阻止变换后结果继续交付并把 Capability 标成失败,不能声称回滚 upstream 已产生的副作用。其他普通 after-event 只观察已经发生的结果,失败也不会伪装成能够回滚先前副作用。
|
|
30
32
|
|
|
31
33
|
Hook report 会随工具结果返回给 Host/Agent。`report: false` 只隐藏成功的高频报告;阻断失败始终可见。Agent 看到有意义的 Hook report 时必须告诉用户哪些 Hook 运行了、是否通过,以及操作是否被阻断;不能在 blocking Hook 阻止操作后声称原操作成功。
|
|
32
34
|
|
|
35
|
+
## External MCP transform 协议
|
|
36
|
+
|
|
37
|
+
普通 Hook 的 stdout 仍然只是命令输出,**不会**改写工具结果。只有 `ExternalMcpBeforeForward` / `ExternalMcpAfterForward` 使用显式 structured transform 协议:ForgeRelay 将 versioned JSON 写入 Hook stdin,并只接受 stdout 中一个合法 JSON envelope。
|
|
38
|
+
|
|
39
|
+
- request phase 输入包含当前 `server`、`tool` 与 `request.arguments`;输出必须是 `{"version":1,"request":{"arguments":{...}}}`。server/tool 不能被改写。
|
|
40
|
+
- result phase 输入包含当前 upstream MCP result;输出必须是 `{"version":1,"result":{...}}`。输出随后重新经过标准 MCP result、Media MIME/base64 与 `mediaMaxBytes` 校验。
|
|
41
|
+
- Hook 环境中的 `FORGERELAY_HOOK_PAYLOAD` 只包含 Capability/server/tool/phase 等有界匹配元数据;任意 arguments、upstream payload 和 image base64 只通过 transform stdin/stdout 瞬态传递。
|
|
42
|
+
|
|
33
43
|
## 安全边界
|
|
34
44
|
|
|
35
|
-
项目 Hook 属于项目执行约定,不需要额外审批,但不能扩大 allowed roots、覆盖认证边界或替换机器级全局规则。Hook command 与 shell 一样以运行 ForgeRelay 的本地用户权限执行;工作区文件边界不等于 OS sandbox。
|
|
45
|
+
项目 Hook 属于项目执行约定,不需要额外审批,但不能扩大 allowed roots、覆盖认证边界或替换机器级全局规则。Hook command 与 shell 一样以运行 ForgeRelay 的本地用户权限执行;工作区文件边界不等于 OS sandbox。Transform Hook 如果主动读取文件或访问网络,那是该用户配置命令自身的 OS 权限,不代表 ForgeRelay 自动获得了新的 `read` 或网络权限。
|
|
36
46
|
|
|
37
47
|
## 检查入口
|
|
38
48
|
|
|
@@ -2,6 +2,7 @@ import { constants } from "node:fs";
|
|
|
2
2
|
import { access, mkdir, readFile, realpath, writeFile, } from "node:fs/promises";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
4
|
import { createPatch } from "diff";
|
|
5
|
+
import { claimMediaBytes } from "../operations/media-content.js";
|
|
5
6
|
import { resolveCanonicalAllowedPath } from "./roots.js";
|
|
6
7
|
const DEFAULT_MAX_LINES = 2_000;
|
|
7
8
|
const DEFAULT_MAX_BYTES = 50 * 1_024;
|
|
@@ -22,9 +23,14 @@ export async function readFileTool(input, context) {
|
|
|
22
23
|
const buffer = await readFile(path);
|
|
23
24
|
const mimeType = supportedImageMimeType(buffer);
|
|
24
25
|
if (mimeType) {
|
|
26
|
+
if (input.offset !== undefined || input.limit !== undefined) {
|
|
27
|
+
throw new Error("Image reads do not accept offset or limit.");
|
|
28
|
+
}
|
|
29
|
+
if (context.mediaBudget)
|
|
30
|
+
claimMediaBytes(context.mediaBudget, buffer.byteLength);
|
|
25
31
|
return {
|
|
26
32
|
content: [
|
|
27
|
-
{ type: "text", text: `Read image file [${mimeType}]` },
|
|
33
|
+
{ type: "text", text: `Read image file [${mimeType}; ${buffer.byteLength} bytes]` },
|
|
28
34
|
{ type: "image", data: buffer.toString("base64"), mimeType },
|
|
29
35
|
],
|
|
30
36
|
};
|
|
@@ -208,8 +214,6 @@ function supportedImageMimeType(buffer) {
|
|
|
208
214
|
buffer.subarray(0, 4).toString("ascii") === "RIFF" &&
|
|
209
215
|
buffer.subarray(8, 12).toString("ascii") === "WEBP")
|
|
210
216
|
return "image/webp";
|
|
211
|
-
if (buffer.length >= 2 && buffer.subarray(0, 2).toString("ascii") === "BM")
|
|
212
|
-
return "image/bmp";
|
|
213
217
|
return undefined;
|
|
214
218
|
}
|
|
215
219
|
async function withMutationQueue(path, operation) {
|
|
@@ -10,7 +10,7 @@ export function executeHookCommand(input) {
|
|
|
10
10
|
detached: input.detached,
|
|
11
11
|
windowsHide: true,
|
|
12
12
|
windowsVerbatimArguments: input.windowsVerbatimArguments,
|
|
13
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
13
|
+
stdio: [input.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
14
14
|
});
|
|
15
15
|
let stdout = "";
|
|
16
16
|
let stderr = "";
|
|
@@ -28,12 +28,17 @@ export function executeHookCommand(input) {
|
|
|
28
28
|
forceKillTimer.unref();
|
|
29
29
|
};
|
|
30
30
|
input.signal?.addEventListener("abort", abort, { once: true });
|
|
31
|
+
const maxCaptureBytes = input.maxCaptureBytes ?? MAX_CAPTURE_BYTES;
|
|
31
32
|
child.stdout?.on("data", (chunk) => {
|
|
32
|
-
stdout = appendCaptured(stdout, chunk);
|
|
33
|
+
stdout = appendCaptured(stdout, chunk, maxCaptureBytes);
|
|
33
34
|
});
|
|
34
35
|
child.stderr?.on("data", (chunk) => {
|
|
35
|
-
stderr = appendCaptured(stderr, chunk);
|
|
36
|
+
stderr = appendCaptured(stderr, chunk, maxCaptureBytes);
|
|
36
37
|
});
|
|
38
|
+
if (input.stdin !== undefined) {
|
|
39
|
+
child.stdin?.on("error", () => undefined);
|
|
40
|
+
child.stdin?.end(input.stdin);
|
|
41
|
+
}
|
|
37
42
|
const timeout = setTimeout(() => {
|
|
38
43
|
timedOut = true;
|
|
39
44
|
terminateProcessTree(child, "SIGTERM", input.detached);
|
|
@@ -65,13 +70,13 @@ export function executeHookCommand(input) {
|
|
|
65
70
|
});
|
|
66
71
|
});
|
|
67
72
|
}
|
|
68
|
-
function appendCaptured(current, chunk) {
|
|
69
|
-
if (Buffer.byteLength(current) >=
|
|
73
|
+
function appendCaptured(current, chunk, maxBytes) {
|
|
74
|
+
if (Buffer.byteLength(current) >= maxBytes)
|
|
70
75
|
return current;
|
|
71
76
|
const next = current + chunk.toString();
|
|
72
|
-
if (Buffer.byteLength(next) <=
|
|
77
|
+
if (Buffer.byteLength(next) <= maxBytes)
|
|
73
78
|
return next;
|
|
74
|
-
return Buffer.from(next).subarray(0,
|
|
79
|
+
return Buffer.from(next).subarray(0, maxBytes).toString("utf8");
|
|
75
80
|
}
|
|
76
81
|
export function hookFailureOutput(stdout, stderr) {
|
|
77
82
|
const output = (stderr.trim() || stdout.trim()).replace(/\s+/g, " ");
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { commandPreview, logEvent, workspaceLogLabel } from "../../runtime/logging/logger.js";
|
|
3
|
+
import { resolveCompatibilityCommandShellRuntime, snapshotCommandShellRuntime, } from "../../runtime/shell/command-shell-runtime.js";
|
|
4
|
+
import { resolveShellCommandForRuntime } from "../process/process-platform.js";
|
|
5
|
+
import { executeHookCommand } from "./command-runner.js";
|
|
6
|
+
import { loadProjectHookConfig, matchHookRule, } from "./hooks.js";
|
|
7
|
+
export class ExternalMcpTransformError extends Error {
|
|
8
|
+
server;
|
|
9
|
+
tool;
|
|
10
|
+
hookName;
|
|
11
|
+
constructor(server, tool, hookName, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.server = server;
|
|
14
|
+
this.tool = tool;
|
|
15
|
+
this.hookName = hookName;
|
|
16
|
+
this.name = "ExternalMcpTransformError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const TRANSFORM_PROTOCOL_VERSION = 1;
|
|
20
|
+
const MIN_TRANSFORM_CAPTURE_BYTES = 1024 * 1024;
|
|
21
|
+
const TRANSFORM_CAPTURE_OVERHEAD_BYTES = 256 * 1024;
|
|
22
|
+
export class ExternalMcpTransformRunner {
|
|
23
|
+
hooks;
|
|
24
|
+
logging;
|
|
25
|
+
baseEnv;
|
|
26
|
+
commandShellRuntime;
|
|
27
|
+
maxCaptureBytes;
|
|
28
|
+
constructor(hooks, logging, baseEnv = process.env, commandShellRuntime, mediaMaxBytes = 20 * 1024 * 1024) {
|
|
29
|
+
this.hooks = hooks;
|
|
30
|
+
this.logging = logging;
|
|
31
|
+
this.baseEnv = baseEnv;
|
|
32
|
+
this.commandShellRuntime = snapshotCommandShellRuntime(commandShellRuntime ?? resolveCompatibilityCommandShellRuntime(process.platform, baseEnv));
|
|
33
|
+
this.maxCaptureBytes = Math.max(MIN_TRANSFORM_CAPTURE_BYTES, Math.ceil((mediaMaxBytes * 4) / 3) + TRANSFORM_CAPTURE_OVERHEAD_BYTES);
|
|
34
|
+
}
|
|
35
|
+
async transformRequest(context, arguments_, signal) {
|
|
36
|
+
return this.run("request", context, arguments_, signal);
|
|
37
|
+
}
|
|
38
|
+
async transformResult(context, result, signal) {
|
|
39
|
+
return this.run("result", context, result, signal);
|
|
40
|
+
}
|
|
41
|
+
async run(phase, context, initialValue, signal) {
|
|
42
|
+
signal?.throwIfAborted();
|
|
43
|
+
const event = phase === "request"
|
|
44
|
+
? "ExternalMcpBeforeForward"
|
|
45
|
+
: "ExternalMcpAfterForward";
|
|
46
|
+
const invocation = {
|
|
47
|
+
workspaceId: context.workspaceId,
|
|
48
|
+
workspaceRoot: context.workspaceRoot,
|
|
49
|
+
workspaceMode: context.workspaceMode,
|
|
50
|
+
payload: transformMetadata(context, phase),
|
|
51
|
+
};
|
|
52
|
+
const project = await loadProjectHookConfig(context.workspaceRoot);
|
|
53
|
+
const handlers = [
|
|
54
|
+
...(this.hooks[event] ?? []).map((rule) => ({ scope: "global", rule })),
|
|
55
|
+
...(project.hooks[event] ?? []).map((rule) => ({ scope: "project", rule })),
|
|
56
|
+
].flatMap(({ scope, rule }) => {
|
|
57
|
+
const matched = matchHookRule(rule.matcher, invocation);
|
|
58
|
+
if (!matched)
|
|
59
|
+
return [];
|
|
60
|
+
return rule.handlers.map((handler) => ({ scope, handler }));
|
|
61
|
+
});
|
|
62
|
+
let value = initialValue;
|
|
63
|
+
const transforms = [];
|
|
64
|
+
for (const [index, entry] of handlers.entries()) {
|
|
65
|
+
signal?.throwIfAborted();
|
|
66
|
+
const execution = await this.runHandler(event, phase, context, entry.handler, entry.scope, index, value, signal);
|
|
67
|
+
value = execution.value;
|
|
68
|
+
transforms.push({
|
|
69
|
+
phase,
|
|
70
|
+
name: execution.report.name,
|
|
71
|
+
scope: execution.report.scope,
|
|
72
|
+
status: "passed",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return { value, transforms };
|
|
76
|
+
}
|
|
77
|
+
async runHandler(event, phase, context, handler, scope, index, currentValue, signal) {
|
|
78
|
+
const startedAt = performance.now();
|
|
79
|
+
const name = handler.name ?? `${event} handler ${index + 1}`;
|
|
80
|
+
const shell = resolveShellCommandForRuntime(handler.command, this.commandShellRuntime);
|
|
81
|
+
const detached = process.platform !== "win32";
|
|
82
|
+
const metadata = transformMetadata(context, phase);
|
|
83
|
+
const env = {
|
|
84
|
+
...this.baseEnv,
|
|
85
|
+
FORGERELAY_HOOK_EVENT: event,
|
|
86
|
+
FORGERELAY_HOOK_PAYLOAD: JSON.stringify(metadata),
|
|
87
|
+
FORGERELAY_WORKSPACE_ROOT: context.workspaceRoot,
|
|
88
|
+
FORGERELAY_WORKSPACE_ID: context.workspaceId,
|
|
89
|
+
FORGERELAY_WORKSPACE_MODE: context.workspaceMode,
|
|
90
|
+
FORGERELAY_TOOL_NAME: "capability",
|
|
91
|
+
FORGERELAY_CAPABILITY_NAME: "mcp.external",
|
|
92
|
+
FORGERELAY_EXTERNAL_MCP_SERVER: context.server,
|
|
93
|
+
FORGERELAY_EXTERNAL_MCP_TOOL: context.tool,
|
|
94
|
+
FORGERELAY_TRANSFORM_PHASE: phase,
|
|
95
|
+
};
|
|
96
|
+
const stdin = JSON.stringify(transformInput(phase, context, currentValue));
|
|
97
|
+
try {
|
|
98
|
+
const result = await executeHookCommand({
|
|
99
|
+
executable: shell.executable,
|
|
100
|
+
args: shell.args,
|
|
101
|
+
windowsVerbatimArguments: shell.windowsVerbatimArguments,
|
|
102
|
+
cwd: context.workspaceRoot,
|
|
103
|
+
env,
|
|
104
|
+
timeoutMs: handler.timeoutSeconds * 1_000,
|
|
105
|
+
detached,
|
|
106
|
+
signal,
|
|
107
|
+
stdin,
|
|
108
|
+
maxCaptureBytes: this.maxCaptureBytes,
|
|
109
|
+
});
|
|
110
|
+
const durationMs = Math.round(performance.now() - startedAt);
|
|
111
|
+
if (result.exitCode !== 0 || result.timedOut || result.signal) {
|
|
112
|
+
const reason = result.timedOut
|
|
113
|
+
? `timed out after ${handler.timeoutSeconds}s`
|
|
114
|
+
: result.signal
|
|
115
|
+
? `terminated by ${result.signal}`
|
|
116
|
+
: `exited with code ${result.exitCode ?? "unknown"}`;
|
|
117
|
+
this.logTransform(event, name, scope, context, false, durationMs, reason, handler.command);
|
|
118
|
+
throw this.error(context, name, `transform Hook ${name} ${reason}.`);
|
|
119
|
+
}
|
|
120
|
+
let value;
|
|
121
|
+
try {
|
|
122
|
+
value = parseTransformOutput(phase, result.stdout);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
this.logTransform(event, name, scope, context, false, durationMs, "invalid structured transform output", handler.command);
|
|
126
|
+
throw this.error(context, name, `transform Hook ${name} returned invalid structured transform output.`);
|
|
127
|
+
}
|
|
128
|
+
this.logTransform(event, name, scope, context, true, durationMs, undefined, handler.command);
|
|
129
|
+
return {
|
|
130
|
+
value,
|
|
131
|
+
report: {
|
|
132
|
+
event,
|
|
133
|
+
name: boundedName(name),
|
|
134
|
+
scope,
|
|
135
|
+
status: "passed",
|
|
136
|
+
durationMs,
|
|
137
|
+
report: handler.report,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (signal?.aborted)
|
|
143
|
+
throw error;
|
|
144
|
+
if (error instanceof ExternalMcpTransformError)
|
|
145
|
+
throw error;
|
|
146
|
+
const durationMs = Math.round(performance.now() - startedAt);
|
|
147
|
+
this.logTransform(event, name, scope, context, false, durationMs, "failed to start", handler.command);
|
|
148
|
+
throw this.error(context, name, `transform Hook ${name} failed to start.`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
error(context, name, detail) {
|
|
152
|
+
return new ExternalMcpTransformError(context.server, context.tool, boundedName(name), `External MCP ${context.server} tool ${context.tool} ${detail}`);
|
|
153
|
+
}
|
|
154
|
+
logTransform(event, name, scope, context, success, durationMs, error, command) {
|
|
155
|
+
logEvent(this.logging, success ? "info" : "warn", "hook_call", {
|
|
156
|
+
hookEvent: event,
|
|
157
|
+
hookName: boundedName(name),
|
|
158
|
+
hookScope: scope,
|
|
159
|
+
workspaceId: context.workspaceId,
|
|
160
|
+
workspace: workspaceLogLabel(context.workspaceRoot, context.workspaceId),
|
|
161
|
+
capability: "mcp.external",
|
|
162
|
+
externalServer: context.server,
|
|
163
|
+
externalTool: context.tool,
|
|
164
|
+
success,
|
|
165
|
+
durationMs,
|
|
166
|
+
error,
|
|
167
|
+
commandPreview: this.logging.shellCommands ? commandPreview(command) : undefined,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function transformMetadata(context, phase) {
|
|
172
|
+
return {
|
|
173
|
+
tool: "capability",
|
|
174
|
+
capability: "mcp.external",
|
|
175
|
+
externalServer: context.server,
|
|
176
|
+
externalTool: context.tool,
|
|
177
|
+
transformPhase: phase,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function transformInput(phase, context, value) {
|
|
181
|
+
const base = {
|
|
182
|
+
version: TRANSFORM_PROTOCOL_VERSION,
|
|
183
|
+
phase,
|
|
184
|
+
capability: "mcp.external",
|
|
185
|
+
server: context.server,
|
|
186
|
+
tool: context.tool,
|
|
187
|
+
};
|
|
188
|
+
return phase === "request"
|
|
189
|
+
? { ...base, request: { arguments: value } }
|
|
190
|
+
: { ...base, result: value };
|
|
191
|
+
}
|
|
192
|
+
function parseTransformOutput(phase, stdout) {
|
|
193
|
+
const text = stdout.trim();
|
|
194
|
+
if (!text)
|
|
195
|
+
throw new Error("empty transform output");
|
|
196
|
+
const value = JSON.parse(text);
|
|
197
|
+
if (!isRecord(value) || value.version !== TRANSFORM_PROTOCOL_VERSION) {
|
|
198
|
+
throw new Error("invalid transform envelope");
|
|
199
|
+
}
|
|
200
|
+
const allowedKeys = phase === "request"
|
|
201
|
+
? new Set(["version", "request"])
|
|
202
|
+
: new Set(["version", "result"]);
|
|
203
|
+
if (Object.keys(value).some((key) => !allowedKeys.has(key))) {
|
|
204
|
+
throw new Error("unexpected transform envelope field");
|
|
205
|
+
}
|
|
206
|
+
if (phase === "request") {
|
|
207
|
+
if (!isRecord(value.request) || !isRecord(value.request.arguments)) {
|
|
208
|
+
throw new Error("invalid request transform output");
|
|
209
|
+
}
|
|
210
|
+
if (Object.keys(value.request).some((key) => key !== "arguments")) {
|
|
211
|
+
throw new Error("unexpected request transform field");
|
|
212
|
+
}
|
|
213
|
+
return value.request.arguments;
|
|
214
|
+
}
|
|
215
|
+
if (!isRecord(value.result))
|
|
216
|
+
throw new Error("invalid result transform output");
|
|
217
|
+
return value.result;
|
|
218
|
+
}
|
|
219
|
+
function boundedName(value) {
|
|
220
|
+
return value.length > 200 ? `${value.slice(0, 197)}...` : value;
|
|
221
|
+
}
|
|
222
|
+
function isRecord(value) {
|
|
223
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
224
|
+
}
|
|
@@ -98,6 +98,9 @@ function formatMatcher(matcher) {
|
|
|
98
98
|
matcher.pathRegex ? `pathRegex=${matcher.pathRegex}` : undefined,
|
|
99
99
|
matcher.provider ? `provider=${matcher.provider}` : undefined,
|
|
100
100
|
matcher.workspaceMode ? `workspaceMode=${matcher.workspaceMode}` : undefined,
|
|
101
|
+
matcher.capability ? `capability=${matcher.capability}` : undefined,
|
|
102
|
+
matcher.externalServer ? `externalServer=${matcher.externalServer}` : undefined,
|
|
103
|
+
matcher.externalTool ? `externalTool=${matcher.externalTool}` : undefined,
|
|
101
104
|
].filter((value) => value !== undefined);
|
|
102
105
|
return parts.length > 0 ? parts.join(" ") : "matcher=*";
|
|
103
106
|
}
|
package/dist/mcp/hooks/hooks.js
CHANGED
|
@@ -10,6 +10,8 @@ export const HOOK_EVENTS = [
|
|
|
10
10
|
"BeforeTool",
|
|
11
11
|
"AfterTool",
|
|
12
12
|
"AfterToolFailure",
|
|
13
|
+
"ExternalMcpBeforeForward",
|
|
14
|
+
"ExternalMcpAfterForward",
|
|
13
15
|
"AfterFileChange",
|
|
14
16
|
"BeforeWorktreeClose",
|
|
15
17
|
"AfterWorktreeClose",
|
|
@@ -20,7 +22,12 @@ const DEFAULT_HOOK_TIMEOUT_SECONDS = 30;
|
|
|
20
22
|
const MAX_HOOK_TIMEOUT_SECONDS = 300;
|
|
21
23
|
const PROJECT_HOOKS_PATH = join(".forgerelay", "hooks.json");
|
|
22
24
|
const PROJECT_HOOKS_DIR = join(".forgerelay", "hooks");
|
|
23
|
-
const BLOCKING_EVENTS = new Set([
|
|
25
|
+
const BLOCKING_EVENTS = new Set([
|
|
26
|
+
"BeforeTool",
|
|
27
|
+
"ExternalMcpBeforeForward",
|
|
28
|
+
"ExternalMcpAfterForward",
|
|
29
|
+
"BeforeWorktreeClose",
|
|
30
|
+
]);
|
|
24
31
|
const EVENT_SET = new Set(HOOK_EVENTS);
|
|
25
32
|
export class HookExecutionError extends Error {
|
|
26
33
|
event;
|
|
@@ -389,7 +396,34 @@ function parseHookMatcher(event, value, index) {
|
|
|
389
396
|
}
|
|
390
397
|
matcher.workspaceMode = value.workspaceMode;
|
|
391
398
|
}
|
|
392
|
-
|
|
399
|
+
if (value.capability !== undefined) {
|
|
400
|
+
if (typeof value.capability !== "string" || value.capability.trim().length === 0) {
|
|
401
|
+
throw new Error(`Hook ${event} matcher capability must be a non-empty string`);
|
|
402
|
+
}
|
|
403
|
+
matcher.capability = value.capability.trim();
|
|
404
|
+
}
|
|
405
|
+
if (value.externalServer !== undefined) {
|
|
406
|
+
if (typeof value.externalServer !== "string" || value.externalServer.trim().length === 0) {
|
|
407
|
+
throw new Error(`Hook ${event} matcher externalServer must be a non-empty string`);
|
|
408
|
+
}
|
|
409
|
+
matcher.externalServer = value.externalServer.trim();
|
|
410
|
+
}
|
|
411
|
+
if (value.externalTool !== undefined) {
|
|
412
|
+
if (typeof value.externalTool !== "string" || value.externalTool.trim().length === 0) {
|
|
413
|
+
throw new Error(`Hook ${event} matcher externalTool must be a non-empty string`);
|
|
414
|
+
}
|
|
415
|
+
matcher.externalTool = value.externalTool.trim();
|
|
416
|
+
}
|
|
417
|
+
const knownKeys = new Set([
|
|
418
|
+
"tool",
|
|
419
|
+
"commandRegex",
|
|
420
|
+
"pathRegex",
|
|
421
|
+
"provider",
|
|
422
|
+
"workspaceMode",
|
|
423
|
+
"capability",
|
|
424
|
+
"externalServer",
|
|
425
|
+
"externalTool",
|
|
426
|
+
]);
|
|
393
427
|
const unknownKey = Object.keys(value).find((key) => !knownKeys.has(key));
|
|
394
428
|
if (unknownKey) {
|
|
395
429
|
throw new Error(`Unknown Hook ${event} matcher field: ${unknownKey}`);
|
|
@@ -472,7 +506,7 @@ export async function loadProjectHookConfig(workspaceRoot) {
|
|
|
472
506
|
...(diagnostics.length > 0 ? { diagnostic: diagnostics.join(" | ") } : {}),
|
|
473
507
|
};
|
|
474
508
|
}
|
|
475
|
-
function matchHookRule(matcher, invocation) {
|
|
509
|
+
export function matchHookRule(matcher, invocation) {
|
|
476
510
|
if (!matcher)
|
|
477
511
|
return invocation;
|
|
478
512
|
if (matcher.workspaceMode && invocation.workspaceMode !== matcher.workspaceMode)
|
|
@@ -517,6 +551,18 @@ function matchHookRule(matcher, invocation) {
|
|
|
517
551
|
return undefined;
|
|
518
552
|
}
|
|
519
553
|
}
|
|
554
|
+
if (matcher.capability) {
|
|
555
|
+
if (invocation.payload?.capability !== matcher.capability)
|
|
556
|
+
return undefined;
|
|
557
|
+
}
|
|
558
|
+
if (matcher.externalServer) {
|
|
559
|
+
if (invocation.payload?.externalServer !== matcher.externalServer)
|
|
560
|
+
return undefined;
|
|
561
|
+
}
|
|
562
|
+
if (matcher.externalTool) {
|
|
563
|
+
if (invocation.payload?.externalTool !== matcher.externalTool)
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
520
566
|
return matchedInvocation;
|
|
521
567
|
}
|
|
522
568
|
function hookEnvironment(baseEnv, event, invocation) {
|
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
export async function executeBulkRead(options) {
|
|
2
|
-
const children =
|
|
2
|
+
const children = [];
|
|
3
|
+
for (const path of options.paths) {
|
|
3
4
|
try {
|
|
4
5
|
const response = await options.run(path);
|
|
5
|
-
|
|
6
|
+
children.push({
|
|
6
7
|
path,
|
|
7
8
|
status: options.isError(response) ? "error" : "done",
|
|
8
9
|
result: options.resultText(response),
|
|
9
10
|
response,
|
|
10
|
-
};
|
|
11
|
+
});
|
|
11
12
|
}
|
|
12
13
|
catch (error) {
|
|
13
14
|
if (options.signal?.aborted)
|
|
14
15
|
throw error;
|
|
15
|
-
|
|
16
|
+
children.push({
|
|
16
17
|
path,
|
|
17
18
|
status: "error",
|
|
18
19
|
result: error instanceof Error ? error.message : String(error),
|
|
19
|
-
};
|
|
20
|
+
});
|
|
20
21
|
}
|
|
21
|
-
}
|
|
22
|
+
}
|
|
22
23
|
const failed = children.filter((child) => child.status === "error").length;
|
|
23
24
|
return {
|
|
24
25
|
children,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CapabilityError } from "../../server/core/capability-registry.js";
|
|
2
|
+
import { requireCapabilityWorkspaceRoot } from "../../server/core/capability-support.js";
|
|
3
|
+
import { ExternalMcpTransformError, ExternalMcpTransformRunner, } from "../../hooks/external-mcp-transform.js";
|
|
4
|
+
import { ExternalMcpError, ExternalMcpGateway } from "./external-mcp.js";
|
|
5
|
+
export function createExternalMcpCapabilityRuntime(config) {
|
|
6
|
+
const externalMcp = new ExternalMcpGateway(config.mcpServers, config.mediaMaxBytes);
|
|
7
|
+
const transforms = new ExternalMcpTransformRunner(config.hooks, config.logging, process.env, config.commandShellRuntime, config.mediaMaxBytes);
|
|
8
|
+
return {
|
|
9
|
+
available: externalMcp.available,
|
|
10
|
+
unavailableReason: externalMcp.available ? undefined : "No external MCP servers are configured.",
|
|
11
|
+
run: async (input, context, runOptions) => {
|
|
12
|
+
try {
|
|
13
|
+
const transformContext = (server, tool) => ({
|
|
14
|
+
workspaceId: context.workspaceId,
|
|
15
|
+
workspaceRoot: requireCapabilityWorkspaceRoot(context),
|
|
16
|
+
workspaceMode: context.workspaceMode,
|
|
17
|
+
server,
|
|
18
|
+
tool,
|
|
19
|
+
});
|
|
20
|
+
const result = await externalMcp.run(input, runOptions.signal, input.operation === "call"
|
|
21
|
+
? {
|
|
22
|
+
request: (server, tool, arguments_) => transforms.transformRequest(transformContext(server, tool), arguments_, runOptions.signal),
|
|
23
|
+
result: (server, tool, externalResult) => transforms.transformResult(transformContext(server, tool), externalResult, runOptions.signal),
|
|
24
|
+
}
|
|
25
|
+
: undefined);
|
|
26
|
+
return {
|
|
27
|
+
value: result.value,
|
|
28
|
+
...(result.content ? { content: result.content } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof ExternalMcpTransformError) {
|
|
33
|
+
throw new CapabilityError("mcp.transform_failed", error.message);
|
|
34
|
+
}
|
|
35
|
+
if (error instanceof ExternalMcpError) {
|
|
36
|
+
throw new CapabilityError(`mcp.${error.code}`, error.message);
|
|
37
|
+
}
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|