@akira-tl/forgerelay 1.0.1 → 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 +17 -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 +29 -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/runtime/config/config.js +4 -0
- package/dist/runtime/config/external-mcp-config.js +92 -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
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
if (value === undefined)
|
|
5
|
+
return {};
|
|
6
|
+
if (!isRecord(value))
|
|
7
|
+
throw new Error("mcpServers must be an object keyed by server name.");
|
|
8
|
+
const entries = Object.entries(value);
|
|
9
|
+
if (entries.length > MAX_MCP_SERVERS) {
|
|
10
|
+
throw new Error(`mcpServers may contain at most ${MAX_MCP_SERVERS} configured servers.`);
|
|
11
|
+
}
|
|
12
|
+
return Object.fromEntries(entries.map(([name, raw]) => {
|
|
13
|
+
if (!MCP_SERVER_NAME_PATTERN.test(name)) {
|
|
14
|
+
throw new Error(`Invalid mcpServers name '${name}'. Use a lowercase stable name.`);
|
|
15
|
+
}
|
|
16
|
+
if (!isRecord(raw))
|
|
17
|
+
throw new Error(`mcpServers.${name} must be an object.`);
|
|
18
|
+
if (raw.transport === "stdio")
|
|
19
|
+
return [name, parseStdio(name, raw)];
|
|
20
|
+
if (raw.transport === "streamable-http")
|
|
21
|
+
return [name, parseHttp(name, raw)];
|
|
22
|
+
throw new Error(`mcpServers.${name}.transport must be 'stdio' or 'streamable-http'.`);
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
function parseStdio(name, value) {
|
|
26
|
+
const command = requiredString(value.command, `mcpServers.${name}.command`);
|
|
27
|
+
const args = optionalStringArray(value.args, `mcpServers.${name}.args`);
|
|
28
|
+
const env = optionalStringRecord(value.env, `mcpServers.${name}.env`);
|
|
29
|
+
const cwd = optionalString(value.cwd, `mcpServers.${name}.cwd`);
|
|
30
|
+
rejectUnknownKeys(value, new Set(["transport", "command", "args", "env", "cwd"]), `mcpServers.${name}`);
|
|
31
|
+
return {
|
|
32
|
+
transport: "stdio",
|
|
33
|
+
command,
|
|
34
|
+
...(args ? { args } : {}),
|
|
35
|
+
...(env ? { env } : {}),
|
|
36
|
+
...(cwd ? { cwd } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function parseHttp(name, value) {
|
|
40
|
+
const url = requiredString(value.url, `mcpServers.${name}.url`);
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = new URL(url);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error(`mcpServers.${name}.url must be a valid HTTP(S) URL.`);
|
|
47
|
+
}
|
|
48
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
49
|
+
throw new Error(`mcpServers.${name}.url must use http or https.`);
|
|
50
|
+
}
|
|
51
|
+
const headers = optionalStringRecord(value.headers, `mcpServers.${name}.headers`);
|
|
52
|
+
rejectUnknownKeys(value, new Set(["transport", "url", "headers"]), `mcpServers.${name}`);
|
|
53
|
+
return {
|
|
54
|
+
transport: "streamable-http",
|
|
55
|
+
url: parsed.toString(),
|
|
56
|
+
...(headers ? { headers } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function requiredString(value, label) {
|
|
60
|
+
if (typeof value !== "string" || !value.trim())
|
|
61
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
62
|
+
return value.trim();
|
|
63
|
+
}
|
|
64
|
+
function optionalString(value, label) {
|
|
65
|
+
if (value === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
return requiredString(value, label);
|
|
68
|
+
}
|
|
69
|
+
function optionalStringArray(value, label) {
|
|
70
|
+
if (value === undefined)
|
|
71
|
+
return undefined;
|
|
72
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
|
|
73
|
+
throw new Error(`${label} must be an array of strings.`);
|
|
74
|
+
}
|
|
75
|
+
return [...value];
|
|
76
|
+
}
|
|
77
|
+
function optionalStringRecord(value, label) {
|
|
78
|
+
if (value === undefined)
|
|
79
|
+
return undefined;
|
|
80
|
+
if (!isRecord(value) || Object.values(value).some((entry) => typeof entry !== "string")) {
|
|
81
|
+
throw new Error(`${label} must be an object of string values.`);
|
|
82
|
+
}
|
|
83
|
+
return { ...value };
|
|
84
|
+
}
|
|
85
|
+
function rejectUnknownKeys(value, allowed, label) {
|
|
86
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
87
|
+
if (unknown.length > 0)
|
|
88
|
+
throw new Error(`${label} contains unsupported fields: ${unknown.join(", ")}.`);
|
|
89
|
+
}
|
|
90
|
+
function isRecord(value) {
|
|
91
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
92
|
+
}
|
|
@@ -30,7 +30,12 @@ export async function fixture(t, options = {}) {
|
|
|
30
30
|
const project = join(root, "project");
|
|
31
31
|
const agentDir = join(root, "agent");
|
|
32
32
|
const stateDir = join(root, ".state");
|
|
33
|
+
const configDir = join(root, ".config");
|
|
33
34
|
await mkdir(join(project, ".forgerelay", "agents"), { recursive: true });
|
|
35
|
+
await mkdir(configDir, { recursive: true });
|
|
36
|
+
if (options.userConfig) {
|
|
37
|
+
await writeFile(join(configDir, "config.json"), JSON.stringify(options.userConfig, null, 2) + "\n");
|
|
38
|
+
}
|
|
34
39
|
await mkdir(agentDir, { recursive: true });
|
|
35
40
|
await writeFile(join(agentDir, "AGENTS.md"), "global instructions\n");
|
|
36
41
|
await writeFile(join(project, "AGENTS.md"), "project instructions\n");
|
|
@@ -51,7 +56,7 @@ export async function fixture(t, options = {}) {
|
|
|
51
56
|
await git(project, ["commit", "-m", "Initial commit"]);
|
|
52
57
|
}
|
|
53
58
|
const loadedConfig = loadConfig({
|
|
54
|
-
FORGERELAY_CONFIG_DIR:
|
|
59
|
+
FORGERELAY_CONFIG_DIR: configDir,
|
|
55
60
|
FORGERELAY_STATE_DIR: stateDir,
|
|
56
61
|
FORGERELAY_ALLOWED_ROOTS: root,
|
|
57
62
|
FORGERELAY_WORKTREE_ROOT: join(root, ".worktrees"),
|
package/dist/server.js
CHANGED
|
@@ -15,6 +15,7 @@ import { HookRunner } from "./mcp/hooks/hooks.js";
|
|
|
15
15
|
import { checkHookConfiguration } from "./mcp/hooks/hook-cli.js";
|
|
16
16
|
import { buildExecutionShellContext, buildServerInstructions, buildToolDescriptions, toolNames } from "./mcp/server-instructions.js";
|
|
17
17
|
import { IncomingArtifactAdapterRegistry } from "./mcp/artifacts/incoming-artifacts.js";
|
|
18
|
+
import { createExternalMcpCapabilityRuntime } from "./mcp/operations/external-mcp/external-mcp-runtime.js";
|
|
18
19
|
import { registerProcessTools } from "./mcp/process/tools.js";
|
|
19
20
|
import { attachCompletedProcessNotices, recordBashCompletion } from "./mcp/process/runtime.js";
|
|
20
21
|
import { CompositeActivityCoordinator } from "./workspaces/composite/composite-activity.js";
|
|
@@ -46,7 +47,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
46
47
|
const activityPanelApp = createActivityPanelApp(config, FORGERELAY_VERSION);
|
|
47
48
|
const ownsRemoteWorkspaces = options.remoteWorkspaces === undefined;
|
|
48
49
|
const remoteWorkspaces = options.remoteWorkspaces
|
|
49
|
-
?? new RemoteWorkspaceRelay(config.configDir, config.stateDir);
|
|
50
|
+
?? new RemoteWorkspaceRelay(config.configDir, config.stateDir, config.mediaMaxBytes);
|
|
50
51
|
const compositeWorkspaces = options.compositeWorkspaces
|
|
51
52
|
?? new CompositeWorkspaceRegistry(config.stateDir);
|
|
52
53
|
const workspaceTasks = new WorkspaceTaskStore(config.stateDir);
|
|
@@ -128,11 +129,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
128
129
|
const incomingArtifactRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
|
|
129
130
|
const artifactDownloadAvailable = config.artifactsEnabled && isArtifactDownloadSupportedPlatform();
|
|
130
131
|
const reviewChangesAvailable = config.widgets === "changes";
|
|
132
|
+
const externalMcpCapability = createExternalMcpCapabilityRuntime(config);
|
|
131
133
|
let batchExecutor;
|
|
132
134
|
const batchExecuteAvailable = config.toolMode !== "codex";
|
|
133
135
|
const capabilityRegistry = createCapabilityRegistry({
|
|
134
136
|
inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
|
|
135
137
|
...subagentMcp.registryDependencies,
|
|
138
|
+
externalMcp: externalMcpCapability,
|
|
136
139
|
workspaceRecovery: {
|
|
137
140
|
available: true,
|
|
138
141
|
run: async (input, context) => ({
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
export function capabilityActivityAuditRequest(input) {
|
|
2
|
+
if (input.name === "mcp.external") {
|
|
3
|
+
const argumentsValue = isAuditRecord(input.arguments) ? input.arguments : {};
|
|
4
|
+
const callArguments = isAuditRecord(argumentsValue.arguments) ? argumentsValue.arguments : undefined;
|
|
5
|
+
return {
|
|
6
|
+
workspaceId: input.workspaceId,
|
|
7
|
+
name: input.name,
|
|
8
|
+
action: "run",
|
|
9
|
+
arguments: {
|
|
10
|
+
operation: argumentsValue.operation,
|
|
11
|
+
...(typeof argumentsValue.server === "string" ? { server: argumentsValue.server } : {}),
|
|
12
|
+
...(typeof argumentsValue.tool === "string" ? { tool: argumentsValue.tool } : {}),
|
|
13
|
+
...(callArguments ? { argumentKeys: Object.keys(callArguments).sort() } : {}),
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
2
17
|
if (input.name !== "subagent.session") {
|
|
3
18
|
return {
|
|
4
19
|
workspaceId: input.workspaceId,
|
|
@@ -24,6 +39,23 @@ export function capabilityActivityAuditRequest(input) {
|
|
|
24
39
|
};
|
|
25
40
|
}
|
|
26
41
|
export function capabilityActivityAuditResult(name, result) {
|
|
42
|
+
if (name === "mcp.external" && isAuditRecord(result)) {
|
|
43
|
+
const structuredContent = isAuditRecord(result.structuredContent) ? result.structuredContent : undefined;
|
|
44
|
+
const capabilityResult = structuredContent && isAuditRecord(structuredContent.result)
|
|
45
|
+
? structuredContent.result
|
|
46
|
+
: undefined;
|
|
47
|
+
const error = structuredContent && isAuditRecord(structuredContent.error)
|
|
48
|
+
? structuredContent.error
|
|
49
|
+
: undefined;
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
action: "run",
|
|
53
|
+
...(capabilityResult ? { result: summarizeExternalMcpCapabilityResult(capabilityResult) } : {}),
|
|
54
|
+
...(error
|
|
55
|
+
? { error: { code: error.code } }
|
|
56
|
+
: {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
27
59
|
if (name !== "subagent.session" || !isAuditRecord(result))
|
|
28
60
|
return result;
|
|
29
61
|
const structuredContent = isAuditRecord(result.structuredContent) ? result.structuredContent : undefined;
|
|
@@ -50,6 +82,55 @@ export function capabilityActivityAuditResult(name, result) {
|
|
|
50
82
|
function isAuditRecord(value) {
|
|
51
83
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
84
|
}
|
|
85
|
+
function summarizeExternalMcpCapabilityResult(result) {
|
|
86
|
+
const summary = {
|
|
87
|
+
operation: result.operation,
|
|
88
|
+
...(typeof result.server === "string" ? { server: result.server } : {}),
|
|
89
|
+
...(typeof result.tool === "string" ? { tool: result.tool } : {}),
|
|
90
|
+
};
|
|
91
|
+
if (Array.isArray(result.servers)) {
|
|
92
|
+
summary.servers = result.servers.flatMap((entry) => {
|
|
93
|
+
if (!isAuditRecord(entry) || typeof entry.name !== "string")
|
|
94
|
+
return [];
|
|
95
|
+
return [{ name: entry.name, transport: entry.transport }];
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(result.tools)) {
|
|
99
|
+
summary.tools = result.tools.flatMap((entry) => isAuditRecord(entry) && typeof entry.name === "string" ? [entry.name] : []);
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(result.content)) {
|
|
102
|
+
summary.contentTypes = result.content.flatMap((entry) => isAuditRecord(entry) && typeof entry.type === "string" ? [entry.type] : []);
|
|
103
|
+
const media = result.content.flatMap((entry, index) => {
|
|
104
|
+
if (!isAuditRecord(entry)
|
|
105
|
+
|| entry.type !== "image"
|
|
106
|
+
|| typeof entry.mimeType !== "string"
|
|
107
|
+
|| typeof entry.bytes !== "number")
|
|
108
|
+
return [];
|
|
109
|
+
return [{ index, mimeType: entry.mimeType, bytes: entry.bytes }];
|
|
110
|
+
});
|
|
111
|
+
if (media.length > 0)
|
|
112
|
+
summary.media = media;
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(result.transforms)) {
|
|
115
|
+
const transforms = result.transforms.flatMap((entry) => {
|
|
116
|
+
if (!isAuditRecord(entry)
|
|
117
|
+
|| (entry.phase !== "request" && entry.phase !== "result")
|
|
118
|
+
|| typeof entry.name !== "string"
|
|
119
|
+
|| (entry.scope !== "global" && entry.scope !== "project")
|
|
120
|
+
|| entry.status !== "passed")
|
|
121
|
+
return [];
|
|
122
|
+
return [{
|
|
123
|
+
phase: entry.phase,
|
|
124
|
+
name: entry.name.slice(0, 200),
|
|
125
|
+
scope: entry.scope,
|
|
126
|
+
status: "passed",
|
|
127
|
+
}];
|
|
128
|
+
});
|
|
129
|
+
if (transforms.length > 0)
|
|
130
|
+
summary.transforms = transforms;
|
|
131
|
+
}
|
|
132
|
+
return summary;
|
|
133
|
+
}
|
|
53
134
|
function summarizeSubagentCapabilityResult(result) {
|
|
54
135
|
const summary = { operation: result.operation };
|
|
55
136
|
const session = isAuditRecord(result.session) ? result.session : undefined;
|
|
@@ -108,6 +108,25 @@ export function toolResultText(result) {
|
|
|
108
108
|
.map((entry) => entry.text)
|
|
109
109
|
.join("\n") || "remote tool returned an error";
|
|
110
110
|
}
|
|
111
|
+
export function enforceRelayedMediaBudget(result, maxBytes) {
|
|
112
|
+
let remainingBytes = maxBytes;
|
|
113
|
+
for (const entry of result.content ?? []) {
|
|
114
|
+
if (entry.type !== "image")
|
|
115
|
+
continue;
|
|
116
|
+
const bytes = Buffer.byteLength(entry.data, "base64");
|
|
117
|
+
if (bytes > remainingBytes) {
|
|
118
|
+
return {
|
|
119
|
+
content: [{
|
|
120
|
+
type: "text",
|
|
121
|
+
text: `Relayed media content exceeds the Gateway media limit of ${maxBytes} bytes.`,
|
|
122
|
+
}],
|
|
123
|
+
isError: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
remainingBytes -= bytes;
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
111
130
|
export function remapToolResultWorkspaceId(result, remoteWorkspaceId, gatewayWorkspaceId) {
|
|
112
131
|
return {
|
|
113
132
|
...result,
|
|
@@ -67,6 +67,8 @@ export async function startForge(t, options) {
|
|
|
67
67
|
stateDir,
|
|
68
68
|
worktreeRoot: join(options.root, "worktrees"),
|
|
69
69
|
...(options.taskReminderInterval !== undefined ? { taskReminderInterval: options.taskReminderInterval } : {}),
|
|
70
|
+
...(options.mediaMaxBytes !== undefined ? { mediaMaxBytes: options.mediaMaxBytes } : {}),
|
|
71
|
+
...(options.mcpServers ? { mcpServers: options.mcpServers } : {}),
|
|
70
72
|
...(options.hooks ? { hooks: options.hooks } : {}),
|
|
71
73
|
}, null, 2));
|
|
72
74
|
const env = {
|
|
@@ -104,6 +106,7 @@ export async function startGatewayClient(t, options) {
|
|
|
104
106
|
await writeFile(join(options.configDir, "config.json"), JSON.stringify({
|
|
105
107
|
allowedRoots: [options.allowedRoot],
|
|
106
108
|
stateDir,
|
|
109
|
+
...(options.mediaMaxBytes !== undefined ? { mediaMaxBytes: options.mediaMaxBytes } : {}),
|
|
107
110
|
...(options.hooks ? { hooks: options.hooks } : {}),
|
|
108
111
|
}, null, 2));
|
|
109
112
|
const config = loadConfig({
|
|
@@ -7,15 +7,17 @@ import { RemoteMcpConnectionPool } from "./transport/remote-mcp-connection-pool.
|
|
|
7
7
|
import { withFileLock } from "../../runtime/state/lock/file-lock.js";
|
|
8
8
|
import { withRemoteServiceEndpoint } from "./transport/remote-transport.js";
|
|
9
9
|
import { loadForgeRelayFiles, writeForgeRelayRemote, } from "../../runtime/config/user-config.js";
|
|
10
|
-
import { assertRemoteToolSucceeded, copyBooleanField, copyNumberField, copyStringField, errorMessage, remapToolResultWorkspaceId, replaceExactWorkspaceId, safeManagedWorktreeRecovery, safeTaskSummary, sanitizedRemoteError, stringField, toolResultText, } from "./result-support.js";
|
|
10
|
+
import { assertRemoteToolSucceeded, copyBooleanField, copyNumberField, copyStringField, enforceRelayedMediaBudget, errorMessage, remapToolResultWorkspaceId, replaceExactWorkspaceId, safeManagedWorktreeRecovery, safeTaskSummary, sanitizedRemoteError, stringField, toolResultText, } from "./result-support.js";
|
|
11
11
|
export class RemoteWorkspaceRelay {
|
|
12
|
+
mediaMaxBytes;
|
|
12
13
|
routes = new Map();
|
|
13
14
|
turnRoutes = new Map();
|
|
14
15
|
authEnv;
|
|
15
16
|
routeStateDir;
|
|
16
17
|
routeStatePath;
|
|
17
18
|
mcpConnections = new RemoteMcpConnectionPool();
|
|
18
|
-
constructor(configDir, stateDir) {
|
|
19
|
+
constructor(configDir, stateDir, mediaMaxBytes) {
|
|
20
|
+
this.mediaMaxBytes = mediaMaxBytes;
|
|
19
21
|
this.authEnv = { FORGERELAY_CONFIG_DIR: configDir };
|
|
20
22
|
this.routeStateDir = stateDir;
|
|
21
23
|
this.routeStatePath = join(stateDir, "remote-workspace-routes.json");
|
|
@@ -184,7 +186,7 @@ export class RemoteWorkspaceRelay {
|
|
|
184
186
|
return result;
|
|
185
187
|
}
|
|
186
188
|
async read(gatewayWorkspaceId, input, conversationScopeId) {
|
|
187
|
-
return this.callWorkspaceTool(gatewayWorkspaceId, "read", input, conversationScopeId);
|
|
189
|
+
return enforceRelayedMediaBudget(await this.callWorkspaceTool(gatewayWorkspaceId, "read", input, conversationScopeId), this.mediaMaxBytes);
|
|
188
190
|
}
|
|
189
191
|
async write(gatewayWorkspaceId, input, conversationScopeId) {
|
|
190
192
|
return this.callWorkspaceTool(gatewayWorkspaceId, "write", input, conversationScopeId);
|
|
@@ -214,7 +216,7 @@ export class RemoteWorkspaceRelay {
|
|
|
214
216
|
return this.callWorkspaceTool(gatewayWorkspaceId, "apply_patch", input, conversationScopeId);
|
|
215
217
|
}
|
|
216
218
|
async capability(gatewayWorkspaceId, input, conversationScopeId) {
|
|
217
|
-
return this.callWorkspaceTool(gatewayWorkspaceId, "capability", input, conversationScopeId);
|
|
219
|
+
return enforceRelayedMediaBudget(await this.callWorkspaceTool(gatewayWorkspaceId, "capability", input, conversationScopeId), this.mediaMaxBytes);
|
|
218
220
|
}
|
|
219
221
|
async activityPanel(gatewayWorkspaceId, conversationScopeId) {
|
|
220
222
|
const result = await this.callWorkspaceTool(gatewayWorkspaceId, "activity_panel", {}, conversationScopeId);
|
package/docs/configuration.md
CHANGED
|
@@ -146,6 +146,45 @@ A single persisted string remains fully supported, so existing configs require n
|
|
|
146
146
|
migration. For environment configuration, use a comma-separated list in
|
|
147
147
|
`FORGERELAY_PUBLIC_BASE_URL`.
|
|
148
148
|
|
|
149
|
+
## Media content and external MCP
|
|
150
|
+
|
|
151
|
+
The existing `read` tool recognizes PNG, JPEG, WebP, and GIF by file signature and returns the original bytes as standard MCP `ImageContent`. Image reads do not use filename extensions for recognition, and text-only `offset` / `limit` arguments are rejected for image targets.
|
|
152
|
+
|
|
153
|
+
Inline media uses a separate decoded-byte budget:
|
|
154
|
+
|
|
155
|
+
| Variable | Default | Purpose |
|
|
156
|
+
| --- | --- | --- |
|
|
157
|
+
| `FORGERELAY_MEDIA_MAX_BYTES` | `20971520` | Maximum aggregate decoded image bytes in one Host-facing tool result (20 MiB). |
|
|
158
|
+
|
|
159
|
+
The same value may be persisted as `mediaMaxBytes` in `config.json`. The budget applies at each ForgeRelay media ingress boundary, including local Read, Workspace Relay Gateway delivery, and configured external MCP results. ForgeRelay fails an oversize result instead of truncating, resizing, recompressing, transcoding, or silently creating an Artifact.
|
|
160
|
+
|
|
161
|
+
Media content is transient. Image base64 is present only in the live MCP result delivered to the Host; Activity/Audit, structured output, UI state, Workspace state, and logs keep bounded metadata such as MIME type and decoded byte size. Reading or forwarding an image does **not** create an Artifact. Artifact download/materialization remains a separate explicit workflow.
|
|
162
|
+
|
|
163
|
+
User-configured MCP servers are registered in `config.json` under `mcpServers` and are exposed through the workspace-scoped `mcp.external` Capability rather than becoming new top-level MCP tools. For example:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"mcpServers": {
|
|
168
|
+
"renderer": {
|
|
169
|
+
"transport": "stdio",
|
|
170
|
+
"command": "node",
|
|
171
|
+
"args": ["/opt/renderer/server.mjs"]
|
|
172
|
+
},
|
|
173
|
+
"remote-renderer": {
|
|
174
|
+
"transport": "streamable-http",
|
|
175
|
+
"url": "https://renderer.example.com/mcp",
|
|
176
|
+
"headers": {
|
|
177
|
+
"Authorization": "Bearer <token>"
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The Agent explicitly selects the configured server, advertised tool, and arguments through `capability(name="mcp.external", action="run", ...)`. Direct upstream PNG/JPEG/WebP/GIF `ImageContent` is validated and forwarded without decode/re-encode. Text, paths, URLs, `resource_link` values, and other references remain exactly that by default: ForgeRelay does not automatically fetch them, call `read`, infer that they refer to an image, or create an Artifact.
|
|
185
|
+
|
|
186
|
+
A known server/tool may opt into explicit request/result adaptation with `ExternalMcpBeforeForward` or `ExternalMcpAfterForward` project/global Hooks. These transforms use the structured versioned stdin/stdout protocol described under [Lifecycle hooks](#lifecycle-hooks); ordinary Hook stdout never rewrites MCP data. For example, a project can match one renderer tool whose normal result is a path and deliberately convert that result into `ImageContent`. The Hook command performs any file/network access with its normal local-user authority, and the transformed result still passes through the same MCP shape, MIME/base64, and `mediaMaxBytes` validation before Host delivery.
|
|
187
|
+
|
|
149
188
|
## Native artifact download
|
|
150
189
|
|
|
151
190
|
Native-file download is disabled by default. Enable it with:
|
|
@@ -553,7 +592,7 @@ Hooks v1 是自动生命周期规则。规则由用户或 Agent 主动写入;
|
|
|
553
592
|
|
|
554
593
|
| 字段 | 含义 |
|
|
555
594
|
| --- | --- |
|
|
556
|
-
| `event` |
|
|
595
|
+
| `event` | 必填,当前 Hook event 之一。 |
|
|
557
596
|
| `matcher` | 可选,只在匹配当前生命周期上下文时执行。 |
|
|
558
597
|
| `command` | 必填,本地 shell 命令。 |
|
|
559
598
|
| `timeoutSeconds` | 默认 `30`,范围 `1` 到 `300`。 |
|
|
@@ -587,6 +626,11 @@ forgerelay hooks check --project /path/to/project
|
|
|
587
626
|
| `pathRegex` | 对 payload 中的 `path` 或 `paths` 做正则匹配。 |
|
|
588
627
|
| `provider` | 精确匹配 subagent provider。 |
|
|
589
628
|
| `workspaceMode` | `checkout` 或 `worktree`。 |
|
|
629
|
+
| `capability` | 精确匹配 Capability 名;external MCP transform 使用 `mcp.external`。 |
|
|
630
|
+
| `externalServer` | 精确匹配已经配置的 external MCP server 名。 |
|
|
631
|
+
| `externalTool` | 精确匹配该 server 当前调用的 upstream tool 名。 |
|
|
632
|
+
|
|
633
|
+
`capability` / `externalServer` / `externalTool` 只匹配当前已选择的 Capability target,不允许 Hook 输出改写 server/tool 或提供新的连接地址、命令、凭据。
|
|
590
634
|
|
|
591
635
|
Matcher 匹配 ForgeRelay 收到的那次 tool request,不会窥探该命令内部后续启动的子进程。例如 `bash` 参数本身是 `git push origin v0.2.0` 时可以命中;若参数只是 `./release.sh`,而脚本内部再执行 `git push`,ForgeRelay 不会把内部子进程重新解释成新的 Hook 事件。
|
|
592
636
|
|
|
@@ -600,13 +644,17 @@ Matcher 匹配 ForgeRelay 收到的那次 tool request,不会窥探该命令
|
|
|
600
644
|
| `BeforeTool` | workspace-scoped MCP tool 执行前触发;失败或超时会阻断原操作。`open_workspace` 因执行前还没有 workspace,不走该事件。 |
|
|
601
645
|
| `AfterTool` | tool 成功后触发。 |
|
|
602
646
|
| `AfterToolFailure` | tool 失败或被 `BeforeTool` 拒绝后触发。 |
|
|
647
|
+
| `ExternalMcpBeforeForward` | 已选择 `mcp.external` server/tool 后、upstream 调用前触发;使用 structured transform 协议,可替换当前 request arguments,但不能改写 server/tool。失败会阻止 upstream 调用。 |
|
|
648
|
+
| `ExternalMcpAfterForward` | upstream external MCP 调用成功后触发;使用 structured transform 协议,可替换当前 MCP result。替换结果重新经过标准 MCP/Media 校验。失败会令 Capability 失败,但不会声称回滚 upstream 已发生的副作用。 |
|
|
603
649
|
| `AfterFileChange` | `write`、`edit`、`rename`、`delete`、`apply_patch`、native artifact 等明确文件变更成功后触发;不会推断 shell 的文件副作用。 |
|
|
604
650
|
| `BeforeWorktreeClose` | worktree commit、fast-forward、cleanup 前触发;失败会保留 worktree 并阻断 close。 |
|
|
605
651
|
| `AfterWorktreeClose` | managed worktree 成功关闭后触发;此时从 source checkout 运行。 |
|
|
606
652
|
| `SubagentStart` | 本地 subagent worker 进入执行时触发。 |
|
|
607
653
|
| `SubagentStop` | subagent 完成或进入 error 状态时触发。 |
|
|
608
654
|
|
|
609
|
-
`BeforeTool` 与 `BeforeWorktreeClose`
|
|
655
|
+
`BeforeTool` 与 `BeforeWorktreeClose` 是普通 lifecycle blocking 事件。`ExternalMcpBeforeForward` 也是 external-MCP forwarding 的前置阻断点;`ExternalMcpAfterForward` 则发生在 upstream 已成功返回之后,因此它只能阻止变换后结果继续交付,不能回滚或伪装撤销 upstream 副作用。其他 after-events 是 observational:失败会被记录并报告,但不会回滚已经完成的文件、Git、进程或网络副作用。Blocking 同样不是事务;Hook 命令自己已经产生的副作用不会因 exit code 非零而撤销。Host 在前置 blocking Hook 仍运行时取消 MCP request,会终止该 Hook 并阻止对应原始 operation 开始。
|
|
656
|
+
|
|
657
|
+
External MCP transform 的可变数据不通过普通 Hook stdout 约定。ForgeRelay 把一个 `version: 1` JSON envelope 写到 transform Hook 的 stdin,并只接受 stdout 中一个合法 JSON envelope:before-forward 返回 `arguments`,after-forward 返回 `result`。这一协议只对两个 External MCP transform event 生效;现有 lifecycle Hook stdout 仍只是命令输出。
|
|
610
658
|
|
|
611
659
|
### Agent 可见报告
|
|
612
660
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akira-tl/forgerelay",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Local development control plane for MCP coding agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Akira-TL/forgerelay#readme",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"dev": "node scripts/debug/serve.mjs",
|
|
40
40
|
"debug:serve": "node scripts/debug/serve.mjs",
|
|
41
41
|
"debug:accept": "node scripts/debug/accept.mjs",
|
|
42
|
+
"debug:accept:media": "node scripts/debug/accept/media.mjs",
|
|
42
43
|
"debug:accept:relay": "node scripts/debug/relay-accept.mjs",
|
|
43
44
|
"traffic:audit": "node --import tsx scripts/debug/traffic/traffic-audit.mjs",
|
|
44
45
|
"lsp:interop": "node scripts/lsp/interop.mjs",
|
|
@@ -192,6 +192,8 @@ export async function runBootstrapAcceptance({ server, packageJson, ownerToken,
|
|
|
192
192
|
"capability-guides.read",
|
|
193
193
|
"code.intelligence",
|
|
194
194
|
"workspace.tasks",
|
|
195
|
+
"workspace.recovery",
|
|
196
|
+
"workspace.checkpoint",
|
|
195
197
|
"batch.execute",
|
|
196
198
|
...(process.platform === "linux" ? ["artifact.native-download"] : []),
|
|
197
199
|
"ui.mcp-app",
|
|
@@ -203,6 +205,7 @@ export async function runBootstrapAcceptance({ server, packageJson, ownerToken,
|
|
|
203
205
|
"hooks.check",
|
|
204
206
|
"review.changes",
|
|
205
207
|
"code.intelligence",
|
|
208
|
+
"workspace.checkpoint",
|
|
206
209
|
"workspace.tasks",
|
|
207
210
|
"batch.execute",
|
|
208
211
|
...(process.platform === "linux" ? ["artifact.download"] : []),
|
|
@@ -56,7 +56,7 @@ export async function createAcceptanceHarness() {
|
|
|
56
56
|
encoding: "utf8",
|
|
57
57
|
});
|
|
58
58
|
assert.equal(doctor.status, 0, doctor.stderr);
|
|
59
|
-
assert.match(doctor.stdout, /
|
|
59
|
+
assert.match(doctor.stdout, /Client-facing base URL: http:\/\/127\.0\.0\.1:7677/);
|
|
60
60
|
assert.match(doctor.stdout, /Tool mode: full/);
|
|
61
61
|
assert.match(doctor.stdout, /Widgets: changes/);
|
|
62
62
|
assert.match(doctor.stdout, /Trust proxy: off/);
|